Compare commits
No commits in common. "master" and "edit-punishments" have entirely different histories.
master
...
edit-punis
|
|
@ -51,9 +51,6 @@ public class SecurityConfig {
|
|||
.requestMatchers("/api/form/**").authenticated()
|
||||
.requestMatchers("/api/login/getUsername").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/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
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.http.HttpStatus;
|
||||
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.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AuthenticatedUuid {
|
||||
@Value("${UNSECURED:#{false}}")
|
||||
|
|
@ -29,9 +25,6 @@ public class AuthenticatedUuid {
|
|||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
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) {
|
||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
||||
}
|
||||
|
|
@ -46,28 +39,4 @@ public class AuthenticatedUuid {
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.EmailVerificationMapper;
|
||||
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
||||
import com.alttd.altitudeweb.model.*;
|
||||
import com.alttd.altitudeweb.services.forms.DiscordAppeal;
|
||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||
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.mail.AppealMail;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
|
@ -35,19 +36,12 @@ public class AppealController implements AppealsApi {
|
|||
|
||||
private final AppealDataMapper mapper;
|
||||
private final AppealMail appealMail;
|
||||
private final DiscordAppeal discordAppeal;
|
||||
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")
|
||||
@Override
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -242,8 +242,6 @@ public class HistoryApiController implements HistoryApi {
|
|||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
|
|
@ -255,6 +253,7 @@ public class HistoryApiController implements HistoryApi {
|
|||
}
|
||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
|
|
@ -276,8 +275,6 @@ public class HistoryApiController implements HistoryApi {
|
|||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
|
|
@ -289,6 +286,7 @@ public class HistoryApiController implements HistoryApi {
|
|||
}
|
||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
|
|
@ -313,8 +311,6 @@ public class HistoryApiController implements HistoryApi {
|
|||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
|
||||
final UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
|
|
@ -324,6 +320,7 @@ public class HistoryApiController implements HistoryApi {
|
|||
result.complete(false);
|
||||
return;
|
||||
}
|
||||
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
||||
int changed = editMapper.remove(historyTypeEnum, id);
|
||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
||||
|
|
|
|||
|
|
@ -210,16 +210,15 @@ public class LoginController implements LoginApi {
|
|||
try {
|
||||
log.debug("Loading user by uuid {}", uuid.toString());
|
||||
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
|
||||
Optional<PrivilegedUser> optionalPrivilegedUser = mapper
|
||||
Optional<PrivilegedUser> privilegedUser = mapper
|
||||
.getUserByUuid(uuid);
|
||||
|
||||
if (optionalPrivilegedUser.isEmpty()) {
|
||||
PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of());
|
||||
mapper.createPrivilegedUser(privilegedUser);
|
||||
if (privilegedUser.isEmpty()) {
|
||||
int privilegedUserId = mapper.createPrivilegedUser(uuid);
|
||||
privilegedUserCompletableFuture.complete(
|
||||
Optional.of(privilegedUser));
|
||||
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
|
||||
} else {
|
||||
privilegedUserCompletableFuture.complete(optionalPrivilegedUser);
|
||||
privilegedUserCompletableFuture.complete(privilegedUser);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load user by uuid", e);
|
||||
|
|
|
|||
|
|
@ -2,21 +2,15 @@ package com.alttd.altitudeweb.controllers.site;
|
|||
|
||||
import com.alttd.altitudeweb.api.SiteApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
|
||||
import com.alttd.altitudeweb.model.StaffPlaytimeListDto;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.site.StaffPtService;
|
||||
import com.alttd.altitudeweb.services.site.VoteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
|
@ -29,22 +23,8 @@ public class SiteController implements SiteApi {
|
|||
|
||||
private final VoteService voteService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
private final StaffPtService staffPtService;
|
||||
|
||||
@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() {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
package com.alttd.altitudeweb.services.discord;
|
||||
|
||||
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.OutputChannelMapper;
|
||||
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.UserType;
|
||||
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.webinterface.appeals.AppealSender;
|
||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
||||
import com.alttd.webinterface.send_message.DiscordSender;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -23,7 +16,8 @@ import org.springframework.stereotype.Service;
|
|||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
|
|
@ -32,67 +26,19 @@ public class AppealDiscord {
|
|||
|
||||
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) {
|
||||
// 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> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
||||
|
|
@ -118,8 +64,9 @@ public class AppealDiscord {
|
|||
// Group: User
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"User",
|
||||
"Username: `" + safe(appeal.username()) + "`\n" +
|
||||
"Username: " + safe(appeal.username()) + "\n" +
|
||||
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
||||
"Email: " + safe(appeal.email()) + "\n" +
|
||||
"Submitted: " + createdAt,
|
||||
false
|
||||
));
|
||||
|
|
@ -141,22 +88,6 @@ public class AppealDiscord {
|
|||
"Kicks: " + kicks,
|
||||
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());
|
||||
|
||||
|
|
@ -166,44 +97,15 @@ public class AppealDiscord {
|
|||
|
||||
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
||||
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
|
||||
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
|
||||
"New Appeal Submitted", description, fields, null, timestamp, null);
|
||||
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
|
||||
}
|
||||
|
||||
private static CompletableFuture<List<OutputChannel>> 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<>());
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
DiscordSender.getInstance().sendEmbedToChannels(
|
||||
channelIds,
|
||||
"New Appeal Submitted",
|
||||
description,
|
||||
fields,
|
||||
null,
|
||||
timestamp,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
|
||||
|
|
@ -230,50 +132,4 @@ public class AppealDiscord {
|
|||
return instant.atZone(ZoneId.of("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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import com.alttd.altitudeweb.database.discord.OutputChannel;
|
|||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
||||
import com.alttd.webinterface.send_message.DiscordSender;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -47,8 +46,8 @@ public class StaffApplicationDiscord {
|
|||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Applicant",
|
||||
"Username: `" + safe(username) + "`\n" +
|
||||
"Discord: `" + safe(application.discordUsername()) + "`\n" +
|
||||
"Username: " + safe(username) + "\n" +
|
||||
"Discord: " + safe(application.discordUsername()) + "\n" +
|
||||
"Email: " + safe(application.email()) + "\n" +
|
||||
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
||||
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
|
||||
|
|
@ -80,15 +79,16 @@ public class StaffApplicationDiscord {
|
|||
.toList();
|
||||
|
||||
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
|
||||
MessageForEmbed messageForEmbed = new MessageForEmbed(
|
||||
DiscordSender.getInstance().sendEmbedToChannels(
|
||||
channelIds,
|
||||
"New Staff Application Submitted",
|
||||
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
|
||||
"\nSubmitted: " + formatInstant(timestamp),
|
||||
"\nSubmitted: " + formatInstant(timestamp),
|
||||
fields,
|
||||
null,
|
||||
timestamp,
|
||||
null);
|
||||
DiscordSender.getInstance().sendEmbedWithThreadToChannels(channelIds, messageForEmbed, "Staff Application");
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.alttd.altitudeweb.services.limits;
|
||||
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -17,8 +16,6 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
|||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
|
|
@ -27,7 +24,6 @@ import java.util.UUID;
|
|||
public class RateLimitAspect {
|
||||
|
||||
private final InMemoryRateLimiterService rateLimiterService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Around("""
|
||||
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
||||
|
|
@ -41,6 +37,7 @@ public class RateLimitAspect {
|
|||
HttpServletRequest request = requestAttributes.getRequest();
|
||||
HttpServletResponse response = requestAttributes.getResponse();
|
||||
|
||||
String clientIp = request.getRemoteAddr();
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
|
||||
|
|
@ -57,12 +54,7 @@ public class RateLimitAspect {
|
|||
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
|
||||
String customKey = rateLimit.key();
|
||||
|
||||
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
|
||||
if (optionalUUID.isEmpty()) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
UUID uuid = optionalUUID.get();
|
||||
String key = uuid + "-" + (customKey.isEmpty() ? method.getName() : customKey);
|
||||
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
|
||||
|
||||
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
|
||||
|
||||
|
|
@ -75,7 +67,7 @@ public class RateLimitAspect {
|
|||
|
||||
return joinPoint.proceed();
|
||||
} 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.alttd.altitudeweb.services.mail;
|
|||
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
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.internet.MimeMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -30,20 +29,6 @@ public class AppealMail {
|
|||
|
||||
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.
|
||||
*
|
||||
|
|
@ -81,31 +66,4 @@ public class AppealMail {
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,8 @@
|
|||
<div class="columnContainer">
|
||||
<div>
|
||||
<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>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -8,7 +8,6 @@ public enum Databases {
|
|||
LUCK_PERMS("luckperms"),
|
||||
LITE_BANS("litebans"),
|
||||
DISCORD("discordLink"),
|
||||
PROXY_PLAYTIME("proxyplaytime"),
|
||||
VOTING_PLUGIN("votingplugin");
|
||||
|
||||
private final String internalName;
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
package com.alttd.altitudeweb.database.discord;
|
||||
|
||||
public record AppealList(Long userId, boolean next) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ public interface EditHistoryMapper {
|
|||
case BAN -> updateUntil("litebans_bans", id, until);
|
||||
case MUTE -> updateUntil("litebans_mutes", id, until);
|
||||
case KICK -> throw new IllegalArgumentException("KICK has no until");
|
||||
case WARN -> updateUntil("litebans_warnings", id, until);
|
||||
case WARN -> throw new IllegalArgumentException("WARN has no until");
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
package com.alttd.altitudeweb.database.luckperms;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerWithGroup(String username, UUID uuid, String group) {
|
||||
}
|
||||
|
|
@ -15,24 +15,8 @@ public interface TeamMemberMapper {
|
|||
SELECT players.username, players.uuid
|
||||
FROM luckperms_user_permissions AS permissions
|
||||
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
||||
WHERE permission = #{groupPermission} AND server = 'global'
|
||||
WHERE permission = #{groupPermission}
|
||||
AND world = 'global'
|
||||
""")
|
||||
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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ public interface VotingPluginUsersMapper {
|
|||
WeeklyTotal as totalVotesThisWeek,
|
||||
MonthTotal as totalVotesThisMonth,
|
||||
AllTimeTotal as totalVotesAllTime
|
||||
FROM votingplugin.VotingPlugin_Users
|
||||
FROM votingplugin.votingplugin_users
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.alttd.altitudeweb.database.web_db;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
|
@ -102,9 +103,9 @@ public interface PrivilegedUserMapper {
|
|||
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO privileged_users (uuid)
|
||||
VALUES (#{user.uuid})
|
||||
""")
|
||||
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class)
|
||||
void createPrivilegedUser(@Param("user") PrivilegedUser user);
|
||||
INSERT INTO privileged_users (uuid)
|
||||
VALUES (#{uuid})
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int createPrivilegedUser(UUID uuid);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
|
|
@ -34,11 +33,5 @@ public interface AppealMapper {
|
|||
UPDATE appeals SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markAppealAsSent(@Param("id") UUID id);
|
||||
|
||||
@Update("""
|
||||
UPDATE appeals SET assigned_to = #{assignedTo}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void assignAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
|
||||
void markAppealAsSent(UUID id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ public class Connection {
|
|||
InitializeWebDb.init();
|
||||
InitializeLiteBans.init();
|
||||
InitializeLuckPerms.init();
|
||||
InitializeProxyPlaytime.init();
|
||||
InitializeDiscord.init();
|
||||
InitializeVotingPlugin.init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.alttd.altitudeweb.setup;
|
||||
|
||||
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.luckperms.TeamMemberMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -13,7 +12,6 @@ public class InitializeDiscord {
|
|||
log.info("Initializing Discord");
|
||||
Connection.getConnection(Databases.DISCORD, (configuration) -> {
|
||||
configuration.addMapper(OutputChannelMapper.class);
|
||||
configuration.addMapper(AppealListMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized Discord");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.SettingsMapper;
|
||||
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.mail.EmailVerificationMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -25,7 +24,6 @@ public class InitializeWebDb {
|
|||
configuration.addMapper(KeyPairMapper.class);
|
||||
configuration.addMapper(PrivilegedUserMapper.class);
|
||||
configuration.addMapper(AppealMapper.class);
|
||||
configuration.addMapper(DiscordAppealMapper.class);
|
||||
configuration.addMapper(StaffApplicationMapper.class);
|
||||
configuration.addMapper(EmailVerificationMapper.class);
|
||||
}).join()
|
||||
|
|
@ -35,7 +33,6 @@ public class InitializeWebDb {
|
|||
createPrivilegedUsersTable(sqlSession);
|
||||
createPrivilegesTable(sqlSession);
|
||||
createAppealTable(sqlSession);
|
||||
createdDiscordAppealTable(sqlSession);
|
||||
createStaffApplicationsTable(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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import lombok.extern.slf4j.Slf4j;
|
|||
public class DiscordBot {
|
||||
|
||||
public static void main(String[] args) {
|
||||
DiscordBotInstance discordBotInstance = DiscordBotInstance.getInstance();
|
||||
discordBotInstance.getJda();
|
||||
String discordToken = System.getProperty("DISCORD_TOKEN");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
package com.alttd.webinterface.appeals;
|
||||
|
||||
public record BannedUser(long userId, String reason, String name, String avatarUrl) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,41 +6,14 @@ import net.dv8tion.jda.api.JDA;
|
|||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordBotInstance {
|
||||
|
||||
private static final DiscordBotInstance INSTANCE = new DiscordBotInstance();
|
||||
|
||||
public static DiscordBotInstance getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private DiscordBotInstance() {}
|
||||
|
||||
@Getter
|
||||
private JDA jda;
|
||||
private volatile boolean ready = false;
|
||||
|
||||
public JDA getJda() {
|
||||
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) {
|
||||
public synchronized void start(String token) {
|
||||
if (jda != null) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,26 @@
|
|||
package com.alttd.webinterface.send_message;
|
||||
|
||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
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.channel.concrete.TextChannel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.awt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordSender {
|
||||
|
||||
private static final DiscordSender INSTANCE = new DiscordSender();
|
||||
|
||||
private final DiscordBotInstance botInstance = DiscordBotInstance.getInstance();
|
||||
private final DiscordBotInstance botInstance = new DiscordBotInstance();
|
||||
|
||||
private DiscordSender() {}
|
||||
|
||||
|
|
@ -30,7 +28,19 @@ public class DiscordSender {
|
|||
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) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord message.");
|
||||
return;
|
||||
|
|
@ -60,36 +70,12 @@ public class DiscordSender {
|
|||
});
|
||||
}
|
||||
|
||||
public void sendEmbedWithThreadToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed, String threadName) {
|
||||
sendEmbedToChannels(channelIds, messageForEmbed).whenCompleteAsync((result, error) -> {
|
||||
if (error != null) {
|
||||
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) {
|
||||
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
|
||||
Integer colorRgb, Instant timestamp, String footer) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord embed.");
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!botInstance.isReady()) {
|
||||
|
|
@ -97,31 +83,43 @@ public class DiscordSender {
|
|||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
} catch (Exception 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 (channel == null) {
|
||||
log.warn("TextChannel with id {} not found when sending embed message", channelId);
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
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()));
|
||||
}
|
||||
}
|
||||
CompletableFuture<Optional<Message>> completableFuture = new CompletableFuture<>();
|
||||
channel.sendMessageEmbeds(embed).queue(
|
||||
message -> {
|
||||
completableFuture.complete(Optional.of(message));
|
||||
log.debug("Sent embed to channel {}", channelId);
|
||||
},
|
||||
error -> {
|
||||
completableFuture.complete(Optional.empty());
|
||||
log.error("Failed sending embed to channel {}", channelId, error);
|
||||
}
|
||||
);
|
||||
return completableFuture;
|
||||
|
||||
MessageEmbed embed = eb.build();
|
||||
|
||||
channelIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(id -> {
|
||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||
if (channel == null) {
|
||||
log.warn("TextChannel with id {} not found", id);
|
||||
return;
|
||||
}
|
||||
channel.sendMessageEmbeds(embed).queue(
|
||||
success -> log.debug("Sent embed to channel {}", id),
|
||||
error -> log.error("Failed sending embed to channel {}", id, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Data
|
||||
|
|
|
|||
|
|
@ -30,11 +30,6 @@
|
|||
"glob": "**/*",
|
||||
"input": "public",
|
||||
"output": "public"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public",
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
|
|
|||
|
|
@ -2,28 +2,6 @@ import {Routes} from '@angular/router';
|
|||
import {AuthGuard} from './guards/auth.guard';
|
||||
|
||||
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: '',
|
||||
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
|
||||
|
|
@ -36,14 +14,6 @@ export const routes: Routes = [
|
|||
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',
|
||||
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
||||
|
|
@ -140,38 +110,18 @@ export const routes: Routes = [
|
|||
path: 'forms',
|
||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||
},
|
||||
{
|
||||
path: 'appeal/:code',
|
||||
redirectTo: 'forms/appeal/:code',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'appeal',
|
||||
redirectTo: 'forms/appeal',
|
||||
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',
|
||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||
canActivate: [AuthGuard],
|
||||
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']}
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forms/sent',
|
||||
|
|
@ -204,6 +154,6 @@ export const routes: Routes = [
|
|||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
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 {MatDialog} from '@angular/material/dialog';
|
||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||
import {catchError} from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -22,23 +21,6 @@ export class AuthGuard implements CanActivate {
|
|||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): 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$()) {
|
||||
this.router.createUrlTree(['/']);
|
||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||
|
|
|
|||
|
|
@ -9,90 +9,34 @@
|
|||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">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 class="customContainer">
|
||||
<h2>Current Nitro Boosters</h2>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<section id="social" class="darkmodeSectionThree">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<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 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>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Developers</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>
|
||||
<section id="crateTeam" class="darkmodeSection">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">Crate Team</h2>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<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">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<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 style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<section class="darkmodeSection">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<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 style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<div style="flex-direction: column;">
|
||||
|
|
@ -102,33 +46,16 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<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 style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<div style="flex-direction: column;">
|
||||
<p style="text-align: center;"><a style="cursor: pointer;" (click)="toggleSection('yt-stream-req')"
|
||||
id="reqButton">{{ getTextForSection('yt-stream-req') }}</a></p>
|
||||
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
|
||||
</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;">
|
||||
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,12 @@
|
|||
.sectionTitle {
|
||||
flex: 0 0 100%;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.member {
|
||||
width: 33%;
|
||||
min-width: 250px;
|
||||
padding-bottom: 50px;
|
||||
.customContainer {
|
||||
width: 80%;
|
||||
max-width: 1020px;
|
||||
margin: auto;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.member img {
|
||||
padding-bottom: 15px;
|
||||
.hide {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +1,14 @@
|
|||
import {Component, inject} from '@angular/core';
|
||||
import {Component} from '@angular/core';
|
||||
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({
|
||||
selector: 'app-community',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
AsyncPipe,
|
||||
NgOptimizedImage
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './community.component.html',
|
||||
styleUrl: './community.component.scss'
|
||||
})
|
||||
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...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<ng-container>
|
||||
<app-header [current_page]="'team'" height="450px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">>
|
||||
[overlay_gradient]="0.5">>
|
||||
<div class="title" header-content>
|
||||
<h1>Staffing Team</h1>
|
||||
<h2>The team that makes Altitude happen. Your owners, admins, moderators, and trainees are all working together to
|
||||
create Altitude, to create home. This is where the magic happens.</h2>
|
||||
create Altitude, to create home. This is where the magic happens.</h2>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
|
|
@ -15,72 +15,72 @@
|
|||
@for (member of getTeamMembers('owner') | 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>Owner</p>
|
||||
</div>
|
||||
}
|
||||
@for (member of getTeamMembers('manager') | 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>Manager</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Admins</h2>
|
||||
@for (member of getTeamMembers('admin') | 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>Admin</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Head Moderators</h2>
|
||||
@for (member of getTeamMembers('headmod') | 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>Head Mod</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Moderators</h2>
|
||||
@for (member of getTeamMembers('moderator') | 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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@if ((getTeamMembers('trainee') | async)?.length ?? 0 > 0) {
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Trainees</h2>
|
||||
@for (member of getTeamMembers('trainee') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
<p>Owner</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</ng-container>
|
||||
@for (member of getTeamMembers('manager') | 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>Manager</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Admins</h2>
|
||||
@for (member of getTeamMembers('admin') | 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>Admin</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Head Moderators</h2>
|
||||
@for (member of getTeamMembers('headmod') | 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>Head Mod</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Moderators</h2>
|
||||
@for (member of getTeamMembers('moderator') | 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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@if ((getTeamMembers('trainee') | async)?.length ?? 0 > 0) {
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Trainees</h2>
|
||||
@for (member of getTeamMembers('trainee') | 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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -6,161 +6,156 @@
|
|||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<app-full-size>
|
||||
<section class="darkmodeSection appeal-container">
|
||||
<div class="form-container">
|
||||
<div class="pages">
|
||||
@if (currentPageIndex === 0) {
|
||||
@if (history()?.length === 0) {
|
||||
<section class="formPage">
|
||||
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
|
||||
<h1>Punishment Appeal</h1>
|
||||
<p>You have no punishments to appeal.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="formPage">
|
||||
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
|
||||
<h1>Punishment Appeal</h1>
|
||||
<p>We aim to respond within 48 hours.</p>
|
||||
<button mat-raised-button (click)="nextPage()" [disabled]="history() == null">
|
||||
@if (history() == null) {
|
||||
<mat-spinner></mat-spinner>
|
||||
} @else {
|
||||
Next
|
||||
}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
<section class="darkmodeSection appeal-container">
|
||||
<div class="form-container">
|
||||
<div class="pages">
|
||||
@if (currentPageIndex === 0) {
|
||||
@if (history()?.length === 0) {
|
||||
<section class="formPage">
|
||||
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
|
||||
<h1>Punishment Appeal</h1>
|
||||
<p>You have no punishments to appeal.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="formPage">
|
||||
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
|
||||
<h1>Punishment Appeal</h1>
|
||||
<p>We aim to respond within 48 hours.</p>
|
||||
<button mat-raised-button (click)="nextPage()" [disabled]="history() == null">
|
||||
@if (history() == null) {
|
||||
<mat-spinner></mat-spinner>
|
||||
} @else {
|
||||
Next
|
||||
}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (currentPageIndex === 1) {
|
||||
@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">
|
||||
<h2>Please select the punishment you want to appeal</h2>
|
||||
</div>
|
||||
<mat-form-field>
|
||||
<mat-label>Punishment</mat-label>
|
||||
<mat-select (valueChange)="onPunishmentSelected($event)">
|
||||
@for (punishment of history(); track punishment) {
|
||||
<mat-option [value]="punishment">{{ punishment.type }} - {{ punishment.reason }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (selectedPunishment() != null) {
|
||||
<button mat-raised-button (click)="nextPage()" [disabled]="selectedPunishment() == null">
|
||||
Appeal {{ selectedPunishment()!.type }}
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<form [formGroup]="form">
|
||||
@if (currentPageIndex === 3) {
|
||||
<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>
|
||||
<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">
|
||||
@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)="nextPage()" [disabled]="authService.username() == null">
|
||||
I, {{ authService.username() }}, understand and agree
|
||||
<button mat-raised-button (click)="validateMailOrNextPage()" [disabled]="form.controls.email.invalid">
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (currentPageIndex === 2) {
|
||||
@if (currentPageIndex === 4) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Please select the punishment you want to appeal</h2>
|
||||
</div>
|
||||
<mat-form-field>
|
||||
<mat-label>Punishment</mat-label>
|
||||
<mat-select (valueChange)="onPunishmentSelected($event)">
|
||||
@for (punishment of history(); track punishment) {
|
||||
<mat-option [value]="punishment">{{ punishment.type }} - {{ punishment.reason }}</mat-option>
|
||||
<h2>Why should your {{ selectedPunishment()?.type }} 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-select>
|
||||
</mat-form-field>
|
||||
@if (selectedPunishment() != null) {
|
||||
<button mat-raised-button (click)="nextPage()" [disabled]="selectedPunishment() == null">
|
||||
Appeal {{ selectedPunishment()!.type }}
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-raised-button (click)="onSubmit()" [disabled]="form.invalid">
|
||||
Submit Appeal
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<form [formGroup]="form">
|
||||
@if (currentPageIndex === 3) {
|
||||
<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">
|
||||
@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 === 4) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Why should your {{ selectedPunishment()?.type }} 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>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</app-full-size>
|
||||
<!-- Navigation dots -->
|
||||
@if (totalPages.length > 1) {
|
||||
<div class="form-navigation">
|
||||
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
|
||||
<mat-icon>navigate_before</mat-icon>
|
||||
</button>
|
||||
|
||||
@for (i of totalPages; track i) {
|
||||
<div
|
||||
class="nav-dot"
|
||||
[class.active]="i === currentPageIndex"
|
||||
(click)="goToPage(i)">
|
||||
</div>
|
||||
}
|
||||
|
||||
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
|
||||
<mat-icon>navigate_next</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
.appeal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
main {
|
||||
|
|
|
|||
|
|
@ -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 {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api';
|
||||
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 {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
|
||||
import {Router} from '@angular/router';
|
||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
||||
import {finalize} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-appeal',
|
||||
|
|
@ -29,12 +37,11 @@ import {finalize} from 'rxjs';
|
|||
MatSelectModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
FullSizeComponent,
|
||||
],
|
||||
templateUrl: './appeal.component.html',
|
||||
styleUrl: './appeal.component.scss'
|
||||
})
|
||||
export class AppealComponent implements OnInit, OnDestroy {
|
||||
export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
|
||||
private mailService = inject(MailService);
|
||||
private historyFormatService = inject(HistoryFormatService);
|
||||
|
|
@ -54,7 +61,10 @@ export class AppealComponent implements OnInit, OnDestroy {
|
|||
protected emailIsValid = signal<boolean>(false);
|
||||
protected dialog = inject(MatDialog);
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private renderer: Renderer2
|
||||
) {
|
||||
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)]})
|
||||
|
|
@ -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() {
|
||||
if (this.resizeObserver) {
|
||||
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() {
|
||||
if (this.form === undefined) {
|
||||
console.error('Form is undefined');
|
||||
|
|
@ -119,13 +174,7 @@ export class AppealComponent implements OnInit, OnDestroy {
|
|||
|
||||
private router = inject(Router)
|
||||
|
||||
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) {
|
||||
|
|
@ -139,18 +188,14 @@ export class AppealComponent implements OnInit, OnDestroy {
|
|||
username: this.authService.username()!,
|
||||
uuid: uuid
|
||||
}
|
||||
this.appealsService.submitMinecraftAppeal(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();
|
||||
})
|
||||
this.appealsService.submitMinecraftAppeal(appeal).subscribe((result) => {
|
||||
if (!result.verified_mail) {
|
||||
throw new Error('Mail not verified');
|
||||
}
|
||||
this.router.navigate(['/forms/sent'], {
|
||||
state: {message: result.message}
|
||||
}).then();
|
||||
})
|
||||
}
|
||||
|
||||
public currentPageIndex: number = 0;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -6,13 +6,8 @@
|
|||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<app-full-size>
|
||||
<section class="darkmodeSection full-height flex">
|
||||
<div class="margin-auto">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</app-full-size>
|
||||
<section class="darkmodeSection">
|
||||
<p>{{ message }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sent',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
FullSizeComponent
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './sent.component.html',
|
||||
styleUrl: './sent.component.scss',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact
|
||||
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" [routerLink]="['/discord-appeal']">Discord Ban Appeal</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" href="https://alttd.com/appeal">Ban
|
||||
Appeal</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
|
@ -144,7 +144,6 @@
|
|||
<ul class="dropdown">
|
||||
@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]="['/staff-pt']">StaffPlaytime</a></li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
import {AfterViewInit, Component, ElementRef, OnDestroy, Renderer2} from '@angular/core';
|
||||
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatDividerModule} from '@angular/material/divider';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
|
||||
interface Part {
|
||||
text: string;
|
||||
gradient: boolean;
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
continuation: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-nick-generator',
|
||||
templateUrl: './nick-generator.component.html',
|
||||
styleUrls: ['./nick-generator.component.scss'],
|
||||
imports: [
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
HeaderComponent,
|
||||
MatCheckboxModule,
|
||||
FormsModule,
|
||||
MatDividerModule,
|
||||
MatButtonModule,
|
||||
]
|
||||
})
|
||||
export class NickGeneratorComponent implements AfterViewInit, OnDestroy {
|
||||
parts: Part[] = [
|
||||
{text: '', gradient: false, colorA: '#ffffff', colorB: '#ffffff', continuation: false}
|
||||
];
|
||||
|
||||
tryCmd = '';
|
||||
requestCmd = '';
|
||||
previewHtml: SafeHtml = '';
|
||||
showPreview = false;
|
||||
showCommands = false;
|
||||
private handleResize: any;
|
||||
private boundHandleResize: any;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
|
||||
constructor(private sanitizer: DomSanitizer,
|
||||
private elementRef: ElementRef,
|
||||
private renderer: Renderer2
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundHandleResize) {
|
||||
window.removeEventListener('resize', this.boundHandleResize);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.setupResizeObserver();
|
||||
window.addEventListener('resize', this.boundHandleResize);
|
||||
this.boundHandleResize = this.handleResize.bind(this);
|
||||
setTimeout(() => this.updateContainerHeight(), 0);
|
||||
}
|
||||
|
||||
addPart(): void {
|
||||
this.parts.push({text: '', gradient: false, colorA: '#ffffff', colorB: '#ffffff', continuation: false});
|
||||
this.onInputChanged();
|
||||
}
|
||||
|
||||
deletePart(): void {
|
||||
if (this.parts.length > 1) {
|
||||
this.parts.pop();
|
||||
// If last part was a gradient, unset continuation on new last part
|
||||
if (this.parts.length > 0) this.parts[this.parts.length - 1].continuation = false;
|
||||
this.onInputChanged();
|
||||
}
|
||||
}
|
||||
|
||||
onGradientToggle(i: number): void {
|
||||
// Toggling gradient affects availability of continuation for this & next part
|
||||
if (!this.parts[i].gradient) {
|
||||
// If gradient turned off, force continuation off for this index (not visible anymore)
|
||||
this.parts[i].continuation = false;
|
||||
}
|
||||
if (i + 1 < this.parts.length && !this.parts[i + 1].gradient) {
|
||||
this.parts[i + 1].continuation = false;
|
||||
}
|
||||
this.onInputChanged();
|
||||
}
|
||||
|
||||
onContinuationToggle(_: number): void {
|
||||
this.onInputChanged();
|
||||
}
|
||||
|
||||
onInputChanged(): void {
|
||||
let result = '';
|
||||
let preview = '';
|
||||
let valid = true;
|
||||
let nickLen = 0;
|
||||
let prevColorB = '#ffffff';
|
||||
|
||||
for (let i = 0; i < this.parts.length; i++) {
|
||||
const p = this.parts[i];
|
||||
const len = p.text.length;
|
||||
nickLen += len;
|
||||
|
||||
const partValid =
|
||||
(p.gradient && len >= 1 && len <= 16) ||
|
||||
(!p.gradient && len > 0);
|
||||
|
||||
p.invalid = !partValid;
|
||||
if (!partValid) {
|
||||
valid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (p.gradient) {
|
||||
// Continuation allowed only if previous & current are gradient
|
||||
const contAllowed = i > 0 && this.parts[i - 1].gradient;
|
||||
const cont = p.continuation && contAllowed;
|
||||
|
||||
if (cont) {
|
||||
result += p.text;
|
||||
preview += this.generateGradient(p.text, prevColorB, p.colorB);
|
||||
} else {
|
||||
result += `{${p.colorA}>}${p.text}`;
|
||||
preview += this.generateGradient(p.text, p.colorA, p.colorB);
|
||||
}
|
||||
|
||||
// Add closing/continuation marker
|
||||
const nextContinuation = (i + 1 < this.parts.length) && this.parts[i + 1].continuation;
|
||||
if (i < this.parts.length - 1) {
|
||||
result += `{${p.colorB}<>}`;
|
||||
} else {
|
||||
result += `{${p.colorB}<}`;
|
||||
}
|
||||
prevColorB = p.colorB;
|
||||
} else {
|
||||
// Solid
|
||||
result += `{${p.colorA}}${p.text}`;
|
||||
preview += this.generateSolidColor(p.text, p.colorA);
|
||||
}
|
||||
}
|
||||
|
||||
this.tryCmd = '';
|
||||
this.requestCmd = '';
|
||||
this.showPreview = false;
|
||||
this.showCommands = false;
|
||||
|
||||
if (valid && result.length > 0 && nickLen >= 3 && nickLen <= 16) {
|
||||
this.tryCmd = `/nick try ${result}`;
|
||||
this.requestCmd = `/nick request ${result}`;
|
||||
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml(
|
||||
this.generateSolidColor('Nickname preview: ', '#ffffff') + preview
|
||||
);
|
||||
this.showPreview = true;
|
||||
this.showCommands = true;
|
||||
} else {
|
||||
if (!valid && (this.parts.length > 1 || nickLen > 0)) {
|
||||
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml(
|
||||
this.generateSolidColor('Invalid part(s) length', '#dd0000')
|
||||
);
|
||||
} else if (valid && (nickLen < 3 || nickLen > 16)) {
|
||||
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml(
|
||||
this.generateSolidColor('Nickname needs to be 3–16 chars', '#dd0000')
|
||||
);
|
||||
} else {
|
||||
this.previewHtml = this.sanitizer.bypassSecurityTrustHtml('');
|
||||
}
|
||||
this.showPreview = nickLen > 0;
|
||||
}
|
||||
}
|
||||
|
||||
tryCommandButtonContent = 'Copy';
|
||||
requestCommandButtonContent = 'Copy';
|
||||
|
||||
copy(text: string, button: 'try' | 'request'): void {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (button === 'try') {
|
||||
this.tryCommandButtonContent = 'Copied!';
|
||||
} else if (button === 'request') {
|
||||
this.requestCommandButtonContent = 'Copied!';
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (button === 'try') {
|
||||
this.tryCommandButtonContent = 'Copy';
|
||||
} else if (button === 'request') {
|
||||
this.requestCommandButtonContent = 'Copy';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
generateSolidColor(text: string, color: string): string {
|
||||
return `<span style="color:${color}">${this.escape(text)}</span>`;
|
||||
}
|
||||
|
||||
generateGradient(text: string, colorA: string, colorB: string): string {
|
||||
const len = text.length;
|
||||
if (len === 0) return '';
|
||||
const a = this.hexToRgb(colorA);
|
||||
const b = this.hexToRgb(colorB);
|
||||
if (!a || !b) return this.generateSolidColor(text, colorA);
|
||||
|
||||
const stepR = len > 1 ? (b.r - a.r) / (len - 1) : 0;
|
||||
const stepG = len > 1 ? (b.g - a.g) / (len - 1) : 0;
|
||||
const stepB = len > 1 ? (b.b - a.b) / (len - 1) : 0;
|
||||
|
||||
let res = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
const r = a.r + stepR * i;
|
||||
const g = a.g + stepG * i;
|
||||
const bl = a.b + stepB * i;
|
||||
res += this.generateSolidColor(text[i], this.rgbToHex(r, g, bl));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return m
|
||||
? {r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16)}
|
||||
: null;
|
||||
}
|
||||
|
||||
componentToHex(c: number): string {
|
||||
const x = Math.round(c);
|
||||
const h = x.toString(16);
|
||||
return h.length === 1 ? '0' + h : h;
|
||||
}
|
||||
|
||||
rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
'#' + this.componentToHex(r) + this.componentToHex(g) + this.componentToHex(b)
|
||||
);
|
||||
}
|
||||
|
||||
escape(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private updateContainerHeight() {
|
||||
const headerElement = document.querySelector('app-header');
|
||||
const footerElement = document.querySelector('footer');
|
||||
|
||||
const container = this.elementRef.nativeElement.querySelector('.containerNick');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<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>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<!-- <section class="darkmodeSection">
|
||||
<div class="container containerNick">
|
||||
<div style="padding: 0 5% 0 5%;">
|
||||
<div id="parts" class="previewNickDiv">
|
||||
</div>
|
||||
<div class="previewNickDiv">
|
||||
<input type="button" class="button" value="Add Part" onclick="addPart()"/>
|
||||
<input type="button" class="button" value="Remove Part" onclick="deletePart()"/>
|
||||
</div>
|
||||
<br><br><br><br>
|
||||
<div id="commandTry" class="previewNickDiv">
|
||||
<div id="try" class="command darkBg"></div>
|
||||
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
|
||||
</div>
|
||||
<div id="commandRequest" class="previewNickDiv">
|
||||
<div id="request" class="command darkBg"></div>
|
||||
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
|
||||
</div>
|
||||
<div id="preview" class="preview darkBg previewNickDiv">
|
||||
</div>
|
||||
<div id="template" class='part' style="display: none">
|
||||
<p style="font-family: 'minecraft-text', sans-serif">
|
||||
Text: <input type="text" id="text" class="textPart" size=18 oninput="inputChanged()"/>
|
||||
Gradient: <input type="checkbox" id="grad" class="gradPart" oninput="onGradient(this)"/>
|
||||
<input id="colorA" type="text" class="coloris colorAPart color" value="#ffffff" oninput="inputChanged()"/>
|
||||
<input id="colorB" type="text" class="coloris colorBPart color" value="#ffffff" oninput="inputChanged()"/>
|
||||
Continuation: <input type="checkbox" id="cont" class="contPart" disabled oninput="onContinuation(this)"/>
|
||||
<span id="invalid" class="invalidPart" style="display: none">(min 1 - max 16 chars)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
<p style="font-family: 'minecraft-text', sans-serif">
|
||||
Usage: Add as many parts as you wish, then apply the color and/or gradient, and copy/paste the command
|
||||
into the minecraft chat. The total length of the nickname should be between 3 and 16 characters. Use the
|
||||
continuation checkbox to continue the gradient from the last gradient color.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section> -->
|
||||
</main>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NickgeneratorComponent } from './nickgenerator.component';
|
||||
|
||||
describe('NickgeneratorComponent', () => {
|
||||
let component: NickgeneratorComponent;
|
||||
let fixture: ComponentFixture<NickgeneratorComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NickgeneratorComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NickgeneratorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from "@header/header.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-nickgenerator',
|
||||
imports: [
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './nickgenerator.component.html',
|
||||
styleUrl: './nickgenerator.component.scss'
|
||||
})
|
||||
export class NickgeneratorComponent {
|
||||
|
||||
}
|
||||
|
|
@ -197,32 +197,27 @@
|
|||
<tbody>
|
||||
<tr>
|
||||
<td data-label="Rank" class="rankTitle">[<span class="rank5">Social Media</span>]</td>
|
||||
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
|
||||
</td>
|
||||
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
|
||||
<td data-label="Perks" class="perks">/record<br>/ptime<br>/pweather</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Rank" class="rankTitle">[<span class="rank5">Streamer</span>]</td>
|
||||
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
|
||||
</td>
|
||||
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
|
||||
<td data-label="Perks" class="perks">/record<br>/ptime<br>/pweather</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Rank" class="rankTitle">[<span class="rank5">YouTube</span>]</td>
|
||||
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
|
||||
</td>
|
||||
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
|
||||
<td data-label="Perks" class="perks">/record<br>/ptime<br>/pweather</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Rank" class="rankTitle">[<span class="rank5">Event Leader</span>]</td>
|
||||
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
|
||||
</td>
|
||||
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
|
||||
<td data-label="Perks" class="perks">Build perms on event server</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Rank" class="rankTitle">[<span class="rank5">Event Team</span>]</td>
|
||||
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
|
||||
</td>
|
||||
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
|
||||
<td data-label="Perks" class="perks">Build perms on event server</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RanksComponent } from './ranks.component';
|
||||
|
||||
describe('RanksComponent', () => {
|
||||
let component: RanksComponent;
|
||||
let fixture: ComponentFixture<RanksComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RanksComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RanksComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from "@header/header.component";
|
||||
import {RouterLink} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ranks',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
RouterLink
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './ranks.component.html',
|
||||
styleUrl: './ranks.component.scss'
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
</div>
|
||||
<div class="voteDisclaimer">
|
||||
<h3 style="text-align: center;">Disclaimers & Info</h3>
|
||||
<p style="text-align: center;">You can only store 7 daily crate keys, 2 weekly crate key and 2 quest crate
|
||||
<p style="text-align: center;">You can only store 7 daily crate keys, 1 weekly crate key and 2 quest crate
|
||||
keys.</p><br>
|
||||
<p style="text-align: center;">Voting within 30 minutes of midnight UTC can cause your votes to glitch. Keys
|
||||
lost due to voting too close to midnight UTC will not be reimbursed.
|
||||
|
|
@ -41,12 +41,6 @@
|
|||
</section>
|
||||
</section>
|
||||
<section class="voteSection">
|
||||
@if (voteStats) {
|
||||
<p style="text-align: center">You have voted {{ voteStats.voteStats.total }} times
|
||||
and {{ voteStats.voteStats.weekly }} this week. You
|
||||
are
|
||||
on a {{ voteStats.voteStreak.dailyStreak }} day vote streak!</p>
|
||||
}
|
||||
<div class="container voteContainer">
|
||||
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
||||
<div class="vote">
|
||||
|
|
@ -57,9 +51,7 @@
|
|||
[href]="voteSites[voteSite]">
|
||||
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
||||
[class.available-button-outer]="canVote(voteSite)">
|
||||
<span class="button-inner">
|
||||
{{ getVoteText(voteSite) }}
|
||||
</span>
|
||||
<span class="button-inner">{{ getVoteText(voteSite) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@
|
|||
}
|
||||
|
||||
.available-button-outer {
|
||||
border: 2px solid #4caf50;
|
||||
background-color: #4caf50 !important;
|
||||
}
|
||||
|
||||
.not-available-button-outer {
|
||||
border: 2px solid #ffa433;
|
||||
background-color: var(--white) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,13 @@ import {SiteService, VoteData} from '@api';
|
|||
import {AuthService} from '@services/auth.service';
|
||||
import {interval, Subscription} from 'rxjs';
|
||||
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vote',
|
||||
standalone: true,
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
TimeAgoPipe,
|
||||
MatIconModule,
|
||||
TimeAgoPipe
|
||||
],
|
||||
templateUrl: './vote.component.html',
|
||||
styleUrl: './vote.component.scss'
|
||||
|
|
@ -50,7 +48,7 @@ export class VoteComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refreshSubscription = interval(60000).subscribe(() => {
|
||||
this.refreshSubscription = interval(300000).subscribe(() => {
|
||||
this.loadVoteStats();
|
||||
});
|
||||
}
|
||||
|
|
@ -96,10 +94,9 @@ export class VoteComponent implements OnInit, OnDestroy {
|
|||
return false;
|
||||
}
|
||||
const now: Date = new Date();
|
||||
const voteInfo = this.voteStats.allVoteInfo.find(voteInfo => voteInfo.siteName === voteSite);
|
||||
if (!voteInfo) {
|
||||
return true;
|
||||
}
|
||||
return (now.getTime() - voteInfo.lastVoteTimestamp < 86400000)
|
||||
return (
|
||||
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
|
||||
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {Injectable, signal} from '@angular/core';
|
||||
import {LoginService} from '@api';
|
||||
import {Observable, throwError, timer} from 'rxjs';
|
||||
import {Observable, throwError} from 'rxjs';
|
||||
import {catchError, tap} from 'rxjs/operators';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {JwtHelperService} from '@auth0/angular-jwt';
|
||||
|
|
@ -24,10 +24,6 @@ export class AuthService {
|
|||
) {
|
||||
// Check if user is already logged in on service initialization
|
||||
this.checkAuthStatus();
|
||||
|
||||
timer(1000).subscribe(() => {
|
||||
this.reloadUsername();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,20 +45,11 @@ export class AuthService {
|
|||
}
|
||||
|
||||
private reloadUsername() {
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.log("Reloading username for localhost");
|
||||
this._username.set('developer');
|
||||
}
|
||||
if (!this.isAuthenticated$()) {
|
||||
return;
|
||||
}
|
||||
this.loginService.getUsername().subscribe({
|
||||
next: (username) => {
|
||||
console.log("Username retrieved: " + username.username);
|
||||
this._username.set(username.username);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Error retrieving username:", error);
|
||||
return throwError(() => error);
|
||||
}
|
||||
});
|
||||
|
|
@ -97,7 +84,9 @@ export class AuthService {
|
|||
const claims = this.extractJwtClaims(jwt);
|
||||
this.userClaimsSubject.set(claims);
|
||||
this.isAuthenticatedSubject.set(true);
|
||||
this.reloadUsername();
|
||||
if (this.username() == null) {
|
||||
this.reloadUsername();
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logout();
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
<div class="full-size-container">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.full-size-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import {AfterViewInit, Component, ElementRef, Input, OnDestroy, Renderer2} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-full-size',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './full-size.component.html',
|
||||
styleUrl: './full-size.component.scss'
|
||||
})
|
||||
export class FullSizeComponent implements AfterViewInit, OnDestroy {
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private boundHandleResize: any;
|
||||
|
||||
// Optional extra offset in pixels to subtract from available height
|
||||
@Input() extraOffset: number = 0;
|
||||
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private renderer: Renderer2
|
||||
) {
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.setupResizeObserver();
|
||||
this.updateContainerHeight();
|
||||
|
||||
this.boundHandleResize = this.handleResize.bind(this);
|
||||
window.addEventListener('resize', this.boundHandleResize);
|
||||
|
||||
// Ensure first paint sets correct height
|
||||
setTimeout(() => this.updateContainerHeight(), 0);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundHandleResize) {
|
||||
window.removeEventListener('resize', this.boundHandleResize);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
this.updateContainerHeight();
|
||||
}
|
||||
|
||||
private setupResizeObserver() {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.updateContainerHeight();
|
||||
});
|
||||
|
||||
const headerElement = document.querySelector('app-header');
|
||||
if (headerElement) {
|
||||
this.resizeObserver.observe(headerElement);
|
||||
}
|
||||
|
||||
const footerElement = document.querySelector('footer');
|
||||
if (footerElement) {
|
||||
this.resizeObserver.observe(footerElement);
|
||||
}
|
||||
}
|
||||
|
||||
private updateContainerHeight() {
|
||||
const headerElement = document.querySelector('app-header');
|
||||
const footerElement = document.querySelector('footer');
|
||||
|
||||
const container: HTMLElement | null = this.elementRef.nativeElement.querySelector('.full-size-container');
|
||||
|
||||
if (container) {
|
||||
const headerHeight = headerElement ? headerElement.getBoundingClientRect().height : 0;
|
||||
const footerHeight = footerElement ? footerElement.getBoundingClientRect().height : 0;
|
||||
|
||||
const totalOffset = headerHeight + footerHeight + (this.extraOffset || 0);
|
||||
const calculatedHeight = `calc(100vh - ${totalOffset}px)`;
|
||||
this.renderer.setStyle(container, 'height', calculatedHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<div class="redirect-loading">
|
||||
Redirecting...
|
||||
</div>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.redirect-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-redirect',
|
||||
imports: [],
|
||||
templateUrl: './redirect.component.html',
|
||||
styleUrl: './redirect.component.scss'
|
||||
})
|
||||
export class RedirectComponent implements OnInit {
|
||||
private router: Router = inject(Router)
|
||||
private route: ActivatedRoute = inject(ActivatedRoute)
|
||||
private map: Record<string, string> = {
|
||||
'worlddl': 'https://www.mediafire.com/folder/zblzjurjleq6i/Altitude_servers_1.17.1',
|
||||
'grove-dl': 'https://www.mediafire.com/folder/bd6ej1i7if0y6',
|
||||
};
|
||||
|
||||
private getRedirectTarget(key: string): string | null {
|
||||
const value = this.map[key];
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const type = this.route.snapshot.paramMap.get('type');
|
||||
|
||||
if (type) {
|
||||
const target = this.getRedirectTarget(type);
|
||||
if (target) {
|
||||
window.location.href = target;
|
||||
} else {
|
||||
this.router.navigate(['/']).then();
|
||||
}
|
||||
} else {
|
||||
this.router.navigate(['/']).then();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,19 +155,6 @@ time, mark, audio, video {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.centered {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* flex end */
|
||||
|
||||
/* main css */
|
||||
|
|
@ -479,46 +466,3 @@ main .container {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.margin-auto {
|
||||
margin: auto
|
||||
}
|
||||
|
||||
|
||||
/* Angular Material global theme bridge to app CSS variables */
|
||||
.theme-light, .theme-dark {
|
||||
/* Ensure Material table text and headers follow theme font color */
|
||||
.mat-mdc-table,
|
||||
.mat-mdc-header-cell,
|
||||
.mat-mdc-cell,
|
||||
.mat-mdc-header-row,
|
||||
.mat-mdc-row {
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
/* Sort arrow color */
|
||||
.mat-sort-header-arrow {
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme overrides for Angular Material tables to ensure readable backgrounds */
|
||||
.theme-dark {
|
||||
/* Set a dark surface for the table container */
|
||||
.mat-mdc-table {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Ensure rows and cells don't bring back white backgrounds from the prebuilt theme */
|
||||
.mat-mdc-header-row,
|
||||
.mat-mdc-row,
|
||||
.mat-mdc-header-cell,
|
||||
.mat-mdc-cell {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Optional: keep sort header arrow readable (already set in the bridge, but safe here) */
|
||||
.mat-sort-header-arrow {
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,6 @@ tasks.register< GenerateTask>("generateJavaApi") {
|
|||
typeMappings.put("OffsetDateTime", "Instant")
|
||||
importMappings.put("java.time.OffsetDateTime", "java.time.Instant")
|
||||
modelNameSuffix.set("Dto")
|
||||
|
||||
// Make generator use Java 8 time types and map date-time -> Instant
|
||||
additionalProperties.set(mapOf("dateLibrary" to "java8"))
|
||||
typeMappings.set(mapOf("date-time" to "Instant"))
|
||||
importMappings.set(mapOf("Instant" to "java.time.Instant"))
|
||||
generateModelTests.set(false)
|
||||
generateModelDocumentation.set(false)
|
||||
generateApiTests.set(false)
|
||||
|
|
|
|||
|
|
@ -59,8 +59,6 @@ paths:
|
|||
$ref: './schemas/bans/bans.yml#/removePunishment'
|
||||
/api/appeal/update-mail:
|
||||
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
||||
/api/appeal/discord/getBannedUser:
|
||||
$ref: './schemas/forms/appeal/discordAppeal.yml#/getBannedUser'
|
||||
/api/appeal/minecraft-appeal:
|
||||
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
|
||||
/api/appeal/discord-appeal:
|
||||
|
|
@ -95,5 +93,3 @@ paths:
|
|||
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
||||
/api/site/vote:
|
||||
$ref: './schemas/site/vote.yml#/VoteStats'
|
||||
/api/site/get-staff-playtime/{from}/{to}:
|
||||
$ref: './schemas/site/staff_pt.yml#/GetStaffPlaytime'
|
||||
|
|
|
|||
|
|
@ -119,21 +119,23 @@ components:
|
|||
type: object
|
||||
description: Schema for Discord ban/punishment appeals
|
||||
required:
|
||||
- discordId
|
||||
- appeal
|
||||
- email
|
||||
- punishmentId
|
||||
- appeal
|
||||
properties:
|
||||
discordId:
|
||||
type: string
|
||||
pattern: "^[0-9]{17,18}$"
|
||||
minLength: 17
|
||||
maxLength: 18
|
||||
appeal:
|
||||
type: string
|
||||
description: Appeal text explaining why the punishment should be reconsidered
|
||||
userId:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Discord user ID of the appealing user
|
||||
email:
|
||||
type: string
|
||||
description: Contact email address of the appealing user
|
||||
punishmentId:
|
||||
type: integer
|
||||
description: Unique identifier of the punishment being appealed
|
||||
appeal:
|
||||
type: string
|
||||
description: Appeal text explaining why the punishment should be reconsidered
|
||||
UpdateMail:
|
||||
type: object
|
||||
required:
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
getBannedUser:
|
||||
post:
|
||||
tags:
|
||||
- appeals
|
||||
summary: get banned user
|
||||
description: Get a banned user by their discord id
|
||||
operationId: getBannedUser
|
||||
parameters:
|
||||
- name: discordId
|
||||
in: query
|
||||
required: true
|
||||
description: The discord id of the user
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[0-9]{17,18}$"
|
||||
minLength: 17
|
||||
maxLength: 18
|
||||
responses:
|
||||
'200':
|
||||
description: Banned user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BannedUserResponse'
|
||||
default:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
|
||||
components:
|
||||
schemas:
|
||||
BannedUserResponse:
|
||||
type: object
|
||||
required:
|
||||
- isBanned
|
||||
properties:
|
||||
isBanned:
|
||||
type: boolean
|
||||
description: Whether the user is banned
|
||||
bannedUser:
|
||||
$ref: '#/components/schemas/BannedUser'
|
||||
BannedUser:
|
||||
type: object
|
||||
required:
|
||||
- userId
|
||||
- reason
|
||||
- name
|
||||
- avatarUrl
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
pattern: "^[0-9]{17,18}$"
|
||||
minLength: 17
|
||||
maxLength: 18
|
||||
reason:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
avatarUrl:
|
||||
type: string
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
GetStaffPlaytime:
|
||||
get:
|
||||
tags:
|
||||
- site
|
||||
summary: Get staff playtime for a specified duration
|
||||
description: Get staff playtime for all staff members for a specified duration
|
||||
operationId: getStaffPlaytime
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/From'
|
||||
- $ref: '#/components/parameters/To'
|
||||
responses:
|
||||
'200':
|
||||
description: Staff playtime retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StaffPlaytimeList'
|
||||
components:
|
||||
parameters:
|
||||
From:
|
||||
name: from
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: 2025-01-01T00:00:00.000Z
|
||||
To:
|
||||
name: to
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: 2025-01-07T23:59:59.999Z
|
||||
schemas:
|
||||
StaffPlaytimeList:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StaffPlaytime'
|
||||
StaffPlaytime:
|
||||
type: object
|
||||
required:
|
||||
- staff_member
|
||||
- playtime
|
||||
- last_played
|
||||
- role
|
||||
properties:
|
||||
staff_member:
|
||||
type: string
|
||||
description: The name of the staff member
|
||||
playtime:
|
||||
type: integer
|
||||
description: Total playtime for the specified duration in minutes
|
||||
last_played:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Last played timestamp
|
||||
role:
|
||||
type: string
|
||||
description: The role of the staff member
|
||||
|
|
@ -12,8 +12,6 @@ getTeam:
|
|||
description: The group name of the team
|
||||
schema:
|
||||
type: string
|
||||
example: owner
|
||||
maxLength: 32
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user