Author SHA1 Message Date
stijn 9311a1ccd6 Refactor appeal message sending to use AppealSender and improve assignment handling with thread creation and button interactions. 2025-11-22 00:56:18 +01:00
stijn 65820cf0a4 Implement appeal assignment system with AppealListMapper and associated logic. 2025-11-22 00:44:18 +01:00
stijn 9d23838eb0 Remove email information from Discord appeal message. 2025-11-22 00:17:03 +01:00
stijn a50b4ed658 Handle WARN case in EditHistoryMapper by updating "litebans_warnings" instead of throwing an exception. 2025-11-22 00:13:11 +01:00
stijn 0f11167953 Refactor Discord message sending to use MessageForEmbed object and add support for creating threads in targeted channels. 2025-11-21 23:39:35 +01:00
stijn ec3435dccc Add grove-dl route and corresponding redirect to MediaFire folder in RedirectComponent 2025-11-15 20:50:18 +01:00
stijn 07048567a1 Set body background color in RedirectComponent styles to use secondary theme color 2025-11-13 21:24:20 +01:00
stijn 5d9bf922a4 Update RedirectComponent to use window.location.href for external navigation instead of router.navigateByUrl. 2025-11-13 21:18:10 +01:00
stijn 19bc6fc8e3 Create RedirectComponent to handle dynamic redirections and update routes for improved maintainability. 2025-11-13 21:10:38 +01:00
stijn 42786dce74 Add route to redirect /worlddl path to external MediaFire link 2025-11-13 20:59:28 +01:00
stijn e415ecc415 Implement dynamic container height adjustment in NickGeneratorComponent based on header and footer dimensions. Refactor HTML structure for improved dark mode styling and accessibility. Optimize component lifecycle by adding AfterViewInit and OnDestroy handling with a ResizeObserver. 2025-11-08 22:09:35 +01:00
stijn 72b9109ece Configure additional assets output path in angular.json. 2025-11-08 21:40:53 +01:00
stijn 19f37e7dd7 Correct misassigned section classes in CommunityComponent HTML for consistent styling. 2025-11-08 21:23:06 +01:00
stijn 7ce9ee33c4 Add "Developers" section to CommunityComponent with dynamic team member display and adjust structure of existing sections. 2025-11-08 21:21:53 +01:00
stijn a1fbdf3581 Add "Developers" section to CommunityComponent with dynamic team member display and adjust structure of existing sections. 2025-11-08 21:20:25 +01:00
stijn a05a751628 Remove redundant unit tests for CommunityComponent and RanksComponent, enhance community.component with dynamic team member display and toggle functionality, update routing for community links, and set stricter field constraints in team schema. 2025-11-08 18:45:03 +01:00
stijn 042a6450c2 Fix spacing 2025-11-08 18:32:49 +01:00
stijn 724b773be5 Refactor createPrivilegedUser to accept PrivilegedUser object and adjust LoginController for consistency. 2025-11-08 18:21:21 +01:00
stijn 7315ea8455 Navigate to root path after successful login via login/:code route in AuthGuard. 2025-11-08 18:06:13 +01:00
stijn b7c553acc1 Add login/:code route with AuthGuard and required authorizations 2025-11-08 18:02:34 +01:00
stijn 5ab81ee66e Switch to @SelectKey for id generation in createPrivilegedUser and remove unused @Nullable import. 2025-11-08 17:58:03 +01:00
stijn e83d109012 Add route parameter handling for appeal paths and enhance AuthGuard to support login via code query parameter 2025-11-08 17:42:29 +01:00
stijn e8f952e7e2 Ensure all staff members are included in playtime mapping by adding default playtime data for missing UUIDs. 2025-11-02 23:15:12 +01:00
stijn ff85b42190 Highlight playtime under threshold in red in Staff Playtime view. 2025-11-02 23:12:04 +01:00
stijn 0a96593992 Simplify "Playtime" column header in Staff Playtime view. 2025-11-02 23:09:27 +01:00
stijn 795bd22ee9 Adjust loadStaffData method to handle timezones accurately in Staff Playtime component. 2025-11-02 23:06:12 +01:00
stijn 83893f947d Center Staff Playtime container for improved layout alignment. 2025-11-02 23:04:15 +01:00
stijn 9a039e1e10 Limit Staff Playtime component width to improve layout consistency. 2025-11-02 23:03:25 +01:00
stijn 2bdebb71b7 Add rate limiting to getStaffPlaytime and getVoteStats endpoints 2025-11-02 23:03:00 +01:00
stijn 39b7a398a5 Refactor minutesToHm method in Staff Playtime component for improved readability by adding explicit braces to conditional blocks. 2025-11-02 22:58:47 +01:00
stijn 06a1cd64e3 Remove "Last Played" column from Staff Playtime view and enhance time formatting in minutesToHm method to include days. 2025-11-02 22:58:21 +01:00
stijn 6292d0cacf Update weekStart to dynamically use the current date instead of a fixed one in Staff Playtime component 2025-11-02 22:54:45 +01:00
stijn 8b4f1c2785 Wrap weekLabel in a <span> for improved styling and DOM structure consistency in Staff Playtime view. 2025-11-02 22:52:50 +01:00
stijn 710771f5f7 Integrate HeaderComponent into Staff Playtime view, update layout with full-height styling, and enhance UI consistency. 2025-11-02 22:48:27 +01:00
stijn edaebe9e4a Fix getTeamMembers query to scope permissions to the global server 2025-11-02 22:46:26 +01:00
stijn e43cbbf9e4 WIP staff pt 2025-11-02 22:36:28 +01:00
stijn 8b0d2f9203 Add staff playtime feature, including backend services, API endpoint, and frontend integration.
WIP
2025-11-02 22:25:10 +01:00
stijn 2be79c180a Refactor Nickname Generator component with Angular Material, update logic for fields and commands, and improve styling. 2025-10-29 21:39:39 +01:00
stijn 423d5e4a4c Fix getTeamMembers query to scope permissions to the global server 2025-10-26 01:45:19 +02:00
stijn a0db55dede Fix getTeamMembers query to scope permissions to the global server 2025-10-26 01:43:00 +02:00
stijn e0a09d303c Update vote eligibility logic, add MatIconModule, and refine button styles for improved clarity. 2025-10-24 22:43:38 +02:00
stijn 29967d65b8 Improve vote eligibility check by adding find to handle cases where voteSite is not found. 2025-10-24 22:28:36 +02:00
stijn 8b265514a6 Refactor RateLimitAspect to use authenticated UUID instead of client IP for rate limiting. Enhance AuthenticatedUuid with optional UUID retrieval method. 2025-10-24 22:27:04 +02:00
stijn e766fd1125 Fix vote eligibility check logic by correcting timestamp comparison direction. 2025-10-24 22:22:32 +02:00
stijn 86a85049b3 center p 2025-10-24 22:20:50 +02:00
stijn cf73303218 center div 2025-10-24 22:18:19 +02:00
stijn d075464ded center div 2025-10-24 22:16:58 +02:00
stijn 7be3b6f9d3 Wrap vote statistics message in a centered <div> for improved alignment and readability. 2025-10-24 22:15:30 +02:00
stijn 24e28015d3 Adjust vote refresh interval to 1 minute and update vote disclaimer text. Add vote statistics display. 2025-10-24 22:13:44 +02:00
stijn 754479eb98 Refactor: move actor and actorUuid UUID retrieval to method to thread with auth 2025-10-24 22:01:12 +02:00
stijn 5974ec1dba Revert "Refactor getAuthenticatedUserUuid - extract getAuthentication method for improved null handling and clarity"
This reverts commit 4b466f314e.
2025-10-24 21:59:57 +02:00
stijn c5ed657d3e Revert "Enhance AuthenticatedUuid to improve UUID extraction by adding support for decoding tokens from the Authorization header. Add logging, refactor for better null handling, and introduce @RequiredArgsConstructor."
This reverts commit 02adbb2522.
2025-10-24 21:59:57 +02:00
stijn 02adbb2522 Enhance AuthenticatedUuid to improve UUID extraction by adding support for decoding tokens from the Authorization header. Add logging, refactor for better null handling, and introduce @RequiredArgsConstructor. 2025-10-24 21:58:07 +02:00
stijn 4b466f314e Refactor getAuthenticatedUserUuid - extract getAuthentication method for improved null handling and clarity 2025-10-24 21:46:07 +02:00
stijn 6531526278 Add logging for invalid authentication principal in AuthenticatedUuid and annotate with @Slf4j 2025-10-24 21:43:50 +02:00
stijn bc0739f707 Fix table name casing in VotingPluginUsersMapper query 2025-10-24 21:34:54 +02:00
59 changed files with 1692 additions and 320 deletions
@@ -51,6 +51,8 @@ public class SecurityConfig {
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").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,5 +1,7 @@
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;
@@ -8,8 +10,10 @@ 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}}")
@@ -25,6 +29,9 @@ 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");
}
@@ -39,4 +46,28 @@ 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();
}
}
}
@@ -242,6 +242,8 @@ 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);
@@ -253,7 +255,6 @@ 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);
@@ -275,6 +276,8 @@ 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);
@@ -286,7 +289,6 @@ 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);
@@ -311,6 +313,8 @@ 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);
@@ -320,7 +324,6 @@ 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,15 +210,16 @@ public class LoginController implements LoginApi {
try {
log.debug("Loading user by uuid {}", uuid.toString());
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
Optional<PrivilegedUser> privilegedUser = mapper
Optional<PrivilegedUser> optionalPrivilegedUser = mapper
.getUserByUuid(uuid);
if (privilegedUser.isEmpty()) {
int privilegedUserId = mapper.createPrivilegedUser(uuid);
if (optionalPrivilegedUser.isEmpty()) {
PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of());
mapper.createPrivilegedUser(privilegedUser);
privilegedUserCompletableFuture.complete(
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
Optional.of(privilegedUser));
} else {
privilegedUserCompletableFuture.complete(privilegedUser);
privilegedUserCompletableFuture.complete(optionalPrivilegedUser);
}
} catch (Exception e) {
log.error("Failed to load user by uuid", e);
@@ -2,15 +2,21 @@ 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;
@@ -23,8 +29,22 @@ 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);
@@ -0,0 +1,62 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.luckperms.Player;
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<Player> staffMembers, long from, long to) {
Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to);
for (Player 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();
dto.setStaffMember(staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey()))
.map(Player::username)
.findFirst()
.orElse(entry.getKey().toString())
);
dto.setLastPlayed(OffsetDateTime.ofInstant(Instant.ofEpochMilli(lastPlayedMillis), ZoneOffset.UTC));
dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime()));
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,6 +1,8 @@
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;
@@ -8,7 +10,10 @@ 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.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;
@@ -18,6 +23,8 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@@ -66,7 +73,6 @@ public class AppealDiscord {
"User",
"Username: " + safe(appeal.username()) + "\n" +
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
"Email: " + safe(appeal.email()) + "\n" +
"Submitted: " + createdAt,
false
));
@@ -88,6 +94,22 @@ 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
));
assignAppealTo(appeal.id(), assignedTo);
} else {
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: None (failed to assign)",
true
));
}
String description = safe(appeal.reason());
@@ -97,15 +119,19 @@ public class AppealDiscord {
// colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Appeal Submitted",
description,
fields,
null,
timestamp,
null
);
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
"New Appeal Submitted", description, fields, null, timestamp, null);
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
}
private void assignAppealTo(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 CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
@@ -132,4 +158,49 @@ 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())
.sorted()
.findFirst();
AppealList nextAppealList = optionalNextAppealList.orElse(appealList.stream().sorted().findFirst().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,6 +5,7 @@ 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;
@@ -79,16 +80,15 @@ public class StaffApplicationDiscord {
.toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
MessageForEmbed messageForEmbed = new MessageForEmbed(
"New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp),
fields,
null,
timestamp,
null
);
null);
DiscordSender.getInstance().sendEmbedWithThreadToChannels(channelIds, messageForEmbed, "Staff Application");
}
private String safe(String s) {
@@ -1,5 +1,6 @@
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;
@@ -16,6 +17,8 @@ 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
@@ -24,6 +27,7 @@ import java.time.Duration;
public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
private final AuthenticatedUuid authenticatedUuid;
@Around("""
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
@@ -37,7 +41,6 @@ public class RateLimitAspect {
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
@@ -54,7 +57,12 @@ public class RateLimitAspect {
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
String customKey = rateLimit.key();
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
if (optionalUUID.isEmpty()) {
return joinPoint.proceed();
}
UUID uuid = optionalUUID.get();
String key = uuid + "-" + (customKey.isEmpty() ? method.getName() : customKey);
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
@@ -67,7 +75,7 @@ public class RateLimitAspect {
return joinPoint.proceed();
} else {
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
log.warn("Rate limit exceeded for uuid: {}, endpoint: {}", uuid, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
@@ -0,0 +1,68 @@
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.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.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffPtService {
private final static String STAFF_GROUPS = "'group.admin', 'group.developer', 'group.headmod', 'group.manager', 'group.moderator', 'group.owner', 'group.trainee'";
private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper;
public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) {
CompletableFuture<List<Player>> staffMembersFuture = new CompletableFuture<>();
CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading staff members");
try {
List<Player> 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<Player> staffMembers = staffMembersFuture.join().stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(Player::uuid, player -> player, (player1, player2) -> player1),
m -> new ArrayList<>(m.values())));
Connection.getConnection(Databases.PROXY_PLAYTIME)
.runQuery(sqlSession -> {
String staffUUIDs = staffMembers.stream()
.map(Player::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();
return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli()));
}
}
@@ -8,6 +8,7 @@ public enum Databases {
LUCK_PERMS("luckperms"),
LITE_BANS("litebans"),
DISCORD("discordLink"),
PROXY_PLAYTIME("proxyplaytime"),
VOTING_PLUGIN("votingplugin");
private final String internalName;
@@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.discord;
public record AppealList(Long userId, boolean next) {
}
@@ -0,0 +1,25 @@
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 -> throw new IllegalArgumentException("WARN has no until");
case WARN -> updateUntil("litebans_warnings", id, until);
};
}
@@ -15,8 +15,23 @@ 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}
WHERE permission = #{groupPermission} AND server = 'global'
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)
})
@Select("""
SELECT players.username, players.uuid
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<Player> getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions);
}
@@ -0,0 +1,33 @@
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);
}
@@ -0,0 +1,6 @@
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,7 +1,6 @@
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;
@@ -104,8 +103,8 @@ public interface PrivilegedUserMapper {
@Insert("""
INSERT INTO privileged_users (uuid)
VALUES (#{uuid})
VALUES (#{user.uuid})
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int createPrivilegedUser(UUID uuid);
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class)
void createPrivilegedUser(@Param("user") PrivilegedUser user);
}
@@ -34,4 +34,10 @@ public interface AppealMapper {
WHERE id = #{id}
""")
void markAppealAsSent(UUID id);
@Update("""
UPDATE appeals SET assigned_to = #{assignedTo}
WHERE id = #{id}
""")
void assignAppeal(UUID id, Long assignedTo);
}
@@ -37,6 +37,7 @@ public class Connection {
InitializeWebDb.init();
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeProxyPlaytime.init();
InitializeDiscord.init();
InitializeVotingPlugin.init();
}
@@ -1,6 +1,7 @@
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;
@@ -12,6 +13,7 @@ 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");
}
@@ -0,0 +1,18 @@
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");
}
}
@@ -0,0 +1,61 @@
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;
import java.util.concurrent.CompletableFuture;
@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();
});
}
}
@@ -0,0 +1,49 @@
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,19 +1,21 @@
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.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import java.awt.*;
import java.time.Instant;
import java.util.ArrayList;
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 {
@@ -70,12 +72,38 @@ public class DiscordSender {
});
}
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
Integer colorRgb, Instant timestamp, String footer) {
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) {
ensureStarted();
if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord embed.");
return;
return CompletableFuture.completedFuture(Optional.empty());
}
try {
if (!botInstance.isReady()) {
@@ -83,43 +111,31 @@ 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());
}
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);
MessageEmbed embed = messageForEmbed.toEmbed();
if (fields != null) {
for (EmbedField f : fields) {
if (f == null) continue;
String name = f.getName() == null ? "" : f.getName();
String value = f.getValue() == null ? "" : f.getValue();
// JDA field value max is 1024; truncate to be safe
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
}
}
MessageEmbed embed = eb.build();
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, channelId);
if (channel == null) {
log.warn("TextChannel with id {} not found", id);
return;
log.warn("TextChannel with id {} not found when sending embed message", channelId);
return CompletableFuture.completedFuture(Optional.empty());
}
CompletableFuture<Optional<Message>> completableFuture = new CompletableFuture<>();
channel.sendMessageEmbeds(embed).queue(
success -> log.debug("Sent embed to channel {}", id),
error -> log.error("Failed sending embed to channel {}", id, error)
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;
}
@Data
+5
View File
@@ -30,6 +30,11 @@
"glob": "**/*",
"input": "public",
"output": "public"
},
{
"glob": "**/*",
"input": "public",
"output": "assets"
}
],
"styles": [
+43 -4
View File
@@ -2,6 +2,28 @@ 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)
@@ -14,6 +36,14 @@ 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)
@@ -110,18 +140,27 @@ 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: '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']
}
data: {requiredAuthorizations: ['SCOPE_user']}
},
{
path: 'forms/sent',
@@ -154,6 +193,6 @@ export const routes: Routes = [
},
{
path: 'nickgenerator',
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
loadComponent: () => import('@pages/reference/nickgenerator/nick-generator.component').then(m => m.NickGeneratorComponent)
},
];
+19 -1
View File
@@ -1,9 +1,10 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {map, Observable} from 'rxjs';
import {from, isObservable, map, Observable, of, switchMap} 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'
@@ -21,6 +22,23 @@ 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,34 +9,90 @@
<main>
<section class="darkmodeSection">
<div class="customContainer">
<h2>Current Nitro Boosters</h2>
<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>
</section>
<section id="social" class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<section class="darkmodeSectionThree">
<div class="container teamContainer">
<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 id="crateTeam" class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Crate Team</h2>
<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>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<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">
<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="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<section class="darkmodeSectionThree">
<div class="container teamContainer">
<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;">
@@ -46,16 +102,33 @@
</div>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<section class="darkmodeSection">
<div class="container teamContainer">
<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;" id="reqButton">Show Requirements...</a></p>
<p style="text-align: center;"><a style="cursor: pointer;" (click)="toggleSection('yt-stream-req')"
id="reqButton">{{ getTextForSection('yt-stream-req') }}</a></p>
</div>
</div>
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
<div [hidden]="!isToggled('yt-stream-req')" [class.requirementSection]="isToggled('yt-stream-req')">
<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,12 +1,32 @@
.customContainer {
width: 80%;
max-width: 1020px;
margin: auto;
padding: 80px 0;
.sectionTitle {
flex: 0 0 100%;
text-align: center;
padding-bottom: 20px;
font-size: 2em;
}
.member {
width: 33%;
min-width: 250px;
padding-bottom: 50px;
text-align: center;
}
.hide {
display: none !important;
.member img {
padding-bottom: 15px;
}
.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;
}
@@ -1,23 +0,0 @@
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,14 +1,66 @@
import {Component} from '@angular/core';
import {Component, inject} 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
HeaderComponent,
AsyncPipe,
NgOptimizedImage
],
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...';
}
}
}
@@ -83,4 +83,4 @@
</section>
}
</main>
</ng-container>
</ng-container>
@@ -0,0 +1,45 @@
<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>chevron_left</mat-icon>
</button>
<div class="week-title"><span>{{ weekLabel() }}</span></div>
<button mat-icon-button (click)="nextWeek()" [disabled]="!canGoNextWeek()"
matTooltip="Next week" aria-label="Next week">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<table mat-table [dataSource]="staffPt()" class="mat-elevation-z2 full-width">
<ng-container matColumnDef="staff_member">
<th mat-header-cell *matHeaderCellDef> Staff Member</th>
<td mat-cell *matCellDef="let row"> {{ row.staff_member }}</td>
</ng-container>
<ng-container matColumnDef="playtime">
<th mat-header-cell *matHeaderCellDef> Playtime</th>
<td mat-cell *matCellDef="let row"
[style.color]="row.playtime < 420 ? 'red' : ''"> {{ minutesToHm(row.playtime) }}
</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>
@@ -0,0 +1,27 @@
.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;
}
@@ -0,0 +1,119 @@
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 {SiteService, StaffPlaytime} from '@api';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-staff-pt',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule, MatTooltipModule, HeaderComponent],
templateUrl: './staff-pt.component.html',
styleUrl: './staff-pt.component.scss'
})
export class StaffPtComponent implements OnInit {
siteService = inject(SiteService);
staffPt = signal<StaffPlaytime[]>([]);
weekStart = signal<Date>(this.getStartOfWeek(new Date()));
weekEnd = computed(() => this.getEndOfWeek(this.weekStart()));
todayStart = signal<Date>(this.startOfDay(new Date()));
displayedColumns = ['staff_member', 'playtime'];
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;
}
}
@@ -144,6 +144,7 @@
<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>
@@ -0,0 +1,109 @@
<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>
@@ -0,0 +1,71 @@
.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;
}
@@ -0,0 +1,282 @@
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 316 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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);
}
}
}
@@ -1,53 +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>
</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>
@@ -1,23 +0,0 @@
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();
});
});
@@ -1,14 +0,0 @@
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,27 +197,32 @@
<tbody>
<tr>
<td data-label="Rank" class="rankTitle">[<span class="rank5">Social Media</span>]</td>
<td data-label="Requirements">See requirements on the <a href="/community.php">Community</a> page</td>
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">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 href="/community.php">Community</a> page</td>
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">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 href="/community.php">Community</a> page</td>
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">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 href="/community.php">Community</a> page</td>
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">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 href="/community.php">Community</a> page</td>
<td data-label="Requirements">See requirements on the <a [routerLink]="['/community']">Community</a> page
</td>
<td data-label="Perks" class="perks">Build perms on event server</td>
</tr>
</tbody>
@@ -1,23 +0,0 @@
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,10 +1,12 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "@header/header.component";
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-ranks',
imports: [
HeaderComponent
HeaderComponent,
RouterLink
],
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, 1 weekly crate key and 2 quest crate
<p style="text-align: center;">You can only store 7 daily crate keys, 2 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,6 +41,12 @@
</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">
@@ -51,7 +57,9 @@
[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 {
background-color: #4caf50 !important;
border: 2px solid #4caf50;
}
.not-available-button-outer {
background-color: var(--white) !important;
border: 2px solid #ffa433;
}
@@ -6,13 +6,15 @@ 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
TimeAgoPipe,
MatIconModule,
],
templateUrl: './vote.component.html',
styleUrl: './vote.component.scss'
@@ -48,7 +50,7 @@ export class VoteComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.refreshSubscription = interval(300000).subscribe(() => {
this.refreshSubscription = interval(60000).subscribe(() => {
this.loadVoteStats();
});
}
@@ -94,9 +96,10 @@ export class VoteComponent implements OnInit, OnDestroy {
return false;
}
const now: Date = new Date();
return (
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
)
const voteInfo = this.voteStats.allVoteInfo.find(voteInfo => voteInfo.siteName === voteSite);
if (!voteInfo) {
return true;
}
return (now.getTime() - voteInfo.lastVoteTimestamp < 86400000)
}
}
@@ -0,0 +1,3 @@
<div class="redirect-loading">
Redirecting...
</div>
@@ -0,0 +1,12 @@
.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);
}
@@ -0,0 +1,37 @@
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();
}
}
}
+13
View File
@@ -155,6 +155,19 @@ 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 */
+5
View File
@@ -63,6 +63,11 @@ 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)
+2
View File
@@ -93,3 +93,5 @@ 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'
@@ -0,0 +1,53 @@
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
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
@@ -12,6 +12,8 @@ getTeam:
description: The group name of the team
schema:
type: string
example: owner
maxLength: 32
responses:
'200':
description: successful operation