Compare commits

..

No commits in common. "master" and "edit-punishments" have entirely different histories.

129 changed files with 1052 additions and 4051 deletions

2
.gitignore vendored
View File

@ -91,5 +91,3 @@ generated
liquibase*.properties liquibase*.properties
node node
.env

View File

@ -51,14 +51,9 @@ public class SecurityConfig {
.requestMatchers("/api/form/**").authenticated() .requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated() .requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/appeal").authenticated()
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
//TODO allow users access to their own folder
.requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll() .requestMatchers("/api/login/userLogin/**").permitAll()
.anyRequest().permitAll() .anyRequest().permitAll()

View File

@ -1,7 +1,5 @@
package com.alttd.altitudeweb.controllers.data_from_auth; package com.alttd.altitudeweb.controllers.data_from_auth;
import com.nimbusds.jwt.JWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -10,10 +8,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Slf4j
@Service @Service
public class AuthenticatedUuid { public class AuthenticatedUuid {
@Value("${UNSECURED:#{false}}") @Value("${UNSECURED:#{false}}")
@ -29,9 +25,6 @@ public class AuthenticatedUuid {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
log.error("Authentication principal is null {} or not a JWT {}",
authentication == null, authentication == null ?
"null" : authentication.getPrincipal() instanceof JWT);
if (unsecured) { if (unsecured) {
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"); return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
} }
@ -46,28 +39,4 @@ public class AuthenticatedUuid {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
} }
} }
/**
* Extracts the authenticated user's UUID from the JWT token.
*
* @return The UUID of the authenticated user
*/
public Optional<UUID> tryGetAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
if (unsecured) {
return Optional.of(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"));
}
return Optional.empty();
}
String stringUuid = jwt.getSubject();
try {
return Optional.of(UUID.fromString(stringUuid));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
} }

View File

@ -10,14 +10,15 @@ import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification; import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper; import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper; import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.*; import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.services.forms.DiscordAppeal; import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail; import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -35,19 +36,12 @@ public class AppealController implements AppealsApi {
private final AppealDataMapper mapper; private final AppealDataMapper mapper;
private final AppealMail appealMail; private final AppealMail appealMail;
private final DiscordAppeal discordAppeal;
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord; private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
@Override
public ResponseEntity<BannedUserResponseDto> getBannedUser(String discordId) throws Exception {
long discordIdAsLong = Long.parseLong(discordId);
return new ResponseEntity<>(discordAppeal.getBannedUser(discordIdAsLong), HttpStatus.OK);
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override @Override
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) { public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
return new ResponseEntity<>(discordAppeal.submitAppeal(discordAppealDto), HttpStatus.OK); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
} }
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")

View File

@ -242,8 +242,6 @@ public class HistoryApiController implements HistoryApi {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>(); CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try { try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
@ -255,6 +253,7 @@ public class HistoryApiController implements HistoryApi {
} }
int changed = editMapper.setReason(historyTypeEnum, id, reason); int changed = editMapper.setReason(historyTypeEnum, id, reason);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})", log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed); actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null); result.complete(after != null ? mapPunishmentHistory(after) : null);
@ -276,8 +275,6 @@ public class HistoryApiController implements HistoryApi {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>(); CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try { try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
@ -289,6 +286,7 @@ public class HistoryApiController implements HistoryApi {
} }
int changed = editMapper.setUntil(historyTypeEnum, id, until); int changed = editMapper.setUntil(historyTypeEnum, id, until);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})", log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed); actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null); result.complete(after != null ? mapPunishmentHistory(after) : null);
@ -313,8 +311,6 @@ public class HistoryApiController implements HistoryApi {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Boolean> result = new CompletableFuture<>(); CompletableFuture<Boolean> result = new CompletableFuture<>();
final UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try { try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
@ -324,6 +320,7 @@ public class HistoryApiController implements HistoryApi {
result.complete(false); result.complete(false);
return; return;
} }
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString()); String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
int changed = editMapper.remove(historyTypeEnum, id); int changed = editMapper.remove(historyTypeEnum, id);
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})", log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",

View File

@ -210,16 +210,15 @@ public class LoginController implements LoginApi {
try { try {
log.debug("Loading user by uuid {}", uuid.toString()); log.debug("Loading user by uuid {}", uuid.toString());
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class); PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
Optional<PrivilegedUser> optionalPrivilegedUser = mapper Optional<PrivilegedUser> privilegedUser = mapper
.getUserByUuid(uuid); .getUserByUuid(uuid);
if (optionalPrivilegedUser.isEmpty()) { if (privilegedUser.isEmpty()) {
PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of()); int privilegedUserId = mapper.createPrivilegedUser(uuid);
mapper.createPrivilegedUser(privilegedUser);
privilegedUserCompletableFuture.complete( privilegedUserCompletableFuture.complete(
Optional.of(privilegedUser)); Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
} else { } else {
privilegedUserCompletableFuture.complete(optionalPrivilegedUser); privilegedUserCompletableFuture.complete(privilegedUser);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to load user by uuid", e); log.error("Failed to load user by uuid", e);

View File

@ -24,27 +24,34 @@ import java.nio.file.Path;
@RestController @RestController
public class ParticleController implements ParticlesApi { public class ParticleController implements ParticlesApi {
// @Value("${login.secret:#{null}}") @Value("${login.secret:#{null}}")
// private String loginSecret; private String loginSecret;
@Value("${particles.file_path}") @Value("${particles.file_path}")
private String particlesFilePath; private String particlesFilePath;
// @Value("${notification.server.url:http://localhost:8080}") @Value("${notification.server.url:http://localhost:8080}")
// private String notificationServerUrl; private String notificationServerUrl;
@Override @Override
public ResponseEntity<Resource> downloadFile(String filename) { public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath); File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) { if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath); log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
return ResponseEntity.status(404).build(); return ResponseEntity.status(404).build();
} }
return getFileForDownload(file, filename); File targetFile = new File(file, filename);
return getFileForDownload(targetFile, filename);
} }
@Override @Override
public ResponseEntity<Resource> downloadFileForUser(String uuid, String filename) { public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath); File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) { if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath); log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
@ -60,7 +67,6 @@ public class ParticleController implements ParticlesApi {
} }
private ResponseEntity<Resource> getFileForDownload(File file, String filename) { private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
filename += ".json";
File targetFile = new File(file, filename); File targetFile = new File(file, filename);
if (!targetFile.exists()) { if (!targetFile.exists()) {
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath()); log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
@ -89,19 +95,19 @@ public class ParticleController implements ParticlesApi {
} }
@Override @Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) { public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath); File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) { if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath); log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
return ResponseEntity.status(404).build(); return ResponseEntity.status(404).build();
} }
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content); ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
// notifyServerOfFileUpload(filename); notifyServerOfFileUpload(filename);
return voidResponseEntity; return voidResponseEntity;
} }
@Override @Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) { public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath); File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) { if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath); log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
@ -116,39 +122,38 @@ public class ParticleController implements ParticlesApi {
} }
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content); ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
// notifyServerOfFileUpload(uuid, filename); notifyServerOfFileUpload(uuid, filename);
return voidResponseEntity; return voidResponseEntity;
} }
// private void notifyServerOfFileUpload(String filename) { private void notifyServerOfFileUpload(String filename) {
// String notificationUrl = String.format("http://%s/notify/%s.json", notificationServerUrl, filename); String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
// sendNotification(notificationUrl, String.format("file upload: %s", filename)); sendNotification(notificationUrl, String.format("file upload: %s", filename));
// } }
//
// private void notifyServerOfFileUpload(String uuid, String filename) { private void notifyServerOfFileUpload(String uuid, String filename) {
// String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename); String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
// sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename)); sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
// } }
//
// private void sendNotification(String notificationUrl, String logDescription) { private void sendNotification(String notificationUrl, String logDescription) {
// try { try {
// RestTemplate restTemplate = new RestTemplate(); RestTemplate restTemplate = new RestTemplate();
// ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class); ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
//
// if (response.getStatusCode().is2xxSuccessful()) { if (response.getStatusCode().is2xxSuccessful()) {
// log.info("Successfully notified server of {}", logDescription); log.info("Successfully notified server of {}", logDescription);
// } else { } else {
// log.warn("Failed to notify server of {}, status: {}", log.warn("Failed to notify server of {}, status: {}",
// logDescription, response.getStatusCode()); logDescription, response.getStatusCode());
// } }
// } catch (Exception e) { } catch (Exception e) {
// log.error("Error notifying server of {}", logDescription, e); log.error("Error notifying server of {}", logDescription, e);
// } }
// } }
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) { private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
filename += ".json";
File targetFile = new File(dir, filename); File targetFile = new File(dir, filename);
if (!Files.isWritable(targetFile.toPath())) { if (!Files.isWritable(targetFile.toPath())) {
log.error("Particles file {} is not writable", targetFile.getAbsolutePath()); log.error("Particles file {} is not writable", targetFile.getAbsolutePath());

View File

@ -2,21 +2,15 @@ package com.alttd.altitudeweb.controllers.site;
import com.alttd.altitudeweb.api.SiteApi; import com.alttd.altitudeweb.api.SiteApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid; import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import com.alttd.altitudeweb.model.StaffPlaytimeListDto;
import com.alttd.altitudeweb.model.VoteDataDto; import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteStatsDto; import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.site.StaffPtService;
import com.alttd.altitudeweb.services.site.VoteService; import com.alttd.altitudeweb.services.site.VoteService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -29,22 +23,8 @@ public class SiteController implements SiteApi {
private final VoteService voteService; private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid; private final AuthenticatedUuid authenticatedUuid;
private final StaffPtService staffPtService;
@Override @Override
@RateLimit(limit = 1, timeValue = 1, timeUnit = TimeUnit.SECONDS, key = "getStaffPlaytime")
public ResponseEntity<StaffPlaytimeListDto> getStaffPlaytime(OffsetDateTime from, OffsetDateTime to) {
Optional<List<StaffPlaytimeDto>> staffPlaytimeDto = staffPtService.getStaffPlaytime(from.toInstant(), to.toInstant());
if (staffPlaytimeDto.isEmpty()) {
return ResponseEntity.noContent().build();
}
StaffPlaytimeListDto staffPlaytimeListDto = new StaffPlaytimeListDto();
staffPlaytimeListDto.addAll(staffPlaytimeDto.get());
return ResponseEntity.ok(staffPlaytimeListDto);
}
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "getVoteStats")
public ResponseEntity<VoteDataDto> getVoteStats() { public ResponseEntity<VoteDataDto> getVoteStats() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid); Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);

View File

@ -1,14 +0,0 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.model.BannedUserDto;
import com.alttd.webinterface.appeals.BannedUser;
import org.springframework.stereotype.Service;
@Service
public class BannedUserToBannedUserDtoMapper {
public BannedUserDto map(BannedUser bannedUser) {
return new BannedUserDto(String.valueOf(bannedUser.userId()), bannedUser.reason(), bannedUser.name(), bannedUser.avatarUrl());
}
}

View File

@ -1,26 +0,0 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class DiscordAppealDtoToDiscordAppealMapper {
public DiscordAppeal map(DiscordAppealDto discordAppealDto, UUID loggedInUserUuid, String discordUsername) {
return new DiscordAppeal(
UUID.randomUUID(),
loggedInUserUuid,
Long.parseLong(discordAppealDto.getDiscordId()),
discordUsername,
discordAppealDto.getAppeal(),
Instant.now(),
null,
discordAppealDto.getEmail(),
null);
}
}

View File

@ -1,70 +0,0 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Service
public final class StaffPtToStaffPlaytimeMapper {
private record PlaytimeInfo(long totalPlaytime, long lastPlayed) {}
public List<StaffPlaytimeDto> map(List<StaffPt> sessions, List<PlayerWithGroup> staffMembers, long from, long to, HashMap<String, String> staffGroupsMap) {
Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to);
for (PlayerWithGroup staffMember : staffMembers) {
if (!playtimeData.containsKey(staffMember.uuid())) {
playtimeData.put(staffMember.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
}
}
List<StaffPlaytimeDto> results = new ArrayList<>(playtimeData.size());
for (Map.Entry<UUID, PlaytimeInfo> entry : playtimeData.entrySet()) {
long lastPlayedMillis = entry.getValue().lastPlayed() == Long.MIN_VALUE ? 0L : entry.getValue().lastPlayed();
StaffPlaytimeDto dto = new StaffPlaytimeDto();
Optional<PlayerWithGroup> first = staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey())).findFirst();
dto.setStaffMember(first.isPresent() ? first.get().username() : entry.getKey().toString());
dto.setStaffMember(staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey()))
.map(PlayerWithGroup::username)
.findFirst()
.orElse(entry.getKey().toString())
);
dto.setLastPlayed(OffsetDateTime.ofInstant(Instant.ofEpochMilli(lastPlayedMillis), ZoneOffset.UTC));
dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime()));
if (first.isPresent()) {
dto.setRole(staffGroupsMap.getOrDefault(first.get().group(), "Unknown"));
} else {
dto.setRole("Unknown");
}
results.add(dto);
}
return results;
}
private Map<UUID, PlaytimeInfo> getUuidPlaytimeInfoMap(List<StaffPt> sessions, long from, long to) {
Map<UUID, PlaytimeInfo> playtimeData = new HashMap<>();
for (StaffPt session : sessions) {
long overlapStart = Math.max(session.sessionStart(), from);
long overlapEnd = Math.min(session.sessionEnd(), to);
if (overlapEnd <= overlapStart) {
continue;
}
PlaytimeInfo info = playtimeData.getOrDefault(session.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
long totalPlaytime = info.totalPlaytime() + (overlapEnd - overlapStart);
long lastPlayed = Math.max(info.lastPlayed(), overlapEnd);
playtimeData.put(session.uuid(), new PlaytimeInfo(totalPlaytime, lastPlayed));
}
return playtimeData;
}
}

View File

@ -1,8 +1,6 @@
package com.alttd.altitudeweb.services.discord; package com.alttd.altitudeweb.services.discord;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.AppealList;
import com.alttd.altitudeweb.database.discord.AppealListMapper;
import com.alttd.altitudeweb.database.discord.OutputChannel; import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper; import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper; import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
@ -10,12 +8,7 @@ import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType; import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.UserType; import com.alttd.altitudeweb.database.litebans.UserType;
import com.alttd.altitudeweb.database.web_db.forms.Appeal; import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.appeals.AppealSender;
import com.alttd.webinterface.objects.MessageForEmbed;
import com.alttd.webinterface.send_message.DiscordSender; import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -23,7 +16,8 @@ import org.springframework.stereotype.Service;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@Slf4j @Slf4j
@ -32,67 +26,19 @@ public class AppealDiscord {
private static final String OUTPUT_TYPE = "APPEAL"; private static final String OUTPUT_TYPE = "APPEAL";
public void sendAppealToDiscord(DiscordAppeal discordAppeal) {
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
List<OutputChannel> channels = channelsFuture.join();
if (channels.isEmpty()) {
log.warn("Discord appeal: No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
String createdAt = formatInstant(discordAppeal.createdAt());
List<DiscordSender.EmbedField> fields = new ArrayList<>();
// Group: User
fields.add(new DiscordSender.EmbedField(
"User",
"""
Discord Username: `%s`
Discord id: %s
MC UUID: %s
Submitted: %s
""".formatted(
safe(discordAppeal.discordUsername()),
discordAppeal.discordId(),
safe(String.valueOf(discordAppeal.uuid())),
createdAt
),
false
));
Optional<Long> optionalAssignedTo = assignAppeal();
if (optionalAssignedTo.isPresent()) {
Long assignedTo = optionalAssignedTo.get();
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: <@" + assignedTo + ">",
true
));
assignDiscordAppealTo(discordAppeal.id(), assignedTo);
} else {
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: None (failed to assign)",
true
));
}
String description = safe(discordAppeal.reason());
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
// colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = discordAppeal.createdAt() != null ? discordAppeal.createdAt() : Instant.now();
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
"New Discord Appeal Submitted", description, fields, null, timestamp, null);
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
}
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) { public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
// Fetch channels // Fetch channels
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture(); CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid()); CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid()); CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
@ -118,8 +64,9 @@ public class AppealDiscord {
// Group: User // Group: User
fields.add(new DiscordSender.EmbedField( fields.add(new DiscordSender.EmbedField(
"User", "User",
"Username: `" + safe(appeal.username()) + "`\n" + "Username: " + safe(appeal.username()) + "\n" +
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" + "UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
"Email: " + safe(appeal.email()) + "\n" +
"Submitted: " + createdAt, "Submitted: " + createdAt,
false false
)); ));
@ -141,22 +88,6 @@ public class AppealDiscord {
"Kicks: " + kicks, "Kicks: " + kicks,
true true
)); ));
Optional<Long> optionalAssignedTo = assignAppeal();
if (optionalAssignedTo.isPresent()) {
Long assignedTo = optionalAssignedTo.get();
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: <@" + assignedTo + ">",
true
));
assignMinecraftAppealTo(appeal.id(), assignedTo);
} else {
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: None (failed to assign)",
true
));
}
String description = safe(appeal.reason()); String description = safe(appeal.reason());
@ -166,44 +97,15 @@ public class AppealDiscord {
// colorRgb = null (use default), timestamp = appeal.createdAt if available // colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now(); Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
MessageForEmbed newAppealSubmitted = new MessageForEmbed( DiscordSender.getInstance().sendEmbedToChannels(
"New Appeal Submitted", description, fields, null, timestamp, null); channelIds,
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L)); "New Appeal Submitted",
} description,
fields,
private static CompletableFuture<List<OutputChannel>> getChannelListFuture() { null,
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>(); timestamp,
Connection.getConnection(Databases.DISCORD).runQuery(sql -> { null
try { );
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
return channelsFuture;
}
private void assignMinecraftAppealTo(UUID appealId, Long assignedTo) {
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
try {
sql.getMapper(AppealMapper.class).assignAppeal(appealId, assignedTo);
} catch (Exception e) {
log.error("Failed to assign appeal to {}", assignedTo, e);
}
});
}
private void assignDiscordAppealTo(UUID appealId, Long assignedTo) {
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
try {
sql.getMapper(DiscordAppealMapper.class).assignDiscordAppeal(appealId, assignedTo);
} catch (Exception e) {
log.error("Failed to assign appeal to {}", assignedTo, e);
}
});
} }
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) { private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
@ -230,50 +132,4 @@ public class AppealDiscord {
return instant.atZone(ZoneId.of("UTC")) return instant.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")); .format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
} }
private Optional<Long> assignAppeal() {
CompletableFuture<Long> assignToCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
AppealListMapper mapper = sql.getMapper(AppealListMapper.class);
List<AppealList> appealList = mapper
.getAppealList();
if (appealList.isEmpty()) {
log.warn("No appeal lists found. Skipping assignment.");
assignToCompletableFuture.complete(0L);
return;
}
Optional<AppealList> optionalAssignTo = appealList
.stream()
.filter(AppealList::next).findFirst();
AppealList assignTo = optionalAssignTo.orElseGet(appealList::getFirst);
assignToCompletableFuture.complete(assignTo.userId());
try {
Optional<AppealList> optionalNextAppealList = appealList
.stream()
.filter(entry -> entry.userId() > assignTo.userId())
.min(Comparator.comparing(AppealList::userId));
AppealList nextAppealList = optionalNextAppealList.orElse(appealList.stream()
.min(Comparator.comparing(AppealList::userId))
.orElse(assignTo));
mapper.updateNext(assignTo.userId(), false);
mapper.updateNext(nextAppealList.userId(), true);
} catch (Exception e) {
log.error("Failed to assign next appeal", e);
}
} catch (Exception e) {
log.error("Failed to load appeal list", e);
assignToCompletableFuture.complete(0L);
}
});
Long assignTo = assignToCompletableFuture.join();
if (assignTo.equals(0L)) {
return Optional.empty();
}
return Optional.of(assignTo);
}
} }

View File

@ -5,7 +5,6 @@ import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper; import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication; import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.objects.MessageForEmbed;
import com.alttd.webinterface.send_message.DiscordSender; import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -47,8 +46,8 @@ public class StaffApplicationDiscord {
List<DiscordSender.EmbedField> fields = new ArrayList<>(); List<DiscordSender.EmbedField> fields = new ArrayList<>();
fields.add(new DiscordSender.EmbedField( fields.add(new DiscordSender.EmbedField(
"Applicant", "Applicant",
"Username: `" + safe(username) + "`\n" + "Username: " + safe(username) + "\n" +
"Discord: `" + safe(application.discordUsername()) + "`\n" + "Discord: " + safe(application.discordUsername()) + "\n" +
"Email: " + safe(application.email()) + "\n" + "Email: " + safe(application.email()) + "\n" +
"Age: " + safe(String.valueOf(application.age())) + "\n" + "Age: " + safe(String.valueOf(application.age())) + "\n" +
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()), "Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
@ -80,15 +79,16 @@ public class StaffApplicationDiscord {
.toList(); .toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now(); Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
MessageForEmbed messageForEmbed = new MessageForEmbed( DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Staff Application Submitted", "New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") + "Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp), "\nSubmitted: " + formatInstant(timestamp),
fields, fields,
null, null,
timestamp, timestamp,
null); null
DiscordSender.getInstance().sendEmbedWithThreadToChannels(channelIds, messageForEmbed, "Staff Application"); );
} }
private String safe(String s) { private String safe(String s) {

View File

@ -1,138 +0,0 @@
package com.alttd.altitudeweb.services.forms;
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.DiscordAppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.BannedUserToBannedUserDtoMapper;
import com.alttd.altitudeweb.mappers.DiscordAppealDtoToDiscordAppealMapper;
import com.alttd.altitudeweb.model.BannedUserResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.services.discord.AppealDiscord;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.appeals.BannedUser;
import com.alttd.webinterface.appeals.DiscordAppealDiscord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class DiscordAppeal {
private final BannedUserToBannedUserDtoMapper bannedUserToBannedUserDtoMapper;
private final DiscordAppealDtoToDiscordAppealMapper discordAppealDtoToDiscordAppealMapper;
private final AuthenticatedUuid authenticatedUuid;
private final AppealDiscord appealDiscord;
private final AppealMail appealMail;
public BannedUserResponseDto getBannedUser(Long discordId) {
DiscordAppealDiscord discordAppeal = DiscordAppealDiscord.getInstance();
Optional<BannedUser> join = discordAppeal.getBannedUser(discordId).join();
if (join.isEmpty()) {
return new BannedUserResponseDto(false);
}
BannedUserResponseDto bannedUserResponseDto = new BannedUserResponseDto(true);
bannedUserResponseDto.setBannedUser(bannedUserToBannedUserDtoMapper.map(join.get()));
return bannedUserResponseDto;
}
public FormResponseDto submitAppeal(DiscordAppealDto discordAppealDto) {
DiscordAppealDiscord discordAppealDiscord = DiscordAppealDiscord.getInstance();
long discordId = Long.parseLong(discordAppealDto.getDiscordId());
Optional<BannedUser> join = discordAppealDiscord.getBannedUser(discordId).join();
if (join.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
BannedUser bannedUser = join.get();
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
if (optionalUUID.isEmpty()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated");
}
UUID uuid = optionalUUID.get();
CompletableFuture<com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal> appealCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppealRecord = discordAppealDtoToDiscordAppealMapper
.map(discordAppealDto, uuid, bannedUser.name());
sqlSession.getMapper(DiscordAppealMapper.class).createDiscordAppeal(discordAppealRecord);
appealCompletableFuture.complete(discordAppealRecord);
} catch (Exception e) {
log.error("Failed to load history count", e);
appealCompletableFuture.completeExceptionally(e);
}
});
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppeal = appealCompletableFuture.join();
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Retrieving mail by uuid and address");
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(discordAppeal.uuid(), discordAppeal.email().toLowerCase());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
});
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid mail");
}
EmailVerification emailVerification = optionalEmailVerification.get();
if (!emailVerification.verified()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Mail not verified");
}
try {
appealDiscord.sendAppealToDiscord(discordAppeal);
} catch (Exception e) {
log.error("Failed to send appeal {} to Discord", discordAppeal.id(), e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send appeal to Discord");
}
//TODO verify mail
String username = getUsername(uuid);
appealMail.sendAppealNotification(discordAppeal, username);
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Marking appeal {} as sent", discordAppeal.id());
sqlSession.getMapper(DiscordAppealMapper.class)
.markDiscordAppealAsSent(discordAppeal.id());
});
return new FormResponseDto(
discordAppeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
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();
}
}

View File

@ -1,6 +1,5 @@
package com.alttd.altitudeweb.services.limits; package com.alttd.altitudeweb.services.limits;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -17,8 +16,6 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.time.Duration; import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
@Aspect @Aspect
@Component @Component
@ -27,7 +24,6 @@ import java.util.UUID;
public class RateLimitAspect { public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService; private final InMemoryRateLimiterService rateLimiterService;
private final AuthenticatedUuid authenticatedUuid;
@Around(""" @Around("""
@annotation(com.alttd.altitudeweb.services.limits.RateLimit) @annotation(com.alttd.altitudeweb.services.limits.RateLimit)
@ -41,6 +37,7 @@ public class RateLimitAspect {
HttpServletRequest request = requestAttributes.getRequest(); HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse(); HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature(); MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod(); Method method = signature.getMethod();
@ -57,12 +54,7 @@ public class RateLimitAspect {
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue())); Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
String customKey = rateLimit.key(); String customKey = rateLimit.key();
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid(); String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
if (optionalUUID.isEmpty()) {
return joinPoint.proceed();
}
UUID uuid = optionalUUID.get();
String key = uuid + "-" + (customKey.isEmpty() ? method.getName() : customKey);
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration); boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
@ -75,7 +67,7 @@ public class RateLimitAspect {
return joinPoint.proceed(); return joinPoint.proceed();
} else { } else {
log.warn("Rate limit exceeded for uuid: {}, endpoint: {}", uuid, request.getRequestURI()); log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration); Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);

View File

@ -2,7 +2,6 @@ package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord; import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.web_db.forms.Appeal; import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -30,20 +29,6 @@ public class AppealMail {
private static final String APPEAL_EMAIL = "appeal@alttd.com"; private static final String APPEAL_EMAIL = "appeal@alttd.com";
/**
* Sends an email notification about the appeal to both the user and the appeals team.
*
* @param appeal The appeal object containing all necessary information
*/
public void sendAppealNotification(DiscordAppeal appeal, String username) {
try {
sendEmailToAppealsTeam(appeal, username);
log.info("Discord Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
} catch (Exception e) {
log.error("Failed to send discord appeal notification emails for appeal ID: {}", appeal.id(), e);
}
}
/** /**
* Sends an email notification about the appeal to both the user and the appeals team. * Sends an email notification about the appeal to both the user and the appeals team.
* *
@ -81,31 +66,4 @@ public class AppealMail {
mailSender.send(message); mailSender.send(message);
} }
private void sendEmailToAppealsTeam(DiscordAppeal appeal, String username) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = getAppealMimeMessageHelper(appeal, message);
Context context = new Context();
context.setVariable("appeal", appeal);
context.setVariable("createdAt", appeal.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
context.setVariable("minecraftName", username);
String content = templateEngine.process("discord-appeal-email", context);
helper.setText(content, true);
mailSender.send(message);
}
private MimeMessageHelper getAppealMimeMessageHelper(DiscordAppeal appeal, MimeMessage message) throws MessagingException {
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(APPEAL_EMAIL);
helper.setReplyTo(appeal.email());
helper.setSubject("New Appeal Submitted - " + appeal.discordUsername());
return helper;
}
} }

View File

@ -1,83 +0,0 @@
package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.Player;
import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
import com.alttd.altitudeweb.mappers.StaffPtToStaffPlaytimeMapper;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffPtService {
private final static HashMap<String, String> STAFF_GROUPS_MAP = new HashMap<>();
private final static String STAFF_GROUPS;
static {
STAFF_GROUPS_MAP.put("group.owner", "Owner");
STAFF_GROUPS_MAP.put("group.manager", "Manager");
STAFF_GROUPS_MAP.put("group.admin", "Admin");
STAFF_GROUPS_MAP.put("group.headmod", "Head Mod");
STAFF_GROUPS_MAP.put("group.moderator", "Moderator");
STAFF_GROUPS_MAP.put("group.trainee", "Trainee");
STAFF_GROUPS_MAP.put("group.developer", "Developer");
STAFF_GROUPS = STAFF_GROUPS_MAP.keySet().stream()
.map(group -> "'" + group + "'")
.collect(Collectors.joining(", "));
}
private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper;
public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) {
CompletableFuture<List<PlayerWithGroup>> staffMembersFuture = new CompletableFuture<>();
CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading staff members");
try {
List<PlayerWithGroup> staffMemberList = sqlSession.getMapper(TeamMemberMapper.class)
.getTeamMembersOfGroupList(STAFF_GROUPS);
staffMembersFuture.complete(staffMemberList);
} catch (Exception e) {
log.error("Failed to load staff members", e);
staffMembersFuture.completeExceptionally(e);
}
});
List<PlayerWithGroup> staffMembers = staffMembersFuture.join().stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(PlayerWithGroup::uuid, player -> player, (player1, player2) -> player1),
m -> new ArrayList<>(m.values())));
Connection.getConnection(Databases.PROXY_PLAYTIME)
.runQuery(sqlSession -> {
String staffUUIDs = staffMembers.stream()
.map(PlayerWithGroup::uuid)
.map(uuid -> "'" + uuid + "'")
.collect(Collectors.joining(","));
log.debug("Loading staff playtime for group");
try {
List<StaffPt> sessionsDuring = sqlSession.getMapper(StaffPlaytimeMapper.class)
.getSessionsDuring(from.toEpochMilli(), to.toEpochMilli(), staffUUIDs);
staffPlaytimeFuture.complete(sessionsDuring);
} catch (Exception e) {
log.error("Failed to load staff playtime", e);
staffPlaytimeFuture.completeExceptionally(e);
}
});
List<StaffPt> join = staffPlaytimeFuture.join();
HashMap<String, String> staffGroupsMap = new HashMap<>(STAFF_GROUPS_MAP);
return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli(), staffGroupsMap));
}
}

View File

@ -6,7 +6,7 @@ database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com} cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN} login.secret=${LOGIN_SECRET:SET_TOKEN}
particles.file_path=${PARTICLE_FILE_PATH:${user.home}/.altitudeweb/particles} particles.file_path=${user.home}/.altitudeweb/particles
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080} notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
my-server.address=${SERVER_ADDRESS:https://alttd.com} my-server.address=${SERVER_ADDRESS:https://alttd.com}
logging.level.com.alttd.altitudeweb=INFO logging.level.com.alttd.altitudeweb=INFO

View File

@ -114,7 +114,8 @@
<div class="columnContainer"> <div class="columnContainer">
<div> <div>
<h2>Appeal:</h2> <h2>Appeal:</h2>
<p th:text="${appeal.reason}">appeal</p> <p th:text="${appeal.reason}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,122 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Discord Appeal Notification</title>
<style>
@font-face {
font-family: 'minecraft-title';
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
}
@font-face {
font-family: 'minecraft-text';
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
}
@font-face {
font-family: 'opensans';
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
}
@font-face {
font-family: 'opensans-bold';
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
}
body {
font-family: 'minecraft-title', sans-serif;
}
.columnSection {
width: 80%;
max-width: 800px;
margin: 0 auto;
display: flex;
}
.columnContainer {
flex: 1 1 200px;
min-width: 200px;
box-sizing: border-box;
padding: 0 15px;
}
img {
display: block;
margin: auto;
padding-top: 10px;
}
ul {
list-style-type: none;
padding-left: 0;
}
li {
padding-bottom: 7px;
}
li, p {
font-family: 'opensans', sans-serif;
font-size: 1rem;
}
h2 {
font-size: 1.5rem;
}
@media (max-width: 1150px) {
.columnContainer, .columnSection {
width: 90%;
}
}
@media (max-width: 690px) {
.columnContainer {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<main>
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159"
width="275">
<h1 style="text-align: center;" th:text="'Appeal by ' + ${appeal.discordUsername}">Appeal by Username</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>User information</h2>
<ul>
<li><strong>Discord Username:</strong> <span th:text="${appeal.discordUsername}">dc username</span></li>
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
<li><strong>Minecraft Username:</strong> <span th:text="${minecraftName}">mc username</span></li>
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
</ul>
</div>
</div>
<div class="columnContainer">
<div>
<h2>Appeal:</h2>
<p th:text="${appeal.reason}">appeal</p>
</div>
</div>
</section>
</main>
</body>
</html>

View File

@ -8,7 +8,6 @@ public enum Databases {
LUCK_PERMS("luckperms"), LUCK_PERMS("luckperms"),
LITE_BANS("litebans"), LITE_BANS("litebans"),
DISCORD("discordLink"), DISCORD("discordLink"),
PROXY_PLAYTIME("proxyplaytime"),
VOTING_PLUGIN("votingplugin"); VOTING_PLUGIN("votingplugin");
private final String internalName; private final String internalName;

View File

@ -1,4 +0,0 @@
package com.alttd.altitudeweb.database.discord;
public record AppealList(Long userId, boolean next) {
}

View File

@ -1,25 +0,0 @@
package com.alttd.altitudeweb.database.discord;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
public interface AppealListMapper {
@Select("""
SELECT userId, next
FROM appeal_list
ORDER BY userId;
""")
List<AppealList> getAppealList();
@Update("""
UPDATE appeal_list
SET next = #{next}
WHERE userId = #{userId}
""")
void updateNext(@Param("userId") long userId, @Param("next") boolean next);
}

View File

@ -48,7 +48,7 @@ public interface EditHistoryMapper {
case BAN -> updateUntil("litebans_bans", id, until); case BAN -> updateUntil("litebans_bans", id, until);
case MUTE -> updateUntil("litebans_mutes", id, until); case MUTE -> updateUntil("litebans_mutes", id, until);
case KICK -> throw new IllegalArgumentException("KICK has no until"); case KICK -> throw new IllegalArgumentException("KICK has no until");
case WARN -> updateUntil("litebans_warnings", id, until); case WARN -> throw new IllegalArgumentException("WARN has no until");
}; };
} }

View File

@ -1,6 +0,0 @@
package com.alttd.altitudeweb.database.luckperms;
import java.util.UUID;
public record PlayerWithGroup(String username, UUID uuid, String group) {
}

View File

@ -15,24 +15,8 @@ public interface TeamMemberMapper {
SELECT players.username, players.uuid SELECT players.username, players.uuid
FROM luckperms_user_permissions AS permissions FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission} AND server = 'global' WHERE permission = #{groupPermission}
AND world = 'global' AND world = 'global'
""") """)
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission); List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
@ConstructorArgs({
@Arg(column = "username", javaType = String.class),
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Arg(column = "group", javaType = String.class)
})
@Select("""
SELECT players.username, players.uuid, permissions.permission AS 'group'
FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission IN (${groupPermissions})
AND server = 'global'
AND world = 'global'
""")
List<PlayerWithGroup> getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions);
} }

View File

@ -1,33 +0,0 @@
package com.alttd.altitudeweb.database.proxyplaytime;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import org.apache.ibatis.annotations.Arg;
import org.apache.ibatis.annotations.ConstructorArgs;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.UUID;
public interface StaffPlaytimeMapper {
@ConstructorArgs({
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Arg(column = "serverName", javaType = String.class),
@Arg(column = "sessionStart", javaType = long.class),
@Arg(column = "sessionEnd", javaType = long.class)
})
@Select("""
SELECT uuid,
server_name AS serverName,
session_start AS sessionStart,
session_end AS sessionEnd
FROM sessions
WHERE session_end > #{from}
AND session_start < #{to}
AND uuid IN (${staffUUIDs})
ORDER BY uuid, session_start
""")
List<StaffPt> getSessionsDuring(@Param("from") long from,
@Param("to") long to,
@Param("staffUUIDs") String staffUUIDs);
}

View File

@ -1,6 +0,0 @@
package com.alttd.altitudeweb.database.proxyplaytime;
import java.util.UUID;
public record StaffPt(UUID uuid, String serverName, long sessionStart, long sessionEnd) {
}

View File

@ -21,7 +21,7 @@ public interface VotingPluginUsersMapper {
WeeklyTotal as totalVotesThisWeek, WeeklyTotal as totalVotesThisWeek,
MonthTotal as totalVotesThisMonth, MonthTotal as totalVotesThisMonth,
AllTimeTotal as totalVotesAllTime AllTimeTotal as totalVotesAllTime
FROM votingplugin.VotingPlugin_Users FROM votingplugin.votingplugin_users
WHERE uuid = #{uuid} WHERE uuid = #{uuid}
""") """)
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid); Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.database.web_db; package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -103,8 +104,8 @@ public interface PrivilegedUserMapper {
@Insert(""" @Insert("""
INSERT INTO privileged_users (uuid) INSERT INTO privileged_users (uuid)
VALUES (#{user.uuid}) VALUES (#{uuid})
""") """)
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class) @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
void createPrivilegedUser(@Param("user") PrivilegedUser user); int createPrivilegedUser(UUID uuid);
} }

View File

@ -1,7 +1,6 @@
package com.alttd.altitudeweb.database.web_db.forms; package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
@ -34,11 +33,5 @@ public interface AppealMapper {
UPDATE appeals SET send_at = NOW() UPDATE appeals SET send_at = NOW()
WHERE id = #{id} WHERE id = #{id}
""") """)
void markAppealAsSent(@Param("id") UUID id); void markAppealAsSent(UUID id);
@Update("""
UPDATE appeals SET assigned_to = #{assignedTo}
WHERE id = #{id}
""")
void assignAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
} }

View File

@ -1,17 +0,0 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.util.UUID;
public record DiscordAppeal(
UUID id,
UUID uuid,
Long discordId,
String discordUsername,
String reason,
Instant createdAt,
Instant sendAt,
String email,
Long assignedTo
) {
}

View File

@ -1,28 +0,0 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.util.UUID;
public interface DiscordAppealMapper {
@Insert("""
INSERT INTO discord_appeals (uuid, discord_id, discord_username, reason, created_at, send_at, e_mail, assigned_to)
VALUES (#{uuid}, #{discordId}, #{discordUsername}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
""")
void createDiscordAppeal(DiscordAppeal discordAppeal);
@Update("""
UPDATE discord_appeals SET send_at = NOW()
WHERE id = #{id}
""")
void markDiscordAppealAsSent(@Param("id") UUID id);
@Update("""
UPDATE discord_appeals SET assigned_to = #{assignedTo}
WHERE id = #{id}
""")
void assignDiscordAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
}

View File

@ -37,7 +37,6 @@ public class Connection {
InitializeWebDb.init(); InitializeWebDb.init();
InitializeLiteBans.init(); InitializeLiteBans.init();
InitializeLuckPerms.init(); InitializeLuckPerms.init();
InitializeProxyPlaytime.init();
InitializeDiscord.init(); InitializeDiscord.init();
InitializeVotingPlugin.init(); InitializeVotingPlugin.init();
} }

View File

@ -1,7 +1,6 @@
package com.alttd.altitudeweb.setup; package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.AppealListMapper;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper; import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper; import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -13,7 +12,6 @@ public class InitializeDiscord {
log.info("Initializing Discord"); log.info("Initializing Discord");
Connection.getConnection(Databases.DISCORD, (configuration) -> { Connection.getConnection(Databases.DISCORD, (configuration) -> {
configuration.addMapper(OutputChannelMapper.class); configuration.addMapper(OutputChannelMapper.class);
configuration.addMapper(AppealListMapper.class);
}).join(); }).join();
log.debug("Initialized Discord"); log.debug("Initialized Discord");
} }

View File

@ -1,18 +0,0 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeProxyPlaytime {
protected static void init() {
log.info("Initializing ProxyPlaytime");
Connection.getConnection(Databases.PROXY_PLAYTIME, (configuration) -> {
configuration.addMapper(StaffPlaytimeMapper.class);
}).join();
log.debug("Initialized ProxyPlaytime");
}
}

View File

@ -5,7 +5,6 @@ import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper; import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper; import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper; import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper; import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -25,7 +24,6 @@ public class InitializeWebDb {
configuration.addMapper(KeyPairMapper.class); configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class); configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class); configuration.addMapper(AppealMapper.class);
configuration.addMapper(DiscordAppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class); configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class); configuration.addMapper(EmailVerificationMapper.class);
}).join() }).join()
@ -35,7 +33,6 @@ public class InitializeWebDb {
createPrivilegedUsersTable(sqlSession); createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession); createPrivilegesTable(sqlSession);
createAppealTable(sqlSession); createAppealTable(sqlSession);
createdDiscordAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession); createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession); createUserEmailsTable(sqlSession);
}); });
@ -186,26 +183,4 @@ public class InitializeWebDb {
} }
} }
private static void createdDiscordAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS discord_appeals (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
discord_id BIGINT UNSIGNED NOT NULL,
discord_username VARCHAR(32) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
e_mail TEXT NOT NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
} }

View File

@ -7,7 +7,12 @@ import lombok.extern.slf4j.Slf4j;
public class DiscordBot { public class DiscordBot {
public static void main(String[] args) { public static void main(String[] args) {
DiscordBotInstance discordBotInstance = DiscordBotInstance.getInstance(); String discordToken = System.getProperty("DISCORD_TOKEN");
discordBotInstance.getJda(); if (discordToken == null) {
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
System.exit(1);
}
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
discordBotInstance.start(discordToken);
} }
} }

View File

@ -1,60 +0,0 @@
package com.alttd.webinterface.appeals;
import com.alttd.webinterface.objects.MessageForEmbed;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.entities.Message;
import java.util.List;
import java.util.Optional;
@Slf4j
public class AppealSender {
private static final AppealSender INSTANCE = new AppealSender();
public static AppealSender getInstance() {
return INSTANCE;
}
public void sendAppeal(List<Long> channelIds, MessageForEmbed messageForEmbed, long assignedTo) {
DiscordSender.getInstance()
.sendEmbedToChannels(channelIds, messageForEmbed)
.whenCompleteAsync((result, error) -> {
if (error != null) {
log.error("Failed sending embed to channels", error);
return;
}
List<Message> list = result.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.toList();
list.forEach(message -> {
message.createThreadChannel("Appeal")
.queue(channel -> {
if (assignedTo == 0L) {
return;
}
String assignedUserMessage = "<@" + assignedTo + "> you have a new appeal!";
channel.sendMessage(assignedUserMessage).queue();
});
});
addButtons(list);
});
}
public void addButtons(List<Message> messages) {
Button reminderAccepted = Button.primary("reminder_accepted", "Accepted");
Button reminderInProgress = Button.secondary("reminder_in_progress", "In Progress");
Button reminderDenied = Button.danger("reminder_denied", "Denied");
messages.forEach(message -> {
message.editMessageComponents(ActionRow.of(reminderAccepted, reminderInProgress, reminderDenied)).queue();
});
}
}

View File

@ -1,14 +0,0 @@
package com.alttd.webinterface.appeals;
import net.dv8tion.jda.api.entities.Guild;
public class BanToBannedUser {
public static BannedUser map(Guild.Ban ban) {
return new BannedUser(ban.getUser().getIdLong(),
ban.getReason(),
ban.getUser().getEffectiveName(),
ban.getUser().getEffectiveAvatarUrl());
}
}

View File

@ -1,4 +0,0 @@
package com.alttd.webinterface.appeals;
public record BannedUser(long userId, String reason, String name, String avatarUrl) {
}

View File

@ -1,43 +0,0 @@
package com.alttd.webinterface.appeals;
import com.alttd.webinterface.bot.DiscordBotInstance;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.Guild;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@Slf4j
public class DiscordAppealDiscord {
private static final DiscordAppealDiscord INSTANCE = new DiscordAppealDiscord();
public static DiscordAppealDiscord getInstance() {
return INSTANCE;
}
public CompletableFuture<Optional<BannedUser>> getBannedUser(long discordId) {
Guild guildById = DiscordBotInstance.getInstance()
.getJda()
.getGuildById(141644560005595136L);
if (guildById == null) {
throw new IllegalStateException("Guild not found");
}
CompletableFuture<Optional<BannedUser>> completableFuture = new CompletableFuture<>();
log.info("Retrieving ban for user {}", discordId);
DiscordBotInstance.getInstance().getJda().retrieveUserById(discordId)
.queue(user -> {
log.info("Found user {}", user.getEffectiveName());
guildById.retrieveBan(user).queue(ban -> {
if (ban == null) {
completableFuture.complete(Optional.empty());
log.info("User {} is not banned", user.getEffectiveName());
return;
}
log.info("User {} is banned", user.getEffectiveName());
completableFuture.complete(Optional.of(BanToBannedUser.map(ban)));
});
});
return completableFuture;
}
}

View File

@ -6,41 +6,14 @@ import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent; import net.dv8tion.jda.api.requests.GatewayIntent;
import java.util.Optional;
@Slf4j @Slf4j
public class DiscordBotInstance { public class DiscordBotInstance {
private static final DiscordBotInstance INSTANCE = new DiscordBotInstance(); @Getter
public static DiscordBotInstance getInstance() {
return INSTANCE;
}
private DiscordBotInstance() {}
private JDA jda; private JDA jda;
private volatile boolean ready = false; private volatile boolean ready = false;
public JDA getJda() { public synchronized void start(String token) {
if (jda == null) {
String discordToken = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
.orElse(System.getProperty("DISCORD_TOKEN"));
if (discordToken == null) {
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
System.exit(1);
}
start(discordToken);
try {
jda.awaitReady();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
return jda;
}
private synchronized void start(String token) {
if (jda != null) { if (jda != null) {
return; return;
} }

View File

@ -1,49 +0,0 @@
package com.alttd.webinterface.objects;
import com.alttd.webinterface.send_message.DiscordSender;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import java.awt.*;
import java.time.Instant;
import java.util.List;
public record MessageForEmbed(String title, String description, List<DiscordSender.EmbedField> fields, Integer colorRgb,
Instant timestamp, String footer) {
public MessageEmbed toEmbed() {
EmbedBuilder eb = new EmbedBuilder();
if (title != null && !title.isBlank()) {
eb.setTitle(title);
}
if (description != null && !description.isBlank()) {
eb.setDescription(description);
}
if (colorRgb != null) {
eb.setColor(new Color(colorRgb));
} else {
eb.setColor(new Color(0xFF8C00)); // default orange
}
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
if (footer != null && !footer.isBlank()) {
eb.setFooter(footer);
}
if (fields != null) {
for (DiscordSender.EmbedField f : fields) {
if (f == null) {
continue;
}
String name = f.getName() == null ? "" : f.getName();
String value = f.getValue() == null ? "" : f.getValue();
// JDA field value max is 1024; truncate to be safe
if (value.length() > 1024) {
value = value.substring(0, 1021) + "...";
}
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
}
}
return eb.build();
}
}

View File

@ -1,28 +1,26 @@
package com.alttd.webinterface.send_message; package com.alttd.webinterface.send_message;
import com.alttd.webinterface.bot.DiscordBotInstance; import com.alttd.webinterface.bot.DiscordBotInstance;
import com.alttd.webinterface.objects.MessageForEmbed;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import java.util.ArrayList; import java.awt.*;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@Slf4j @Slf4j
public class DiscordSender { public class DiscordSender {
private static final DiscordSender INSTANCE = new DiscordSender(); private static final DiscordSender INSTANCE = new DiscordSender();
private final DiscordBotInstance botInstance = DiscordBotInstance.getInstance(); private final DiscordBotInstance botInstance = new DiscordBotInstance();
private DiscordSender() {} private DiscordSender() {}
@ -30,7 +28,19 @@ public class DiscordSender {
return INSTANCE; return INSTANCE;
} }
private void ensureStarted() {
if (botInstance.getJda() != null) return;
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
.orElse(System.getProperty("DISCORD_TOKEN"));
if (token == null || token.isBlank()) {
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
return;
}
botInstance.start(token);
}
public void sendMessageToChannels(List<Long> channelIds, String message) { public void sendMessageToChannels(List<Long> channelIds, String message) {
ensureStarted();
if (botInstance.getJda() == null) { if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord message."); log.error("JDA not initialized; cannot send Discord message.");
return; return;
@ -60,36 +70,12 @@ public class DiscordSender {
}); });
} }
public void sendEmbedWithThreadToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed, String threadName) { public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
sendEmbedToChannels(channelIds, messageForEmbed).whenCompleteAsync((result, error) -> { Integer colorRgb, Instant timestamp, String footer) {
if (error != null) { ensureStarted();
log.error("Failed sending embed to channels", error);
return;
}
result.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.forEach(message ->
message.createThreadChannel(threadName).queue());
});
}
public CompletableFuture<List<Optional<Message>>> sendEmbedToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed) {
List<CompletableFuture<Optional<Message>>> futures = new ArrayList<>();
for (Long channelId : channelIds) {
futures.add(sendEmbedToChannel(channelId, messageForEmbed));
}
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v ->
futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()));
}
public CompletableFuture<Optional<Message>> sendEmbedToChannel(Long channelId, MessageForEmbed messageForEmbed) {
if (botInstance.getJda() == null) { if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord embed."); log.error("JDA not initialized; cannot send Discord embed.");
return CompletableFuture.completedFuture(Optional.empty()); return;
} }
try { try {
if (!botInstance.isReady()) { if (!botInstance.isReady()) {
@ -97,31 +83,43 @@ public class DiscordSender {
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return CompletableFuture.completedFuture(Optional.empty());
} catch (Exception e) { } catch (Exception e) {
log.warn("Error while waiting for JDA ready state", e); log.warn("Error while waiting for JDA ready state", e);
return CompletableFuture.completedFuture(Optional.empty());
} }
MessageEmbed embed = messageForEmbed.toEmbed(); EmbedBuilder eb = new EmbedBuilder();
if (title != null && !title.isBlank()) eb.setTitle(title);
if (description != null && !description.isBlank()) eb.setDescription(description);
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, channelId); if (fields != null) {
for (EmbedField f : fields) {
if (f == null) continue;
String name = f.getName() == null ? "" : f.getName();
String value = f.getValue() == null ? "" : f.getValue();
// JDA field value max is 1024; truncate to be safe
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
}
}
MessageEmbed embed = eb.build();
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
if (channel == null) { if (channel == null) {
log.warn("TextChannel with id {} not found when sending embed message", channelId); log.warn("TextChannel with id {} not found", id);
return CompletableFuture.completedFuture(Optional.empty()); return;
} }
CompletableFuture<Optional<Message>> completableFuture = new CompletableFuture<>();
channel.sendMessageEmbeds(embed).queue( channel.sendMessageEmbeds(embed).queue(
message -> { success -> log.debug("Sent embed to channel {}", id),
completableFuture.complete(Optional.of(message)); error -> log.error("Failed sending embed to channel {}", id, error)
log.debug("Sent embed to channel {}", channelId);
},
error -> {
completableFuture.complete(Optional.empty());
log.error("Failed sending embed to channel {}", channelId, error);
}
); );
return completableFuture; });
} }
@Data @Data

View File

@ -30,11 +30,6 @@
"glob": "**/*", "glob": "**/*",
"input": "public", "input": "public",
"output": "public" "output": "public"
},
{
"glob": "**/*",
"input": "public",
"output": "assets"
} }
], ],
"styles": [ "styles": [

View File

@ -13,15 +13,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "20.2.2", "@angular/cdk": "^20.1.3",
"@angular/common": "20.2.2", "@angular/common": "^20.1.0",
"@angular/compiler": "20.2.2", "@angular/compiler": "^20.1.0",
"@angular/core": "20.2.2", "@angular/core": "^20.1.0",
"@angular/forms": "20.2.2", "@angular/forms": "^20.1.0",
"@angular/material": "20.2.2", "@angular/material": "^20.1.3",
"@angular/platform-browser": "20.2.2", "@angular/platform-browser": "^20.1.0",
"@angular/platform-browser-dynamic": "20.2.2", "@angular/platform-browser-dynamic": "^20.1.0",
"@angular/router": "20.2.2", "@angular/router": "^20.1.0",
"@auth0/angular-jwt": "^5.2.0", "@auth0/angular-jwt": "^5.2.0",
"@types/three": "^0.177.0", "@types/three": "^0.177.0",
"ngx-cookie-service": "^20.0.1", "ngx-cookie-service": "^20.0.1",
@ -31,9 +31,9 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "20.2.2", "@angular/build": "^20.1.0",
"@angular/cli": "20.2.2", "@angular/cli": "^20.1.0",
"@angular/compiler-cli": "20.2.2", "@angular/compiler-cli": "^20.1.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@ -41,6 +41,6 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "^5.9.2" "typescript": "^5.8.3"
} }
} }

View File

@ -0,0 +1,29 @@
import {TestBed} from '@angular/core/testing';
import {AppComponent} from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'frontend' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
});
});

View File

@ -1,7 +1,7 @@
import {Component, inject, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {Meta, Title} from '@angular/platform-browser'; import {Meta, Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from '@custom-types/constant'; import {ALTITUDE_VERSION} from '@custom-types/constant';
import {RouterOutlet} from '@angular/router'; import {Router, RouterOutlet} from '@angular/router';
import {FooterComponent} from '@pages/footer/footer/footer.component'; import {FooterComponent} from '@pages/footer/footer/footer.component';
@Component({ @Component({
@ -27,8 +27,9 @@ export class AppComponent implements OnInit {
ALTITUDE_VERSION + ',altitude,alttd,play,join,find,friends,friendly,simple,private,whitelist,whitelisted,creative,' + ALTITUDE_VERSION + ',altitude,alttd,play,join,find,friends,friendly,simple,private,whitelist,whitelisted,creative,' +
'worldedit' 'worldedit'
private readonly titleService = inject(Title); constructor(private titleService: Title, private metaService: Meta, private router: Router) {
private readonly metaService = inject(Meta);
}
ngOnInit(): void { ngOnInit(): void {
this.titleService.setTitle(this.title) this.titleService.setTitle(this.title)

View File

@ -2,28 +2,6 @@ import {Routes} from '@angular/router';
import {AuthGuard} from './guards/auth.guard'; import {AuthGuard} from './guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{
path: 'worlddl',
redirectTo: 'redirect/worlddl',
pathMatch: 'full'
},
{
path: 'grove-dl',
redirectTo: 'redirect/grove-dl',
pathMatch: 'full'
},
{
path: 'redirect/:type',
loadComponent: () => import('./shared-components/redirect/redirect.component').then(m => m.RedirectComponent),
},
{
path: 'login/:code',
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{ {
path: '', path: '',
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent) loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
@ -36,14 +14,6 @@ export const routes: Routes = [
requiredAuthorizations: ['SCOPE_head_mod'] requiredAuthorizations: ['SCOPE_head_mod']
} }
}, },
{
path: 'staff-pt',
loadComponent: () => import('./pages/head-mod/staff-pt/staff-pt.component').then(m => m.StaffPtComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_head_mod']
}
},
{ {
path: 'map', path: 'map',
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent) loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
@ -140,38 +110,18 @@ export const routes: Routes = [
path: 'forms', path: 'forms',
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent) loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
}, },
{
path: 'appeal/:code',
redirectTo: 'forms/appeal/:code',
pathMatch: 'full'
},
{ {
path: 'appeal', path: 'appeal',
redirectTo: 'forms/appeal', redirectTo: 'forms/appeal',
pathMatch: 'full' pathMatch: 'full'
}, },
{
path: 'discord-appeal',
redirectTo: 'forms/discord-appeal',
pathMatch: 'full'
},
{
path: 'forms/appeal/:code',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
canActivate: [AuthGuard],
data: {requiredAuthorizations: ['SCOPE_user']}
},
{ {
path: 'forms/appeal', path: 'forms/appeal',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent), loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: {requiredAuthorizations: ['SCOPE_user']} data: {
}, requiredAuthorizations: ['SCOPE_user']
{ }
path: 'forms/discord-appeal',
loadComponent: () => import('./pages/forms/discord-appeal/discord-appeal.component').then(m => m.DiscordAppealComponent),
canActivate: [AuthGuard],
data: {requiredAuthorizations: ['SCOPE_user']}
}, },
{ {
path: 'forms/sent', path: 'forms/sent',
@ -204,6 +154,6 @@ export const routes: Routes = [
}, },
{ {
path: 'nickgenerator', path: 'nickgenerator',
loadComponent: () => import('@pages/reference/nickgenerator/nick-generator.component').then(m => m.NickGeneratorComponent) loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
}, },
]; ];

View File

@ -1,10 +1,9 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router'; import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {from, isObservable, map, Observable, of, switchMap} from 'rxjs'; import {map, Observable} from 'rxjs';
import {AuthService} from '@services/auth.service'; import {AuthService} from '@services/auth.service';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {LoginDialogComponent} from '@shared-components/login/login.component'; import {LoginDialogComponent} from '@shared-components/login/login.component';
import {catchError} from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -22,23 +21,6 @@ export class AuthGuard implements CanActivate {
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree { ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
const code = route.paramMap.get('code');
if (code) {
return this.authService.login(code).pipe(
switchMap(() => {
const result = this.canActivateInternal(route, state);
if (route.routeConfig?.path === 'login/:code') {
this.router.navigateByUrl('/', {replaceUrl: true}).then();
}
return isObservable(result) ? result : result instanceof Promise ? from(result) : of(result);
}),
catchError(() => of(this.router.createUrlTree(['/'])))
);
}
return this.canActivateInternal(route, state);
}
private canActivateInternal(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (!this.authService.isAuthenticated$()) { if (!this.authService.isAuthenticated$()) {
this.router.createUrlTree(['/']); this.router.createUrlTree(['/']);
const dialogRef = this.dialog.open(LoginDialogComponent, { const dialogRef = this.dialog.open(LoginDialogComponent, {

View File

@ -9,90 +9,34 @@
<main> <main>
<section class="darkmodeSection"> <section class="darkmodeSection">
<div class="container teamContainer"> <div class="customContainer">
<h2 class="sectionTitle">Current Nitro Boosters</h2> <h2>Current Nitro Boosters</h2>
@for (member of getTeamMembers('discord') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Nitro booster</p>
</div>
}
</div> </div>
</section> </section>
<section class="darkmodeSectionThree"> <section id="social" class="darkmodeSectionThree">
<div class="container teamContainer"> <div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Social Media</h2> <h2 class="sectionTitle">Social Media</h2>
@for (member of getTeamMembers('socialmedia') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Social media</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p> <p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
</div> </div>
</section> </section>
<section class="darkmodeSection"> <section id="crateTeam" class="darkmodeSection">
<div class="container teamContainer"> <div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Developers</h2> <h2 class="sectionTitle">Crate Team</h2>
@for (member of getTeamMembers('developer') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Developer</p>
</div>
}
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">If you want to be a developer please reach out to .teri on Discord.</p>
</div> </div>
</section> </section>
<section class="darkmodeSectionThree"> <section class="darkmodeSectionThree">
<div class="container teamContainer"> <div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Crate Team</h2>
@for (member of getTeamMembers('crate') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Crate team</p>
</div>
}
</div>
</section>
<section class="darkmodeSection">
<div class="container teamContainer">
<h2 class="sectionTitle">Event Leaders</h2> <h2 class="sectionTitle">Event Leaders</h2>
@for (member of getTeamMembers('eventleader') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Event leaders</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p> <p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
</div> </div>
</section> </section>
<section class="darkmodeSectionThree"> <section class="darkmodeSection">
<div class="container teamContainer"> <div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Event Team</h2> <h2 class="sectionTitle">Event Team</h2>
@for (member of getTeamMembers('eventteam') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Event team</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;"> <div style="flex-direction: column;">
@ -102,33 +46,16 @@
</div> </div>
</div> </div>
</section> </section>
<section class="darkmodeSection"> <section class="darkmodeSectionThree">
<div class="container teamContainer"> <div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">YouTubers & Streamers</h2> <h2 class="sectionTitle">YouTubers & Streamers</h2>
@for (member of getTeamMembers('youtube') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Youtuber</p>
</div>
}
@for (member of getTeamMembers('twitch') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Streamer</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;"> <div style="flex-direction: column;">
<p style="text-align: center;"><a style="cursor: pointer;" (click)="toggleSection('yt-stream-req')" <p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
id="reqButton">{{ getTextForSection('yt-stream-req') }}</a></p>
</div> </div>
</div> </div>
<div [hidden]="!isToggled('yt-stream-req')" [class.requirementSection]="isToggled('yt-stream-req')"> <div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column; justify-content: center; max-width: 800px;"> <div style="flex-direction: column; justify-content: center; max-width: 800px;">
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span> <p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
</p> </p>

View File

@ -1,32 +1,12 @@
.sectionTitle { .customContainer {
flex: 0 0 100%; width: 80%;
text-align: center; max-width: 1020px;
padding-bottom: 20px; margin: auto;
font-size: 2em; padding: 80px 0;
}
.member {
width: 33%;
min-width: 250px;
padding-bottom: 50px;
text-align: center; text-align: center;
} }
.member img { .hide {
padding-bottom: 15px; display: none !important;
} }
.member p {
font-family: 'opensans-bold', sans-serif;
}
.teamContainer {
padding: 50px 0 0 0;
justify-content: center;
}
.requirementSection {
display: flex;
justify-content: center;
padding-bottom: 30px;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityComponent } from './community.component';
describe('CommunityComponent', () => {
let component: CommunityComponent;
let fixture: ComponentFixture<CommunityComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommunityComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CommunityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,66 +1,14 @@
import {Component, inject} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from "@header/header.component"; import {HeaderComponent} from "@header/header.component";
import {map, Observable, shareReplay} from 'rxjs';
import {Player, TeamService} from '@api';
import {ScrollService} from '@services/scroll.service';
import {AsyncPipe, NgOptimizedImage} from '@angular/common';
@Component({ @Component({
selector: 'app-community', selector: 'app-community',
imports: [ imports: [
HeaderComponent, HeaderComponent
AsyncPipe,
NgOptimizedImage
], ],
templateUrl: './community.component.html', templateUrl: './community.component.html',
styleUrl: './community.component.scss' styleUrl: './community.component.scss'
}) })
export class CommunityComponent { export class CommunityComponent {
private teamMembersCache: { [key: string]: Observable<Player[]> } = {};
protected scrollService: ScrollService = inject(ScrollService)
protected teamService: TeamService = inject(TeamService)
public getTeamMembers(team: string): Observable<Player[]> {
if (!this.teamMembersCache[team]) {
this.teamMembersCache[team] = this.teamService.getTeamMembers(team).pipe(
map(res => this.removeDuplicates(res)),
shareReplay(1)
);
}
return this.teamMembersCache[team];
}
private removeDuplicates(array: Player[]): Player[] {
return array.filter((player, index, self) =>
index === self.findIndex((p) => p.uuid === player.uuid)
);
}
public getAvatarUrl(entry: Player): string {
let uuid = entry.uuid.replace('-', '');
return `https://crafatar.com/avatars/${uuid}?overlay`;
}
public toggledSections: string[] = [];
public isToggled(section: string) {
return this.toggledSections.includes(section);
}
public toggleSection(section: string) {
if (this.isToggled(section)) {
this.toggledSections = this.toggledSections.filter(s => s !== section);
} else {
this.toggledSections.push(section);
}
}
public getTextForSection(section: string) {
if (this.isToggled(section)) {
return 'Hide Requirements...';
} else {
return 'Show Requirements...';
}
}
} }

View File

@ -83,4 +83,4 @@
</section> </section>
} }
</main> </main>
</ng-container> </ng-container>

View File

@ -166,6 +166,7 @@
<p>/iwanttobreakthisblock, required to break some natural generated blocks</p> <p>/iwanttobreakthisblock, required to break some natural generated blocks</p>
<p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</p> <p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</p>
<p>Swift Sneak enchantment can be found in villager trades</p> <p>Swift Sneak enchantment can be found in villager trades</p>
<p>Nether portals can be as small as 1x2 (1x2 portal, 3x4 base)</p>
<p>Lootchests refill once for every player</p> <p>Lootchests refill once for every player</p>
<p>Empty maps can be duplicated using paper in a cartography table</p> <p>Empty maps can be duplicated using paper in a cartography table</p>
<p>Eating glow berries gives you a glowing effect</p> <p>Eating glow berries gives you a glowing effect</p>

View File

@ -1,13 +1,9 @@
@if (hideFooter()) { <footer>
} @else {
<footer>
<div class="footer"> <div class="footer">
<div class="footerInner"> <div class="footerInner">
<div class="footerText"> <div class="footerText">
<h2>ABOUT US</h2> <h2>ABOUT US</h2>
<p>Altitude is a community-centered {{ ALTITUDE_VERSION }} survival server. We're one of those servers you <p>Altitude is a community-centered {{ ALTITUDE_VERSION }} survival server. We're one of those servers you come
come
to call "home". We are your place to get together with friends and play survival, with a few extra features to call "home". We are your place to get together with friends and play survival, with a few extra features
suggested by our community!</p> suggested by our community!</p>
<div class="followUs" style="height: 35px; display: flex; align-items: flex-end;"> <div class="followUs" style="height: 35px; display: flex; align-items: flex-end;">
@ -45,5 +41,4 @@
<p class="copyright">Copyright © 2015-{{ getCurrentYear() }} Altitude. All rights Reserved. Not affiliated with <p class="copyright">Copyright © 2015-{{ getCurrentYear() }} Altitude. All rights Reserved. Not affiliated with
Mojang AB or Microsoft.</p> Mojang AB or Microsoft.</p>
</div> </div>
</footer> </footer>
}

View File

@ -1,21 +1,19 @@
import {Component, computed, inject} from '@angular/core'; import {Component} from '@angular/core';
import {ALTITUDE_VERSION} from '@custom-types/constant'; import {ALTITUDE_VERSION} from '@custom-types/constant';
import {FooterService} from '@services/footer.service'; import { NgOptimizedImage } from '@angular/common';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
standalone: true, standalone: true,
imports: [ imports: [
RouterLink RouterLink,
], NgOptimizedImage
],
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrl: './footer.component.scss' styleUrl: './footer.component.scss'
}) })
export class FooterComponent { export class FooterComponent {
private readonly footerService: FooterService = inject(FooterService);
hideFooter = computed(() => (this.footerService.hideFooter()));
public getCurrentYear() { public getCurrentYear() {
return new Date().getFullYear(); return new Date().getFullYear();
} }

View File

@ -6,7 +6,6 @@
</div> </div>
</app-header> </app-header>
<main> <main>
<app-full-size>
<section class="darkmodeSection appeal-container"> <section class="darkmodeSection appeal-container">
<div class="form-container"> <div class="form-container">
<div class="pages"> <div class="pages">
@ -36,8 +35,7 @@
@if (currentPageIndex === 1) { @if (currentPageIndex === 1) {
<section class="formPage"> <section class="formPage">
<div class="description"> <div class="description">
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct <p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
account
please continue</p> please continue</p>
<br> <br>
<p><strong>Notice: </strong> Submitting an appeal is <strong>not</strong> an instant process. <p><strong>Notice: </strong> Submitting an appeal is <strong>not</strong> an instant process.
@ -103,8 +101,7 @@
</div> </div>
} }
</div> </div>
<button mat-raised-button (click)="validateMailOrNextPage()" <button mat-raised-button (click)="validateMailOrNextPage()" [disabled]="form.controls.email.invalid">
[disabled]="form.controls.email.invalid">
Next Next
</button> </button>
</section> </section>
@ -130,7 +127,7 @@
} }
</mat-form-field> </mat-form-field>
</div> </div>
<button mat-raised-button (click)="onSubmit()" [disabled]="formSubmitting || form.invalid"> <button mat-raised-button (click)="onSubmit()" [disabled]="form.invalid">
Submit Appeal Submit Appeal
</button> </button>
</section> </section>
@ -160,7 +157,5 @@
} }
</div> </div>
</section> </section>
</app-full-size>
</main> </main>
</div> </div>

View File

@ -5,7 +5,7 @@
.appeal-container { .appeal-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; min-height: 80vh;
} }
main { main {

View File

@ -1,4 +1,14 @@
import {Component, computed, inject, OnDestroy, OnInit, signal} from '@angular/core'; import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api'; import {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api';
import {HeaderComponent} from '@header/header.component'; import {HeaderComponent} from '@header/header.component';
@ -14,8 +24,6 @@ import {HistoryFormatService} from '@pages/reference/bans/history-format.service
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component'; import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {finalize} from 'rxjs';
@Component({ @Component({
selector: 'app-appeal', selector: 'app-appeal',
@ -29,12 +37,11 @@ import {finalize} from 'rxjs';
MatSelectModule, MatSelectModule,
MatInputModule, MatInputModule,
ReactiveFormsModule, ReactiveFormsModule,
FullSizeComponent,
], ],
templateUrl: './appeal.component.html', templateUrl: './appeal.component.html',
styleUrl: './appeal.component.scss' styleUrl: './appeal.component.scss'
}) })
export class AppealComponent implements OnInit, OnDestroy { export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
private mailService = inject(MailService); private mailService = inject(MailService);
private historyFormatService = inject(HistoryFormatService); private historyFormatService = inject(HistoryFormatService);
@ -54,7 +61,10 @@ export class AppealComponent implements OnInit, OnDestroy {
protected emailIsValid = signal<boolean>(false); protected emailIsValid = signal<boolean>(false);
protected dialog = inject(MatDialog); protected dialog = inject(MatDialog);
constructor() { constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) {
this.form = new FormGroup({ this.form = new FormGroup({
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}), email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]}) appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
@ -87,6 +97,16 @@ export class AppealComponent implements OnInit, OnDestroy {
}) })
} }
ngAfterViewInit() {
this.setupResizeObserver();
this.updateContainerHeight();
this.boundHandleResize = this.handleResize.bind(this);
window.addEventListener('resize', this.boundHandleResize);
setTimeout(() => this.updateContainerHeight(), 0);
}
ngOnDestroy() { ngOnDestroy() {
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
@ -98,6 +118,41 @@ export class AppealComponent implements OnInit, OnDestroy {
} }
} }
private handleResize() {
this.updateContainerHeight();
}
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.updateContainerHeight();
});
const headerElement = document.querySelector('app-header');
if (headerElement) {
this.resizeObserver.observe(headerElement);
}
const footerElement = document.querySelector('footer');
if (footerElement) {
this.resizeObserver.observe(footerElement);
}
}
private updateContainerHeight() {
const headerElement = document.querySelector('app-header');
const footerElement = document.querySelector('footer');
const container = this.elementRef.nativeElement.querySelector('.appeal-container');
if (headerElement && footerElement && container) {
const headerHeight = headerElement.getBoundingClientRect().height;
const footerHeight = footerElement.getBoundingClientRect().height;
const calculatedHeight = `calc(100vh - ${headerHeight}px - ${footerHeight}px)`;
this.renderer.setStyle(container, 'min-height', calculatedHeight);
}
}
public onSubmit() { public onSubmit() {
if (this.form === undefined) { if (this.form === undefined) {
console.error('Form is undefined'); console.error('Form is undefined');
@ -119,13 +174,7 @@ export class AppealComponent implements OnInit, OnDestroy {
private router = inject(Router) private router = inject(Router)
protected formSubmitting: boolean = false;
private sendForm() { private sendForm() {
if (this.formSubmitting) {
return;
}
this.formSubmitting = true;
const rawValue = this.form.getRawValue(); const rawValue = this.form.getRawValue();
const uuid = this.authService.getUuid(); const uuid = this.authService.getUuid();
if (uuid === null) { if (uuid === null) {
@ -139,11 +188,7 @@ export class AppealComponent implements OnInit, OnDestroy {
username: this.authService.username()!, username: this.authService.username()!,
uuid: uuid uuid: uuid
} }
this.appealsService.submitMinecraftAppeal(appeal) this.appealsService.submitMinecraftAppeal(appeal).subscribe((result) => {
.pipe(
finalize(() => this.formSubmitting = false)
)
.subscribe((result) => {
if (!result.verified_mail) { if (!result.verified_mail) {
throw new Error('Mail not verified'); throw new Error('Mail not verified');
} }

View File

@ -1,185 +0,0 @@
<div>
<app-header [current_page]="'appeal'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Discord Appeal</h1>
</div>
</app-header>
<main>
<app-full-size>
<section class="darkmodeSection appeal-container">
<div class="form-container">
<div class="pages">
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
<h1>Discord Appeal</h1>
<p>We aim to respond within 48 hours.</p>
<button mat-raised-button (click)="nextPage()">
Next
</button>
</section>
}
@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 an appeal is <strong>not</strong> an instant process.
We will investigate the punishment you are appealing and respond within 48 hours.</p>
<p style="font-style: italic;">Appeals that seem to have been made with
little to no effort will be automatically denied.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<p>Please enter your Discord ID below.</p>
<p>You can find your Discord ID by going to User settings -> Advanced -> Developer Mode and turning it
on</p>
<p>With Developer Mode on in Discord click your profile in the bottom left and click Copy User ID</p>
<p>We use this to find your punishment on our Discord server.</p>
</div>
<mat-form-field appearance="fill">
<mat-label>Discord Id</mat-label>
<input matInput placeholder="Discord ID" [(ngModel)]="discordId"
minlength="17" maxlength="18" pattern="^[0-9]+$">
</mat-form-field>
<button mat-raised-button (click)="checkPunishment()"
[disabled]="punishmentLoading || authService.username() == null">
Check punishments
</button>
</section>
}
@if (currentPageIndex === 3) {
@if (bannedUser == null) {
<section class="formPage">
<div class="description">
<p>We were unable to find your punishment on our Discord server.</p>
</div>
</section>
} @else if (!bannedUser.isBanned || bannedUser.bannedUser == null) {
<section class="formPage">
<div class="description">
<p>Your discord account is not banned on our Discord server.</p>
</div>
</section>
} @else {
<section class="formPage">
<div class="description">
<img ngSrc="{{ bannedUser.bannedUser.avatarUrl }}" title="{{ bannedUser.bannedUser.name }}"
width="128" height="128" class="discord-avatar"
alt="Avatar for Discord user {{ bannedUser.bannedUser.name }}">
<p style="text-align: center">{{ bannedUser.bannedUser.name }}</p>
<p style="margin-top: 30px;">Your punishment is: <strong>{{ bannedUser.bannedUser.reason }}</strong>
</p>
<button style="display: block; margin-top: 30px;" class="centered" mat-raised-button
(click)="nextPage()"
[disabled]="authService.username() == null">
This is my punishment, continue to appeal
</button>
</div>
</section>)
}
}
@if (currentPageIndex >= 4) {
<form [formGroup]="form">
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Please enter your email.</h2>
<p style="font-style: italic">It does not have to be your minecraft email. You will have to verify
it</p>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email"
type="email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span>
</ng-container>
</div>
}
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid">
Next
</button>
</section>
}
@if (currentPageIndex === 5) {
<section class="formPage">
<div class="description">
<h2>Why should your ban be reduced or removed?</h2>
<p style="font-style: italic">Please take your time writing this, we're more likely to accept an
appeal if effort was put into it.</p>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Reason</mat-label>
<textarea matInput formControlName="appeal" placeholder="Reason" rows="6"></textarea>
@if (form.controls.appeal.invalid && form.controls.appeal.touched) {
<mat-error>
@if (form.controls.appeal.errors?.['required']) {
Reason is required
} @else if (form.controls.appeal.errors?.['minlength']) {
Reason must be at least 10 characters
}
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()" [disabled]="formSubmitting || form.invalid">
Submit Appeal
</button>
</section>
}
</form>
}
</div>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
}
</div>
</section>
</app-full-size>
</main>
</div>

View File

@ -1,121 +0,0 @@
:host {
display: block;
}
.appeal-container {
display: flex;
flex-direction: column;
height: 100%;
}
main {
flex: 1;
display: flex;
flex-direction: column;
}
.form-container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
.formPage {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
height: 100%;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.navigation-buttons {
display: flex;
gap: 16px;
margin-top: 20px;
}
.form-navigation {
display: flex;
justify-content: center;
gap: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: auto;
margin-bottom: auto;
&.active {
background-color: #fff;
}
}
.nav-button {
color: #1f9bde;
}
.pages {
margin-top: auto;
margin-bottom: auto;
}
.description {
max-width: 75ch;
text-align: left;
}
.valid-email {
display: flex;
align-items: center;
color: #4CAF50;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
}
.valid-email span {
color: #4CAF50;
font-weight: 500;
}
.discord-avatar {
width: 128px;
height: 128px;
border-radius: 50%;
object-fit: cover;
display: block;
margin-left: auto;
margin-right: auto;
}

View File

@ -1,218 +0,0 @@
import {Component, computed, effect, inject, OnInit, signal} from '@angular/core';
import {AppealsService, BannedUserResponse, DiscordAppeal, EmailEntry, MailService} from '@api';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {HeaderComponent} from '@header/header.component';
import {MatButton, MatIconButton} from '@angular/material/button';
import {NgOptimizedImage} from '@angular/common';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {AuthService} from '@services/auth.service';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatIconModule} from '@angular/material/icon';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {MatDialog} from '@angular/material/dialog';
import {Router} from '@angular/router';
import {finalize} from 'rxjs';
@Component({
selector: 'app-discord-appeal',
imports: [
FullSizeComponent,
HeaderComponent,
MatButton,
MatProgressSpinnerModule,
NgOptimizedImage,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
ReactiveFormsModule,
MatIconButton,
],
templateUrl: './discord-appeal.component.html',
styleUrl: './discord-appeal.component.scss'
})
export class DiscordAppealComponent implements OnInit {
private readonly appealService: AppealsService = inject(AppealsService)
private readonly mailService = inject(MailService);
private readonly dialog = inject(MatDialog);
private readonly router = inject(Router)
private emails = signal<EmailEntry[]>([]);
protected readonly authService = inject(AuthService);
protected bannedUser: BannedUserResponse | null = null;
protected discordId: string = window.location.hostname === 'localhost' ? '212303885988134914' : '';
protected verifiedEmails = computed(() => this.emails()
.filter(email => {
console.log(email.verified)
return email.verified
})
.map(email => {
console.log(email.email.toLowerCase())
return email.email.toLowerCase()
}));
protected emailIsValid = signal<boolean>(false);
protected currentPageIndex: number = 0;
protected totalPages: number[] = [0];
protected form: FormGroup<WebDiscordAppeal>;
constructor() {
this.form = new FormGroup({
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
effect(() => {
if (this.verifiedEmails().length > 0) {
console.log('verified emails')
console.log(this.verifiedEmails()[0])
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
this.emailIsValid.set(true);
}
});
}
ngOnInit(): void {
if (window.location.hostname === 'localhost') {
this.emails.set([{email: 'dev@alttd.com', verified: true}])
} else {
this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails);
});
}
this.form.valueChanges.subscribe(() => {
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
this.emailIsValid.set(true);
} else {
this.emailIsValid.set(false);
}
});
}
protected validateMailOrNextPage() {
if (this.emailIsValid()) {
this.nextPage();
return;
}
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
data: {email: this.form.getRawValue().email},
});
dialogRef.afterClosed().subscribe(result => {
if (result === true) {
this.emailIsValid.set(true);
}
});
}
public goToPage(pageIndex: number): void {
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
this.currentPageIndex = pageIndex;
}
}
public previousPage() {
this.goToPage(this.currentPageIndex - 1);
}
public nextPage() {
if (this.currentPageIndex === this.totalPages.length - 1) {
this.totalPages.push(this.currentPageIndex + 1);
}
this.goToPage(this.currentPageIndex + 1);
}
public isFirstPage(): boolean {
return this.currentPageIndex === 0;
}
public isLastPage(): boolean {
return this.currentPageIndex === this.totalPages.length - 1;
}
protected punishmentLoading: boolean = false;
protected checkPunishment() {
if (this.punishmentLoading) {
return;
}
if (window.location.hostname === 'localhost') {
this.bannedUser = {
isBanned: false,
bannedUser: {
userId: '212303885988134914',
reason: "This is a test punishment",
name: "stijn",
avatarUrl: "https://cdn.discordapp.com/avatars/212303885988134914/3a264be54ca7208d638a22143fc8fdb8.webp?size=160"
}
}
this.nextPage();
return
}
this.appealService.getBannedUser(this.discordId)
.pipe(
finalize(() => this.punishmentLoading = false)
)
.subscribe(user => {
this.bannedUser = user
this.nextPage();
});
}
protected onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
Object.keys(this.form.controls).forEach(field => {
const control = this.form!.get(field);
if (!(control instanceof FormGroup)) {
console.error('Control [' + control + '] is not a FormGroup');
return;
}
control.markAsTouched({onlySelf: true});
});
}
}
protected formSubmitting: boolean = false;
private sendForm() {
if (this.formSubmitting) {
return;
}
this.formSubmitting = true;
const rawValue = this.form.getRawValue();
const uuid = this.authService.getUuid();
if (uuid === null) {
throw new Error('JWT subject is null, are you logged in?');
}
const appeal: DiscordAppeal = {
discordId: this.discordId,
appeal: rawValue.appeal,
email: rawValue.email,
}
this.appealService.submitDiscordAppeal(appeal)
.pipe(
finalize(() => this.formSubmitting = false)
)
.subscribe((result) => {
if (!result.verified_mail) {
throw new Error('Mail not verified');
}
this.router.navigate(['/forms/sent'], {
state: {message: result.message}
}).then();
})
}
}
interface WebDiscordAppeal {
email: FormControl<string>;
appeal: FormControl<string>;
}

View File

@ -6,13 +6,8 @@
</div> </div>
</app-header> </app-header>
<main> <main>
<app-full-size> <section class="darkmodeSection">
<section class="darkmodeSection full-height flex">
<div class="margin-auto">
<p>{{ message }}</p> <p>{{ message }}</p>
</div>
</section> </section>
</app-full-size>
</main> </main>
</div> </div>

View File

@ -1,13 +1,11 @@
import {Component, inject, OnInit} from '@angular/core'; import {Component, inject, OnInit} from '@angular/core';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {HeaderComponent} from '@header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
selector: 'app-sent', selector: 'app-sent',
imports: [ imports: [
HeaderComponent, HeaderComponent
FullSizeComponent
], ],
templateUrl: './sent.component.html', templateUrl: './sent.component.html',
styleUrl: './sent.component.scss', styleUrl: './sent.component.scss',

View File

@ -1,52 +0,0 @@
<div>
<app-header [current_page]="'staff-pt'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
</app-header>
<section class="darkmodeSection full-height">
<div class="staff-pt-container centered">
<div class="week-header">
<button mat-icon-button (click)="prevWeek()" matTooltip="Previous week" aria-label="Previous week">
<mat-icon style="color: var(--font-color)">chevron_left</mat-icon>
</button>
<div class="week-title"><span style="color: var(--font-color)">{{ weekLabel() }}</span></div>
<button mat-icon-button (click)="nextWeek()" [disabled]="!canGoNextWeek()"
matTooltip="Next week" aria-label="Next week">
<mat-icon style="color: var(--font-color)">chevron_right</mat-icon>
</button>
</div>
<table mat-table [dataSource]="sortedStaffPt()" class="mat-elevation-z2 full-width" matSort
(matSortChange)="sort.set($event)"
[matSortActive]="sort().active" [matSortDirection]="sort().direction" [matSortDisableClear]="true">
<ng-container matColumnDef="staff_member">
<th mat-header-cell *matHeaderCellDef mat-sort-header="staff_member">Staff Member</th>
<td mat-cell *matCellDef="let row"> {{ row.staff_member }}</td>
</ng-container>
<ng-container matColumnDef="playtime">
<th mat-header-cell *matHeaderCellDef mat-sort-header="playtime">Playtime</th>
<td mat-cell *matCellDef="let row"
[style.color]="row.playtime < 420 ? 'red' : ''"> {{ minutesToHm(row.playtime) }}
</td>
</ng-container>
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef mat-sort-header="role">Rank</th>
<td mat-cell *matCellDef="let row"> {{ row.role }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
@if (!staffPt().length) {
<tr class="no-data">
<td colspan="3">No data for this week.</td>
</tr>
}
</table>
</div>
</section>
</div>

View File

@ -1,28 +0,0 @@
.staff-pt-container {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 60%;
}
.week-header {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.week-title {
font-size: 1.1rem;
font-weight: 600;
}
.full-width {
width: 100%;
}
.no-data td {
text-align: center;
padding: 16px;
}

View File

@ -1,138 +0,0 @@
import {Component, computed, inject, OnInit, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MatTableModule} from '@angular/material/table';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {MatTooltipModule} from '@angular/material/tooltip';
import {MatSortModule, Sort} from '@angular/material/sort';
import {SiteService, StaffPlaytime} from '@api';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-staff-pt',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule, MatTooltipModule, MatSortModule, HeaderComponent],
templateUrl: './staff-pt.component.html',
styleUrl: './staff-pt.component.scss'
})
export class StaffPtComponent implements OnInit {
siteService = inject(SiteService);
staffPt = signal<StaffPlaytime[]>([]);
sort = signal<Sort>({active: 'playtime', direction: 'desc'} as Sort);
sortedStaffPt = computed<StaffPlaytime[]>(() => {
const data = [...this.staffPt()];
const {active, direction} = this.sort();
if (!direction || !active) return data;
const dir = direction === 'asc' ? 1 : -1;
return data.sort((a, b) => {
switch (active) {
case 'staff_member':
return a.staff_member.localeCompare(b.staff_member) * dir;
case 'role':
return a.role.localeCompare(b.role) * dir;
case 'playtime':
default:
return ((a.playtime ?? 0) - (b.playtime ?? 0)) * dir;
}
});
});
weekStart = signal<Date>(this.getStartOfWeek(new Date()));
weekEnd = computed(() => this.getEndOfWeek(this.weekStart()));
todayStart = signal<Date>(this.startOfDay(new Date()));
displayedColumns = ['staff_member', 'playtime', 'role'];
ngOnInit(): void {
this.loadCurrentWeek();
}
private loadCurrentWeek() {
this.loadStaffData(this.weekStart(), this.weekEnd());
}
prevWeek() {
const prev = new Date(this.weekStart());
prev.setDate(prev.getDate() - 7);
prev.setHours(0, 0, 0, 0);
this.weekStart.set(prev);
this.loadCurrentWeek();
}
nextWeek() {
if (!this.canGoNextWeek()) return;
const next = new Date(this.weekStart());
next.setDate(next.getDate() + 7);
next.setHours(0, 0, 0, 0);
this.weekStart.set(next);
this.loadCurrentWeek();
}
canGoNextWeek(): boolean {
const nextWeekStart = new Date(this.weekStart());
nextWeekStart.setDate(nextWeekStart.getDate() + 7);
nextWeekStart.setHours(0, 0, 0, 0);
return nextWeekStart.getTime() <= this.todayStart().getTime();
}
weekLabel(): string {
const start = this.weekStart();
const end = this.weekEnd();
const startFmt = start.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
const endFmt = end.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
const year = end.getFullYear();
return `Week ${startFmt} ${endFmt}, ${year}`;
}
minutesToHm(mins?: number): string {
if (mins == null) return '';
const d = Math.floor(mins / 1440);
const h = Math.floor((mins % 1440) / 60);
const m = mins % 60;
const parts = [];
if (d > 0) {
parts.push(`${d}d`);
}
if (h > 0 || d > 0) {
parts.push(`${h}h`);
}
if (m > 0 || (h === 0 && d === 0)) {
parts.push(`${m}m`);
}
return parts.join(' ');
}
private loadStaffData(from: Date, to: Date) {
const fromUtc = new Date(from.getTime() - from.getTimezoneOffset() * 60000);
const toUtc = new Date(to.getTime() - to.getTimezoneOffset() * 60000);
this.siteService.getStaffPlaytime(fromUtc.toISOString(), toUtc.toISOString())
.subscribe({
next: data => this.staffPt.set(data ?? []),
error: err => console.error('Error getting staff playtime:', err)
});
}
private getStartOfWeek(date: Date): Date {
const d = new Date(date);
d.setDate(d.getDate() - d.getDay()); // Sunday start
d.setHours(0, 0, 0, 0);
return d;
}
private getEndOfWeek(start: Date): Date {
const d = new Date(start);
d.setDate(start.getDate() + 6);
d.setHours(23, 59, 59, 999);
return d;
}
private startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
}

View File

@ -132,8 +132,8 @@
<li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li>
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact <li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact
Us</a></li> Us</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li> <li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" href="https://alttd.com/appeal">Ban
<li class="nav_li"><a class="nav_link2" [routerLink]="['/discord-appeal']">Discord Ban Appeal</a></li> Appeal</a></li>
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li> <li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
</ul> </ul>
</li> </li>
@ -144,7 +144,6 @@
<ul class="dropdown"> <ul class="dropdown">
@if (hasAccess([PermissionClaim.HEAD_MOD])) { @if (hasAccess([PermissionClaim.HEAD_MOD])) {
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li> <li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/staff-pt']">StaffPlaytime</a></li>
} }
</ul> </ul>
</li> </li>

View File

@ -1,22 +1,7 @@
<div class="card-div"> <div class="card-div">
<mat-card> <mat-card>
<mat-card-header class="frame-header-fullwidth"> <mat-card-header>
<div class="frame-header-row" mat-card-title> <mat-card-title>Frames</mat-card-title>
<span class="frame-title-text">Frames</span>
<div>
<ng-content></ng-content>
<button mat-icon-button color="warn" (click)="removeFrame(currentFrame)"
matTooltip="Delete {{currentFrame}}"
[disabled]="frames.length <= 1">
<mat-icon [class.can-delete]="frames.length > 1" [class.can-not-delete]="frames.length <= 1">
delete
</mat-icon>
</button>
<button mat-icon-button (click)="addFrame()" matTooltip="Add Frame">
<mat-icon class="add-button">add</mat-icon>
</button>
</div>
</div>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="frames-container"> <div class="frames-container">
@ -48,10 +33,21 @@
</div> </div>
} }
</div> </div>
<div class="frame-actions">
<button mat-raised-button color="warn" (click)="removeFrame(frameId)"
[disabled]="frames.length <= 1">
Remove Frame
</button>
</div>
</div> </div>
</mat-tab> </mat-tab>
} }
</mat-tab-group> </mat-tab-group>
<div class="add-frame">
<button mat-raised-button color="primary" (click)="addFrame()">
Add New Frame
</button>
</div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -6,36 +6,6 @@
padding: 15px; padding: 15px;
} }
:host ::ng-deep .mat-mdc-card-header-text {
width: 90%;
text-align: center;
}
.frame-header-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.frame-title-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.add-button {
color: green
}
.can-delete {
color: red;
}
.can-not-delete {
color: gray;
}
.particles-list { .particles-list {
height: 550px; height: 550px;
overflow-y: auto; overflow-y: auto;

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FramesComponent } from './frames.component';
describe('FramesComponent', () => {
let component: FramesComponent;
let fixture: ComponentFixture<FramesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FramesComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FramesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,5 +1,5 @@
import {Component, inject} from '@angular/core'; import {Component} from '@angular/core';
import {MatIconButton} from "@angular/material/button"; import {MatButton, MatIconButton} from "@angular/material/button";
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {MatTab, MatTabGroup} from "@angular/material/tabs"; import {MatTab, MatTabGroup} from "@angular/material/tabs";
@ -7,11 +7,11 @@ import {ParticleData} from '../../models/particle.model';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {ParticleManagerService} from '../../services/particle-manager.service'; import {ParticleManagerService} from '../../services/particle-manager.service';
import {FrameManagerService} from '../../services/frame-manager.service'; import {FrameManagerService} from '../../services/frame-manager.service';
import {MatTooltipModule} from '@angular/material/tooltip';
@Component({ @Component({
selector: 'app-frames', selector: 'app-frames',
imports: [ imports: [
MatButton,
MatCard, MatCard,
MatCardContent, MatCardContent,
MatCardHeader, MatCardHeader,
@ -19,15 +19,17 @@ import {MatTooltipModule} from '@angular/material/tooltip';
MatIcon, MatIcon,
MatIconButton, MatIconButton,
MatTab, MatTab,
MatTabGroup, MatTabGroup
MatTooltipModule, ],
],
templateUrl: './frames.component.html', templateUrl: './frames.component.html',
styleUrl: './frames.component.scss' styleUrl: './frames.component.scss'
}) })
export class FramesComponent { export class FramesComponent {
private particleManagerService = inject(ParticleManagerService);
private frameManagerService = inject(FrameManagerService); constructor(
private particleManagerService: ParticleManagerService,
private frameManagerService: FrameManagerService) {
}
/** /**
* Get the particle data * Get the particle data

View File

@ -1,39 +0,0 @@
<div class="card-div">
<mat-card>
<mat-card-header>
<mat-card-title>
<span>Particle Manager</span>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<br>
<div class="row">
<div class="column">
<mat-form-field appearance="outline" class="type-field">
<mat-label>Particle to download</mat-label>
<input type="text"
[(ngModel)]="selectedParticle"
placeholder="Name of particle to download"
matInput>
</mat-form-field>
<button mat-icon-button (click)="downloadParticle()">
<mat-icon>download</mat-icon>
</button>
</div>
<div class="row">
<mat-form-field appearance="outline" class="type-field">
<mat-label>Particle to upload</mat-label>
<input type="text"
disabled
[ngModel]="createdParticleName"
placeholder="Name of particle to upload"
matInput>
</mat-form-field>
<button mat-icon-button (click)="uploadParticle()">
<mat-icon>upload</mat-icon>
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>

View File

@ -1,6 +0,0 @@
.card-div {
mat-card {
background-color: var(--color-primary);
color: var(--font-color);
}
}

View File

@ -1,86 +0,0 @@
import {Component, inject} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatInput, MatLabel} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIconModule} from '@angular/material/icon';
import {MatButtonModule} from '@angular/material/button';
import {ParticlesService} from '@api';
import {ParticleManagerService} from '@pages/particles/services/particle-manager.service';
import {FrameManagerService} from '@pages/particles/services/frame-manager.service';
@Component({
selector: 'app-particle-manager',
imports: [
FormsModule,
MatFormFieldModule,
MatInput,
MatLabel,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
MatIconModule,
MatButtonModule,
],
templateUrl: './particle-manager.component.html',
styleUrl: './particle-manager.component.scss'
})
export class ParticleManagerComponent {
private readonly particlesService = inject(ParticlesService)
private readonly particleManagerService = inject(ParticleManagerService)
private readonly frameManagerService = inject(FrameManagerService)
private selectedParticleName: string = '';
set selectedParticle(particle: string) {
this.selectedParticleName = particle;
}
get selectedParticle(): string {
return this.selectedParticleName;
}
get createdParticleName(): string {
return this.particleManagerService.getParticleData().particle_name;
}
protected downloadParticle() {
if (this.selectedParticleName === '') {
return;
}
this.particlesService
.downloadFile(this.selectedParticleName)
.subscribe({
next: data => {
data.text().then(text => {
if (text.startsWith('<')) {
console.error("Failed to download particle: Invalid file format")
return;
}
this.particleManagerService.loadParticleData(text)
this.frameManagerService.switchFrame(this.particleManagerService.getCurrentFrame())
})
},
error: () => {
console.error("Failed to download particle: Invalid file name")
},
}
)
}
protected uploadParticle() {
this.particlesService
.saveFile(this.createdParticleName, new Blob([this.particleManagerService.generateJson()], {type: 'application/json'}))
.subscribe({
next: () => {
console.log("Successfully uploaded particle")
},
error: () => {
console.error("Failed to upload particle")
}
})
}
}

View File

@ -1,16 +1,16 @@
<div class="card-div"> <div class="card-div">
<mat-card class="particle-card"> <mat-card class="particle-card">
<mat-card-header> <mat-card-header>
<mat-card-title>Particle input style</mat-card-title> <mat-card-title>Particle Properties</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="particle-properties"> <div class="particle-properties">
<div class="property-row"> <div class="property-row">
<mat-form-field appearance="outline"> <div class="color-picker">
<mat-label>Current color: {{ selectedColor }}</mat-label> <input type="color" [(ngModel)]="selectedColor">
<input type="color" class="color-input" matInput [(ngModel)]="selectedColor"> <span>Current color: {{ selectedColor }}</span>
</mat-form-field> </div>
<mat-form-field appearance="outline" class="type-field"> <mat-form-field appearance="fill" class="type-field">
<mat-label>Select Particle Type</mat-label> <mat-label>Select Particle Type</mat-label>
<input type="text" <input type="text"
placeholder="Search for a particle type" placeholder="Search for a particle type"
@ -36,4 +36,4 @@
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -6,10 +6,10 @@
.particle-properties { .particle-properties {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
} }
.property-row { .property-row {
margin-top: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
@ -22,8 +22,11 @@
max-width: 40ch; max-width: 40ch;
} }
.color-input { .color-picker {
height: 1em; display: flex;
flex: 1;
align-items: center;
gap: 10px;
} }
.color-picker input[type="color"] { .color-picker input[type="color"] {

View File

@ -0,0 +1,23 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ParticleComponent} from './particle.component';
describe('ParticleComponent', () => {
let component: ParticleComponent;
let fixture: ComponentFixture<ParticleComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ParticleComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ParticleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -7,22 +7,14 @@
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Particle Name</mat-label> <mat-label>Particle Name</mat-label>
<input required matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name"> <input matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Display Name</mat-label>
<input required matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Permission name</mat-label> <mat-label>Display Name</mat-label>
<input required matInput [(ngModel)]="particleData.permission" placeholder="Enter permission"> <input matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Package name</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field> </mat-form-field>
</div> </div>
@ -35,25 +27,37 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Lore</mat-label>
<textarea matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Display Item</mat-label> <mat-label>Display Item</mat-label>
<input required matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item"> <input matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="lore-double">
<mat-label>Lore</mat-label>
<textarea required matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Random Offset</mat-label> <mat-label>Permission</mat-label>
<input matInput type="number" [(ngModel)]="particleData.random_offset" <input matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
placeholder="Enter random offset">
</mat-form-field> </mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Package Permission</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Frame Delay</mat-label> <mat-label>Frame Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.frame_delay" placeholder="Enter frame delay"> <input matInput type="number" [(ngModel)]="particleData.frame_delay" placeholder="Enter frame delay">
@ -65,6 +69,9 @@
<mat-label>Repeat</mat-label> <mat-label>Repeat</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat" placeholder="Enter repeat count"> <input matInput type="number" [(ngModel)]="particleData.repeat" placeholder="Enter repeat count">
</mat-form-field> </mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Repeat Delay</mat-label> <mat-label>Repeat Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat_delay" <input matInput type="number" [(ngModel)]="particleData.repeat_delay"
@ -72,9 +79,17 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Random Offset</mat-label>
<input matInput type="number" [(ngModel)]="particleData.random_offset"
placeholder="Enter random offset">
</mat-form-field>
</div>
<div class="form-row"> <div class="form-row">
<mat-checkbox [(ngModel)]="particleData.stationary"><span>Stationary</span></mat-checkbox> <mat-checkbox [(ngModel)]="particleData.stationary"><span>Stationary</span></mat-checkbox>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -1,12 +1,5 @@
.form-row { .form-row {
display: grid; margin-bottom: 15px;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
align-items: start;
}
.lore-double {
grid-column: span 2;
} }
.card-div { .card-div {

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PropertiesComponent } from './properties.component';
describe('PropertiesComponent', () => {
let component: PropertiesComponent;
let fixture: ComponentFixture<PropertiesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PropertiesComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PropertiesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,4 +1,4 @@
import {Component, inject} from '@angular/core'; import {Component} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {MatCheckbox} from "@angular/material/checkbox"; import {MatCheckbox} from "@angular/material/checkbox";
import {MatFormField, MatInput, MatLabel} from "@angular/material/input"; import {MatFormField, MatInput, MatLabel} from "@angular/material/input";
@ -24,14 +24,17 @@ import {ParticleManagerService} from '../../services/particle-manager.service';
MatSelect, MatSelect,
ReactiveFormsModule, ReactiveFormsModule,
FormsModule FormsModule
], ],
templateUrl: './properties.component.html', templateUrl: './properties.component.html',
styleUrl: './properties.component.scss' styleUrl: './properties.component.scss'
}) })
export class PropertiesComponent { export class PropertiesComponent {
public particleTypes = Object.values(ParticleType); public particleTypes = Object.values(ParticleType);
private readonly particleManagerService = inject(ParticleManagerService); constructor(
private particleManagerService: ParticleManagerService,
) {
}
public get particleData(): ParticleData { public get particleData(): ParticleData {
return this.particleManagerService.getParticleData(); return this.particleManagerService.getParticleData();

View File

@ -5,10 +5,6 @@
<mat-label>Opacity</mat-label> <mat-label>Opacity</mat-label>
<input matInput type="number" [(ngModel)]="opacity" min="0" max="1" step="0.01" placeholder=""> <input matInput type="number" [(ngModel)]="opacity" min="0" max="1" step="0.01" placeholder="">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" style="width: 12ch; margin-left: 8px;">
<mat-label>Grid density</mat-label>
<input matInput type="number" [(ngModel)]="gridDensity" min="2" max="64" step="2" placeholder="">
</mat-form-field>
</div> </div>
<div class="button-row"> <div class="button-row">
<button mat-mini-fab color="primary" (click)="resetCamera()" <button mat-mini-fab color="primary" (click)="resetCamera()"
@ -16,30 +12,10 @@
<mat-icon>location_searching</mat-icon> <mat-icon>location_searching</mat-icon>
</button> </button>
<button mat-mini-fab color="primary" (click)="toggleShowParticlesWhenIntersectingPlane()"
[matTooltip]="onlyIntersecting ? 'Show all particles' : 'Show only intersecting particles'">
<mat-icon>{{ onlyIntersecting ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="toggleShowCharacter()"
[matTooltip]="showCharacter ? 'Hide character' : 'Show character'">
<mat-icon>{{ showCharacter ? 'person' : 'person_off' }}</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="togglePlaneLock()" <button mat-mini-fab color="primary" (click)="togglePlaneLock()"
[matTooltip]="isPlaneLocked ? 'Unlock plane' : 'Lock plane'"> [matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'">
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon> <mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
</button> </button>
<button mat-mini-fab color="primary" (click)="gridVisible = !gridVisible"
[matTooltip]="gridVisible ? 'Hide grid' : 'Show grid'">
<mat-icon>{{ gridVisible ? 'grid_off' : 'grid_on' }}</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="gridSnapEnabled = !gridSnapEnabled"
[matTooltip]="gridSnapEnabled ? 'Disable grid snap' : 'Enable grid snap'">
<mat-icon>{{ gridSnapEnabled ? 'push_pin' : 'pinch' }}</mat-icon>
</button>
</div> </div>
@if (isPlaneLocked) { @if (isPlaneLocked) {

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RenderContainerComponent } from './render-container.component';
describe('RenderContainerComponent', () => {
let component: RenderContainerComponent;
let fixture: ComponentFixture<RenderContainerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RenderContainerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RenderContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, inject, OnDestroy, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild} from '@angular/core';
import {MatMiniFabButton} from '@angular/material/button'; import {MatMiniFabButton} from '@angular/material/button';
import {IntersectionPlaneService, PlaneOrientation} from '../../services/intersection-plane.service'; import {IntersectionPlaneService, PlaneOrientation} from '../../services/intersection-plane.service';
@ -9,7 +9,6 @@ import {PlayerModelService} from '../../services/player-model.service';
import {InputHandlerService} from '../../services/input-handler.service'; import {InputHandlerService} from '../../services/input-handler.service';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {ParticleManagerService} from '../../services/particle-manager.service';
@Component({ @Component({
selector: 'app-render-container', selector: 'app-render-container',
@ -21,18 +20,20 @@ import {ParticleManagerService} from '../../services/particle-manager.service';
MatInput, MatInput,
MatFormField, MatFormField,
MatLabel MatLabel
], ],
templateUrl: './render-container.component.html', templateUrl: './render-container.component.html',
styleUrl: './render-container.component.scss' styleUrl: './render-container.component.scss'
}) })
export class RenderContainerComponent implements AfterViewInit, OnDestroy { export class RenderContainerComponent implements AfterViewInit, OnDestroy {
@ViewChild('rendererContainer') rendererContainer!: ElementRef; @ViewChild('rendererContainer') rendererContainer!: ElementRef;
private readonly intersectionPlaneService = inject(IntersectionPlaneService); constructor(
private readonly playerModelService = inject(PlayerModelService); private intersectionPlaneService: IntersectionPlaneService,
private readonly inputHandlerService = inject(InputHandlerService); private playerModelService: PlayerModelService,
private readonly rendererService = inject(RendererService); private inputHandlerService: InputHandlerService,
private readonly particleManagerService = inject(ParticleManagerService); private rendererService: RendererService,
) {
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.initializeScene(); this.initializeScene();
@ -86,32 +87,6 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
return this.intersectionPlaneService.currentOpacity; return this.intersectionPlaneService.currentOpacity;
} }
// Grid proxies
public get gridVisible(): boolean {
return this.intersectionPlaneService.getGridVisible();
}
public set gridVisible(v: boolean) {
this.intersectionPlaneService.setGridVisible(v);
}
public get gridDensity(): number {
return this.intersectionPlaneService.getGridDensity();
}
public set gridDensity(d: number) {
this.intersectionPlaneService.setGridDensity(d);
}
// Grid snap proxies
public get gridSnapEnabled(): boolean {
return this.intersectionPlaneService.getGridSnapEnabled();
}
public set gridSnapEnabled(v: boolean) {
this.intersectionPlaneService.setGridSnapEnabled(v);
}
/** /**
* Toggle the plane locked state * Toggle the plane locked state
*/ */
@ -124,14 +99,6 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
this.rendererService.resetCamera(); this.rendererService.resetCamera();
} }
public toggleShowParticlesWhenIntersectingPlane(): void {
this.particleManagerService.onlyIntersectingParticles = !this.particleManagerService.onlyIntersectingParticles;
}
public toggleShowCharacter(): void {
this.playerModelService.showCharacter = !this.playerModelService.showCharacter;
}
/** /**
* Get the current plane orientation * Get the current plane orientation
*/ */
@ -139,20 +106,6 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
return this.intersectionPlaneService.getCurrentOrientation(); return this.intersectionPlaneService.getCurrentOrientation();
} }
/**
* Retrieves the value indicating whether only intersecting particles are being considered.
*/
public get onlyIntersecting(): boolean {
return this.particleManagerService.onlyIntersectingParticles;
}
/**
* Retrieves the value indicating whether the character is being rendered.
*/
public get showCharacter(): boolean {
return this.playerModelService.showCharacter;
}
/** /**
* Set the plane orientation * Set the plane orientation
*/ */

View File

@ -6,13 +6,11 @@
</app-header> </app-header>
<main> <main>
<app-full-size [hideFooter]="true"> <section class="darkmodeSection">
<section class="darkmodeSection full-height">
<section class="column"> <section class="column">
<div class="renderer-section column"> <div class="renderer-section column">
<div class="flex row spacing"> <div class="flex row">
<div class="flex side-column"> <div class="flex side-column">
<app-particle></app-particle>
<app-particle-properties></app-particle-properties> <app-particle-properties></app-particle-properties>
</div> </div>
<div class="flex middle-column"> <div class="flex middle-column">
@ -26,18 +24,17 @@
</div> </div>
</div> </div>
<div class="flex side-column"> <div class="flex side-column">
<app-particle-manager> <app-particle></app-particle>
<app-frames></app-frames>
</app-particle-manager> <div>
<app-frames> <button mat-fab extended (click)="copyJson()">
<button mat-icon-button matTooltip="Copy JSON to clipboard" (click)="copyJson()">
<mat-icon>content_copy</mat-icon> <mat-icon>content_copy</mat-icon>
Copy JSON to clipboard
</button> </button>
</app-frames> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</section> </section>
</app-full-size>
</main> </main>

View File

@ -1,14 +1,9 @@
.renderer-section { .renderer-section {
margin-top: 15px;
flex: 1; flex: 1;
min-width: 300px; min-width: 300px;
display: flex; display: flex;
} }
.spacing {
gap: 10px;
}
.side-column { .side-column {
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ParticlesComponent } from './particles.component';
describe('ParticlesComponent', () => {
let component: ParticlesComponent;
let fixture: ComponentFixture<ParticlesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ParticlesComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ParticlesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,4 +1,4 @@
import {Component, ElementRef, inject, ViewChild} from '@angular/core'; import {Component, ElementRef, ViewChild} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
@ -23,9 +23,6 @@ import {FramesComponent} from './components/frames/frames.component';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {RenderContainerComponent} from './components/render-container/render-container.component'; import {RenderContainerComponent} from './components/render-container/render-container.component';
import {ParticlesService} from '@api'; import {ParticlesService} from '@api';
import {MatTooltipModule} from '@angular/material/tooltip';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {ParticleManagerComponent} from '@pages/particles/components/particle-manager/particle-manager.component';
@Component({ @Component({
selector: 'app-particles', selector: 'app-particles',
@ -46,21 +43,21 @@ import {ParticleManagerComponent} from '@pages/particles/components/particle-man
PropertiesComponent, PropertiesComponent,
ParticleComponent, ParticleComponent,
FramesComponent, FramesComponent,
RenderContainerComponent, RenderContainerComponent
MatTooltipModule, ],
FullSizeComponent,
ParticleManagerComponent
],
templateUrl: './particles.component.html', templateUrl: './particles.component.html',
styleUrl: './particles.component.scss' styleUrl: './particles.component.scss'
}) })
export class ParticlesComponent { export class ParticlesComponent {
@ViewChild('planeSlider') planeSlider!: ElementRef; @ViewChild('planeSlider') planeSlider!: ElementRef;
private readonly intersectionPlaneService = inject(IntersectionPlaneService); constructor(
private readonly particleManagerService = inject(ParticleManagerService); private intersectionPlaneService: IntersectionPlaneService,
private readonly matSnackBar = inject(MatSnackBar); private particleManagerService: ParticleManagerService,
private readonly particlesService = inject(ParticlesService); private matSnackBar: MatSnackBar,
private particlesService: ParticlesService,
) {
}
/** /**
* Update plane position based on slider * Update plane position based on slider

View File

@ -1,5 +1,5 @@
import {inject, Injectable} from '@angular/core'; import { Injectable } from '@angular/core';
import {ParticleManagerService} from './particle-manager.service'; import { ParticleManagerService } from './particle-manager.service';
/** /**
* Service responsible for managing animation frames * Service responsible for managing animation frames
@ -8,14 +8,15 @@ import {ParticleManagerService} from './particle-manager.service';
providedIn: 'root' providedIn: 'root'
}) })
export class FrameManagerService { export class FrameManagerService {
private readonly particleManager = inject(ParticleManagerService);
constructor(private particleManager: ParticleManagerService) {}
/** /**
* Adds a new frame * Adds a new frame
*/ */
addFrame(): void { addFrame(): void {
const frames = this.particleManager.getFrames(); const frames = this.particleManager.getFrames();
const frameId = `frame-${frames.length}`; const frameId = `frame${frames.length + 1}`;
frames.push(frameId); frames.push(frameId);
this.particleManager.setFrames(frames); this.particleManager.setFrames(frames);

View File

@ -1,7 +1,6 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
import {Subject} from 'rxjs';
/** /**
* Represents the possible orientations of the intersection plane * Represents the possible orientations of the intersection plane
@ -28,103 +27,14 @@ export class IntersectionPlaneService {
private planeLocked: boolean = false; private planeLocked: boolean = false;
private opacity: number = 0.05; private opacity: number = 0.05;
// Grid overlay
private gridHelper?: THREE.GridHelper;
private gridVisible: boolean = true;
private gridDensity: number = 4;
private gridSnapEnabled: boolean = true;
// Emits whenever plane position, orientation, or lock-affecting orientation updates change visuals
public readonly planeChanged$ = new Subject<void>();
private lastPlaneSignature: string | null = null;
constructor(private rendererService: RendererService) { constructor(private rendererService: RendererService) {
} }
public get stepSize() {
return 5
}
/**
* Creates or updates the grid helper attached to the intersection plane
* without affecting raycasting/placement.
*/
private createOrUpdateGrid(): void {
if (!this.intersectionPlane) return;
if (this.gridHelper) {
this.intersectionPlane.remove(this.gridHelper);
(this.gridHelper.geometry as THREE.BufferGeometry).dispose();
if (this.gridHelper.material.dispose) {
this.gridHelper.material.dispose();
}
this.gridHelper = undefined;
}
const size = this.stepSize;
const divisions = Math.max(1, Math.floor(size * this.gridDensity));
this.gridHelper = new THREE.GridHelper(size, divisions, 0x888888, 0xcccccc);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.z -= 0.005;
this.gridHelper.renderOrder = 2;
const gridMat = this.gridHelper.material as THREE.Material | THREE.Material[];
if (Array.isArray(gridMat)) {
gridMat.forEach(material => {
material.transparent = true;
material.depthWrite = false;
if (material.opacity !== undefined) {
material.opacity = 0.25;
}
});
} else {
gridMat.transparent = true;
gridMat.depthWrite = false;
gridMat.opacity = 0.25;
}
this.gridHelper.raycast = () => {
};
this.gridHelper.visible = this.gridVisible;
this.intersectionPlane.add(this.gridHelper);
}
public setGridVisible(visible: boolean): void {
this.gridVisible = visible;
if (this.gridHelper) this.gridHelper.visible = visible;
}
public getGridVisible(): boolean {
return this.gridVisible;
}
public setGridDensity(density: number): void {
this.gridDensity = Math.max(1, Math.min(64, Math.floor(density)));
if (this.intersectionPlane) {
this.createOrUpdateGrid();
}
}
public getGridDensity(): number {
return this.gridDensity;
}
public setGridSnapEnabled(enabled: boolean): void {
this.gridSnapEnabled = enabled;
}
public getGridSnapEnabled(): boolean {
return this.gridSnapEnabled;
}
/** /**
* Creates the intersection plane and adds it to the scene * Creates the intersection plane and adds it to the scene
*/ */
createIntersectionPlane(): THREE.Mesh { createIntersectionPlane(): THREE.Mesh {
const planeGeometry = new THREE.PlaneGeometry(5, 5); const planeGeometry = new THREE.PlaneGeometry(3, 3);
const planeMaterial = new THREE.MeshBasicMaterial({ const planeMaterial = new THREE.MeshBasicMaterial({
color: 0x00AA00, color: 0x00AA00,
transparent: true, transparent: true,
@ -136,10 +46,6 @@ export class IntersectionPlaneService {
this.intersectionPlane.position.z = 0; this.intersectionPlane.position.z = 0;
// Center the plane vertically with the player (player is about 2 blocks tall) // Center the plane vertically with the player (player is about 2 blocks tall)
this.intersectionPlane.position.y = 1; this.intersectionPlane.position.y = 1;
// Add grid overlay as a child so it follows rotation/position
this.createOrUpdateGrid();
this.rendererService.scene.add(this.intersectionPlane); this.rendererService.scene.add(this.intersectionPlane);
this.intersectionPlane.renderOrder = 1; this.intersectionPlane.renderOrder = 1;
@ -213,17 +119,17 @@ export class IntersectionPlaneService {
// Convert from 1/16th block to Three.js units // Convert from 1/16th block to Three.js units
const position = (this.planePosition / 16) const position = (this.planePosition / 16)
this.intersectionPlane.position.y = 1; this.intersectionPlane.position.y = 0.8;
this.intersectionPlane.position.x = 0; this.intersectionPlane.position.x = 0;
this.intersectionPlane.position.z = 0; this.intersectionPlane.position.z = 0;
// Position based on the current orientation // Position based on the current orientation
switch (this.currentOrientation) { switch (this.currentOrientation) {
case PlaneOrientation.VERTICAL_ABOVE: case PlaneOrientation.VERTICAL_ABOVE:
this.intersectionPlane.position.y = position; this.intersectionPlane.position.y = 0.8 - position;
break; break;
case PlaneOrientation.VERTICAL_BELOW: case PlaneOrientation.VERTICAL_BELOW:
this.intersectionPlane.position.y = position; this.intersectionPlane.position.y = 0.8 + position;
break; break;
case PlaneOrientation.HORIZONTAL_FRONT: case PlaneOrientation.HORIZONTAL_FRONT:
this.intersectionPlane.position.z = position; this.intersectionPlane.position.z = position;
@ -238,13 +144,6 @@ export class IntersectionPlaneService {
this.intersectionPlane.position.x = -position; this.intersectionPlane.position.x = -position;
break; break;
} }
// Notify listeners only if signature changed to avoid spamming during animation frames
const signature = `${this.currentOrientation}|${this.planePosition}`;
if (signature !== this.lastPlaneSignature) {
this.lastPlaneSignature = signature;
this.planeChanged$.next();
}
} }
/** /**

View File

@ -1,9 +1,7 @@
import {inject, Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
import {Particle, ParticleData, ParticleInfo, ParticleType} from '../models/particle.model'; import {Particle, ParticleData, ParticleInfo, ParticleType} from '../models/particle.model';
import {IntersectionPlaneService, PlaneOrientation} from './intersection-plane.service';
import {deepCopy} from '../../../util/deep-copy.util';
/** /**
* Service responsible for managing particles in the scene * Service responsible for managing particles in the scene
@ -27,41 +25,22 @@ export class ParticleManagerService {
random_offset: 0, random_offset: 0,
stationary: true, stationary: true,
frames: { frames: {
'frame-0': [] 'frame1': []
} }
}; };
private currentFrame: string = 'frame-0'; private currentFrame: string = 'frame1';
private frames: string[] = ['frame-0']; private frames: string[] = ['frame1'];
private selectedColor: string = '#ff0000'; private selectedColor: string = '#ff0000';
private selectedParticle: Particle = Particle.DUST; private selectedParticle: Particle = Particle.DUST;
private selectedSize: number = 1; private selectedSize: number = 1;
private onlyIntersecting: boolean = false;
private readonly rendererService = inject(RendererService); constructor(private rendererService: RendererService) {
private readonly intersectionPlaneService = inject(IntersectionPlaneService);
constructor() {
this.intersectionPlaneService.planeChanged$.subscribe(() => {
if (this.onlyIntersecting) {
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
}
});
} }
/** /**
* Adds a particle at the specified position * Adds a particle at the specified position
*/ */
addParticle(x: number, y: number, z: number): void { addParticle(x: number, y: number, z: number): void {
const planeSize = this.intersectionPlaneService.stepSize;
const divisions = Math.max(1, Math.floor(planeSize * this.intersectionPlaneService.getGridDensity()));
const gridStepPlane = planeSize / divisions;
if (this.intersectionPlaneService.getGridSnapEnabled()) {
x = Math.round(x / gridStepPlane) * gridStepPlane;
y = Math.round(y / gridStepPlane) * gridStepPlane;
z = Math.round(z / gridStepPlane) * gridStepPlane;
}
// Create a visual representation of the particle // Create a visual representation of the particle
const particleGeometry = new THREE.SphereGeometry(0.03 * this.selectedSize, 16, 16); const particleGeometry = new THREE.SphereGeometry(0.03 * this.selectedSize, 16, 16);
const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor}); const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor});
@ -75,7 +54,6 @@ export class ParticleManagerService {
const hexColor = this.selectedColor.replace('#', ''); const hexColor = this.selectedColor.replace('#', '');
//TODO make this work for more than just type DUST //TODO make this work for more than just type DUST
const particleInfo: ParticleInfo = { const particleInfo: ParticleInfo = {
particle_type: this.selectedParticle, particle_type: this.selectedParticle,
x: x, x: x,
@ -110,34 +88,7 @@ export class ParticleManagerService {
renderFrameParticles(frameId: string): void { renderFrameParticles(frameId: string): void {
if (!this.particleData.frames[frameId]) return; if (!this.particleData.frames[frameId]) return;
const filter = this.onlyIntersecting;
const orientation = this.intersectionPlaneService.getCurrentOrientation();
const offset16 = this.intersectionPlaneService.getPlanePosition();
const planePos = offset16 / 16; // convert from 1/16th units to world units
const epsilon = 0.02; // tolerance for intersection
const isOnPlane = (p: ParticleInfo) => {
if (!filter) return true;
switch (orientation) {
case PlaneOrientation.VERTICAL_ABOVE:
case PlaneOrientation.VERTICAL_BELOW:
// Horizontal plane at y = 0.8 +/- planePos
return Math.abs(p.y - (0.8 + (orientation === PlaneOrientation.VERTICAL_BELOW ? planePos : -planePos))) <= epsilon;
case PlaneOrientation.HORIZONTAL_FRONT:
return Math.abs(p.z - planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_BEHIND:
return Math.abs(p.z + planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_RIGHT:
return Math.abs(p.x - planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_LEFT:
return Math.abs(p.x + planePos) <= epsilon;
}
};
for (const particleInfo of this.particleData.frames[frameId]) { for (const particleInfo of this.particleData.frames[frameId]) {
if (!isOnPlane(particleInfo)) {
continue;
}
const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16); const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const color = this.getColor(particleInfo); const color = this.getColor(particleInfo);
@ -292,29 +243,6 @@ export class ParticleManagerService {
* Generates JSON output of the particle data * Generates JSON output of the particle data
*/ */
generateJson(): string { generateJson(): string {
const particleData = deepCopy(this.particleData)
if (this.particleData.package_permission) {
particleData.package_permission = 'apart.set.' + this.particleData.package_permission.toLowerCase().replace(' ', '-');
} else {
particleData.package_permission = 'apart.set.none';
}
particleData.permission = 'apart.particle.' + this.particleData.permission.toLowerCase().replace(' ', '-');
return JSON.stringify(this.particleData, null, 2); return JSON.stringify(this.particleData, null, 2);
} }
public get onlyIntersectingParticles(): boolean {
return this.onlyIntersecting;
}
public set onlyIntersectingParticles(value: boolean) {
this.onlyIntersecting = value;
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
}
public loadParticleData(data: string): void {
this.particleData = JSON.parse(data);
this.setCurrentFrame('frame-0');
this.frames = Object.keys(this.particleData.frames);
}
} }

View File

@ -1,4 +1,4 @@
import {inject, Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {RendererService} from './renderer.service'; import {RendererService} from './renderer.service';
@ -6,13 +6,13 @@ import {RendererService} from './renderer.service';
providedIn: 'root' providedIn: 'root'
}) })
export class PlayerModelService { export class PlayerModelService {
private readonly rendererService = inject(RendererService);
private playerModel!: THREE.Group; private playerModel!: THREE.Group;
private skinTexture!: THREE.Texture; private skinTexture!: THREE.Texture;
private characterVisible: boolean = true;
private textureLoaded = false; private textureLoaded = false;
constructor(private rendererService: RendererService) {
}
/** /**
* Loads a Minecraft skin texture from a URL * Loads a Minecraft skin texture from a URL
* @param textureUrl The URL of the skin texture to load * @param textureUrl The URL of the skin texture to load
@ -56,20 +56,10 @@ export class PlayerModelService {
} }
this.playerModel.renderOrder = 0; this.playerModel.renderOrder = 0;
this.playerModel.position.y = 0.2;
this.rendererService.scene.add(this.playerModel); this.rendererService.scene.add(this.playerModel);
return this.playerModel; return this.playerModel;
} }
public get showCharacter(): boolean {
return this.characterVisible;
}
public set showCharacter(showCharacter: boolean) {
this.playerModel.visible = showCharacter;
this.characterVisible = showCharacter;
}
/** /**
* Creates a simple colored player model (without textures) * Creates a simple colored player model (without textures)
*/ */

View File

@ -21,7 +21,7 @@ export class RendererService {
this.themeService.theme$.subscribe(theme => { this.themeService.theme$.subscribe(theme => {
this.currentTheme = theme; this.currentTheme = theme;
if (this.scene) { if (this.scene) {
this.setBackgroundColor(); this.setBackgroundColor(theme);
} }
}) })
} }
@ -32,7 +32,7 @@ export class RendererService {
initializeRenderer(container: ElementRef): void { initializeRenderer(container: ElementRef): void {
// Create scene // Create scene
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.setBackgroundColor(); this.setBackgroundColor(this.currentTheme);
// Get container dimensions // Get container dimensions
const containerWidth = container.nativeElement.clientWidth; const containerWidth = container.nativeElement.clientWidth;
@ -66,7 +66,7 @@ export class RendererService {
this.addLights(); this.addLights();
} }
private setBackgroundColor() { private setBackgroundColor(theme: THEME_MODE) {
this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE); this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE);
} }

View File

@ -60,10 +60,6 @@
<li>Consider not raising pigs at all, as their meat is exactly the same as beef and they have no secondary <li>Consider not raising pigs at all, as their meat is exactly the same as beef and they have no secondary
drops drops
</li> </li>
<li>
A special rule applies to villagers, you can have 70 villagers and up to 30 of those can have their AI
enabled. This applies to the entire loaded area.
</li>
<li>Do not fill your base with more vanilla pets than you need</li> <li>Do not fill your base with more vanilla pets than you need</li>
<li>Never circumvent entity cramming. It is there because a lot of entities crammed into a small space is <li>Never circumvent entity cramming. It is there because a lot of entities crammed into a small space is
hard on the server. hard on the server.

View File

@ -1,109 +0,0 @@
<ng-container>
<app-header [current_page]="'nickgenerator'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Nickname Generator</h1>
<h2>Customize your in-game nickname</h2>
<h3 style="font-family: 'minecraft-text', sans-serif; font-size: 0.8rem; margin-top: 10px;">Made by TheParm</h3>
<!--TODO remove this message when everything works-->
<p style="font-weight: bolder; color: red">NOTICE: This page is in the process of being updated to work on the new
site.<br> This version is functional, but only barely. Expect updates in the coming days</p>
</div>
</app-header>
<main>
<section class="darkmodeSection full-width">
<div class="containerNick">
<div class="controls">
@for (part of parts; track $index; let i = $index) {
<div class="part">
<div class="row">
<mat-form-field class="textField" appearance="outline">
<mat-label>Text</mat-label>
<input
matInput
[value]="part.text"
(input)="part.text = ($any($event.target).value || ''); onInputChanged()"
maxlength="16"
/>
<mat-hint align="end">{{ part.text.length }} / 16</mat-hint>
</mat-form-field>
<mat-checkbox
class="checkbox"
[(ngModel)]="part.gradient"
(change)="onGradientToggle(i)"
>Gradient
</mat-checkbox>
<mat-form-field
class="colorField"
appearance="outline"
[style.visibility]="(part.continuation && i>0 && parts[i-1].gradient && part.gradient) ? 'hidden' : 'visible'">
<mat-label>Color A</mat-label>
<input
matInput
type="color"
[value]="part.colorA"
(input)="part.colorA = $any($event.target).value; onInputChanged()"
/>
</mat-form-field>
<mat-form-field
class="colorField"
appearance="outline"
[style.visibility]="part.gradient ? 'visible' : 'hidden'">
<mat-label>Color B</mat-label>
<input
matInput
type="color"
[value]="part.colorB"
(input)="part.colorB = $any($event.target).value; onInputChanged()"
/>
</mat-form-field>
<mat-checkbox
class="checkbox"
[(ngModel)]="part.continuation"
(change)="onContinuationToggle(i)"
[disabled]="i===0 || !part.gradient || !parts[i-1].gradient"
>Continuation
</mat-checkbox
>
</div>
@if (part.invalid) {
<div class="invalid">(min 1 max 16 chars{{ part.gradient ? '' : ' for non-empty text' }})</div>
}
<mat-divider></mat-divider>
</div>
}
<div class="buttons">
<button mat-raised-button (click)="addPart()">Add Part</button>
<button mat-raised-button (click)="deletePart()">Remove Part</button>
</div>
@if (showCommands) {
<div class="commands">
<div class="commandRow">
<div class="command">{{ tryCmd }}</div>
<button mat-stroked-button (click)="copy(tryCmd, 'try')">{{ tryCommandButtonContent }}</button>
</div>
<div class="commandRow">
<div class="command">{{ requestCmd }}</div>
<button mat-stroked-button (click)="copy(requestCmd, 'request')">{{ requestCommandButtonContent }}
</button>
</div>
</div>
}
@if (showPreview) {
<div class="preview" [innerHTML]="previewHtml"></div>
}
</div>
</div>
</section>
</main>
</ng-container>

View File

@ -1,71 +0,0 @@
.containerNick {
max-width: 1220px;
margin: 0 auto;
height: 100%;
}
.controls {
width: 100%;
}
.part {
padding: 8px 0 16px 0;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.textField {
flex: 1 1 260px;
min-width: 220px;
}
.colorField {
width: 110px;
}
.checkbox {
padding: 0 6px;
}
.invalid {
color: #dd0000;
font-size: 12px;
margin-top: 6px;
}
.buttons {
display: flex;
gap: 12px;
margin: 20px 0 32px 0;
}
.commands {
display: grid;
gap: 10px;
margin-bottom: 16px;
}
.commandRow {
display: flex;
gap: 12px;
align-items: center;
}
.command {
padding: 10px 12px;
border-radius: 6px;
font-family: monospace;
overflow-x: auto;
}
.preview {
padding: 14px 12px;
border-radius: 6px;
font-family: 'minecraft-text', monospace;
white-space: pre-wrap;
}

Some files were not shown because too many files have changed in this diff Show More