Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab81ee66e | ||
|
|
e83d109012 | ||
|
|
e8f952e7e2 | ||
|
|
ff85b42190 | ||
|
|
0a96593992 | ||
|
|
795bd22ee9 | ||
|
|
83893f947d | ||
|
|
9a039e1e10 | ||
|
|
2bdebb71b7 | ||
|
|
39b7a398a5 | ||
|
|
06a1cd64e3 | ||
|
|
6292d0cacf | ||
|
|
8b4f1c2785 | ||
|
|
710771f5f7 | ||
|
|
edaebe9e4a | ||
|
|
e43cbbf9e4 | ||
|
|
8b0d2f9203 | ||
|
|
2be79c180a | ||
|
|
423d5e4a4c | ||
|
|
a0db55dede | ||
|
|
e0a09d303c | ||
|
|
29967d65b8 | ||
|
|
8b265514a6 | ||
|
|
e766fd1125 | ||
|
|
86a85049b3 | ||
|
|
cf73303218 | ||
|
|
d075464ded | ||
|
|
7be3b6f9d3 | ||
|
|
24e28015d3 | ||
|
|
754479eb98 | ||
|
|
5974ec1dba | ||
|
|
c5ed657d3e | ||
|
|
02adbb2522 | ||
|
|
4b466f314e | ||
|
|
6531526278 | ||
|
|
bc0739f707 |
@@ -51,6 +51,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/form/**").authenticated()
|
.requestMatchers("/api/form/**").authenticated()
|
||||||
.requestMatchers("/api/login/getUsername").authenticated()
|
.requestMatchers("/api/login/getUsername").authenticated()
|
||||||
.requestMatchers("/api/mail/**").authenticated()
|
.requestMatchers("/api/mail/**").authenticated()
|
||||||
|
.requestMatchers("/api/site/vote").authenticated()
|
||||||
|
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
|||||||
+31
@@ -1,5 +1,7 @@
|
|||||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||||
|
|
||||||
|
import com.nimbusds.jwt.JWT;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -8,8 +10,10 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class AuthenticatedUuid {
|
public class AuthenticatedUuid {
|
||||||
@Value("${UNSECURED:#{false}}")
|
@Value("${UNSECURED:#{false}}")
|
||||||
@@ -25,6 +29,9 @@ public class AuthenticatedUuid {
|
|||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||||
|
log.error("Authentication principal is null {} or not a JWT {}",
|
||||||
|
authentication == null, authentication == null ?
|
||||||
|
"null" : authentication.getPrincipal() instanceof JWT);
|
||||||
if (unsecured) {
|
if (unsecured) {
|
||||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
||||||
}
|
}
|
||||||
@@ -39,4 +46,28 @@ public class AuthenticatedUuid {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the authenticated user's UUID from the JWT token.
|
||||||
|
*
|
||||||
|
* @return The UUID of the authenticated user
|
||||||
|
*/
|
||||||
|
public Optional<UUID> tryGetAuthenticatedUserUuid() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||||
|
if (unsecured) {
|
||||||
|
return Optional.of(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String stringUuid = jwt.getSubject();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Optional.of(UUID.fromString(stringUuid));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -242,6 +242,8 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||||
|
|
||||||
|
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
@@ -253,7 +255,6 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
}
|
}
|
||||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
||||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||||
@@ -275,6 +276,8 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||||
|
|
||||||
|
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
@@ -286,7 +289,6 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
}
|
}
|
||||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
||||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||||
@@ -311,6 +313,8 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||||
|
|
||||||
|
final UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
@@ -320,7 +324,6 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
result.complete(false);
|
result.complete(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
||||||
int changed = editMapper.remove(historyTypeEnum, id);
|
int changed = editMapper.remove(historyTypeEnum, id);
|
||||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ package com.alttd.altitudeweb.controllers.site;
|
|||||||
|
|
||||||
import com.alttd.altitudeweb.api.SiteApi;
|
import com.alttd.altitudeweb.api.SiteApi;
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
|
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
|
||||||
|
import com.alttd.altitudeweb.model.StaffPlaytimeListDto;
|
||||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
|
import com.alttd.altitudeweb.services.site.StaffPtService;
|
||||||
import com.alttd.altitudeweb.services.site.VoteService;
|
import com.alttd.altitudeweb.services.site.VoteService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -23,8 +29,22 @@ public class SiteController implements SiteApi {
|
|||||||
|
|
||||||
private final VoteService voteService;
|
private final VoteService voteService;
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
private final StaffPtService staffPtService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@RateLimit(limit = 1, timeValue = 1, timeUnit = TimeUnit.SECONDS, key = "getStaffPlaytime")
|
||||||
|
public ResponseEntity<StaffPlaytimeListDto> getStaffPlaytime(OffsetDateTime from, OffsetDateTime to) {
|
||||||
|
Optional<List<StaffPlaytimeDto>> staffPlaytimeDto = staffPtService.getStaffPlaytime(from.toInstant(), to.toInstant());
|
||||||
|
if (staffPlaytimeDto.isEmpty()) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
StaffPlaytimeListDto staffPlaytimeListDto = new StaffPlaytimeListDto();
|
||||||
|
staffPlaytimeListDto.addAll(staffPlaytimeDto.get());
|
||||||
|
return ResponseEntity.ok(staffPlaytimeListDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "getVoteStats")
|
||||||
public ResponseEntity<VoteDataDto> getVoteStats() {
|
public ResponseEntity<VoteDataDto> getVoteStats() {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
package com.alttd.altitudeweb.services.limits;
|
package com.alttd.altitudeweb.services.limits;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -16,6 +17,8 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
|||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Aspect
|
@Aspect
|
||||||
@Component
|
@Component
|
||||||
@@ -24,6 +27,7 @@ import java.time.Duration;
|
|||||||
public class RateLimitAspect {
|
public class RateLimitAspect {
|
||||||
|
|
||||||
private final InMemoryRateLimiterService rateLimiterService;
|
private final InMemoryRateLimiterService rateLimiterService;
|
||||||
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
@Around("""
|
@Around("""
|
||||||
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
||||||
@@ -37,7 +41,6 @@ public class RateLimitAspect {
|
|||||||
HttpServletRequest request = requestAttributes.getRequest();
|
HttpServletRequest request = requestAttributes.getRequest();
|
||||||
HttpServletResponse response = requestAttributes.getResponse();
|
HttpServletResponse response = requestAttributes.getResponse();
|
||||||
|
|
||||||
String clientIp = request.getRemoteAddr();
|
|
||||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||||
Method method = signature.getMethod();
|
Method method = signature.getMethod();
|
||||||
|
|
||||||
@@ -54,7 +57,12 @@ public class RateLimitAspect {
|
|||||||
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
|
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
|
||||||
String customKey = rateLimit.key();
|
String customKey = rateLimit.key();
|
||||||
|
|
||||||
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);
|
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
|
||||||
|
|
||||||
@@ -67,7 +75,7 @@ public class RateLimitAspect {
|
|||||||
|
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
} else {
|
} 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);
|
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"),
|
LUCK_PERMS("luckperms"),
|
||||||
LITE_BANS("litebans"),
|
LITE_BANS("litebans"),
|
||||||
DISCORD("discordLink"),
|
DISCORD("discordLink"),
|
||||||
|
PROXY_PLAYTIME("proxyplaytime"),
|
||||||
VOTING_PLUGIN("votingplugin");
|
VOTING_PLUGIN("votingplugin");
|
||||||
|
|
||||||
private final String internalName;
|
private final String internalName;
|
||||||
|
|||||||
+16
-1
@@ -15,8 +15,23 @@ public interface TeamMemberMapper {
|
|||||||
SELECT players.username, players.uuid
|
SELECT players.username, players.uuid
|
||||||
FROM luckperms_user_permissions AS permissions
|
FROM luckperms_user_permissions AS permissions
|
||||||
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
||||||
WHERE permission = #{groupPermission}
|
WHERE permission = #{groupPermission} AND server = 'global'
|
||||||
AND world = 'global'
|
AND world = 'global'
|
||||||
""")
|
""")
|
||||||
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
|
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
|
||||||
|
|
||||||
|
@ConstructorArgs({
|
||||||
|
@Arg(column = "username", javaType = String.class),
|
||||||
|
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class)
|
||||||
|
})
|
||||||
|
@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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -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) {
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ public interface VotingPluginUsersMapper {
|
|||||||
WeeklyTotal as totalVotesThisWeek,
|
WeeklyTotal as totalVotesThisWeek,
|
||||||
MonthTotal as totalVotesThisMonth,
|
MonthTotal as totalVotesThisMonth,
|
||||||
AllTimeTotal as totalVotesAllTime
|
AllTimeTotal as totalVotesAllTime
|
||||||
FROM votingplugin.votingplugin_users
|
FROM votingplugin.VotingPlugin_Users
|
||||||
WHERE uuid = #{uuid}
|
WHERE uuid = #{uuid}
|
||||||
""")
|
""")
|
||||||
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
package com.alttd.altitudeweb.database.web_db;
|
package com.alttd.altitudeweb.database.web_db;
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -106,6 +105,6 @@ public interface PrivilegedUserMapper {
|
|||||||
INSERT INTO privileged_users (uuid)
|
INSERT INTO privileged_users (uuid)
|
||||||
VALUES (#{uuid})
|
VALUES (#{uuid})
|
||||||
""")
|
""")
|
||||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "id", before = false, resultType = int.class)
|
||||||
int createPrivilegedUser(UUID uuid);
|
int createPrivilegedUser(UUID uuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class Connection {
|
|||||||
InitializeWebDb.init();
|
InitializeWebDb.init();
|
||||||
InitializeLiteBans.init();
|
InitializeLiteBans.init();
|
||||||
InitializeLuckPerms.init();
|
InitializeLuckPerms.init();
|
||||||
|
InitializeProxyPlaytime.init();
|
||||||
InitializeDiscord.init();
|
InitializeDiscord.init();
|
||||||
InitializeVotingPlugin.init();
|
InitializeVotingPlugin.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,6 +14,14 @@ export const routes: Routes = [
|
|||||||
requiredAuthorizations: ['SCOPE_head_mod']
|
requiredAuthorizations: ['SCOPE_head_mod']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'staff-pt',
|
||||||
|
loadComponent: () => import('./pages/head-mod/staff-pt/staff-pt.component').then(m => m.StaffPtComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {
|
||||||
|
requiredAuthorizations: ['SCOPE_head_mod']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'map',
|
path: 'map',
|
||||||
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
||||||
@@ -110,18 +118,27 @@ export const routes: Routes = [
|
|||||||
path: 'forms',
|
path: 'forms',
|
||||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'appeal/:code',
|
||||||
|
redirectTo: 'forms/appeal/:code',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'appeal',
|
path: 'appeal',
|
||||||
redirectTo: 'forms/appeal',
|
redirectTo: 'forms/appeal',
|
||||||
pathMatch: 'full'
|
pathMatch: 'full'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'forms/appeal/:code',
|
||||||
|
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
data: {requiredAuthorizations: ['SCOPE_user']}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'forms/appeal',
|
path: 'forms/appeal',
|
||||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
data: {
|
data: {requiredAuthorizations: ['SCOPE_user']}
|
||||||
requiredAuthorizations: ['SCOPE_user']
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'forms/sent',
|
path: 'forms/sent',
|
||||||
@@ -154,6 +171,6 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'nickgenerator',
|
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)
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||||
import {map, Observable} from 'rxjs';
|
import {from, isObservable, map, Observable, of, switchMap} from 'rxjs';
|
||||||
import {AuthService} from '@services/auth.service';
|
import {AuthService} from '@services/auth.service';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||||
|
import {catchError} from 'rxjs/operators';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -21,6 +22,20 @@ export class AuthGuard implements CanActivate {
|
|||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
|
const code = route.paramMap.get('code');
|
||||||
|
if (code) {
|
||||||
|
return this.authService.login(code).pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
const result = this.canActivateInternal(route, state);
|
||||||
|
return isObservable(result) ? result : result instanceof Promise ? from(result) : of(result);
|
||||||
|
}),
|
||||||
|
catchError(() => of(this.router.createUrlTree(['/'])))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.canActivateInternal(route, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canActivateInternal(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
if (!this.authService.isAuthenticated$()) {
|
if (!this.authService.isAuthenticated$()) {
|
||||||
this.router.createUrlTree(['/']);
|
this.router.createUrlTree(['/']);
|
||||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||||
|
|||||||
@@ -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">
|
<ul class="dropdown">
|
||||||
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
|
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
|
||||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
||||||
|
<li class="nav_li"><a class="nav_link2" [routerLink]="['/staff-pt']">StaffPlaytime</a></li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<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="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>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</ng-container>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* nick-generator.component.css */
|
||||||
|
.containerNick {
|
||||||
|
background-color: #292828;
|
||||||
|
padding: 40px 5%;
|
||||||
|
max-width: 1220px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'minecraft-text', monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {Component} 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 {
|
||||||
|
parts: Part[] = [
|
||||||
|
{text: '', gradient: false, colorA: '#ffffff', colorB: '#ffffff', continuation: false}
|
||||||
|
];
|
||||||
|
|
||||||
|
tryCmd = '';
|
||||||
|
requestCmd = '';
|
||||||
|
previewHtml: SafeHtml = '';
|
||||||
|
showPreview = false;
|
||||||
|
showCommands = false;
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '>');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="voteDisclaimer">
|
<div class="voteDisclaimer">
|
||||||
<h3 style="text-align: center;">Disclaimers & Info</h3>
|
<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>
|
keys.</p><br>
|
||||||
<p style="text-align: center;">Voting within 30 minutes of midnight UTC can cause your votes to glitch. Keys
|
<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.
|
lost due to voting too close to midnight UTC will not be reimbursed.
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class="voteSection">
|
<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">
|
<div class="container voteContainer">
|
||||||
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
||||||
<div class="vote">
|
<div class="vote">
|
||||||
@@ -51,7 +57,9 @@
|
|||||||
[href]="voteSites[voteSite]">
|
[href]="voteSites[voteSite]">
|
||||||
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
||||||
[class.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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.available-button-outer {
|
.available-button-outer {
|
||||||
background-color: #4caf50 !important;
|
border: 2px solid #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-available-button-outer {
|
.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 {AuthService} from '@services/auth.service';
|
||||||
import {interval, Subscription} from 'rxjs';
|
import {interval, Subscription} from 'rxjs';
|
||||||
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-vote',
|
selector: 'app-vote',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
TimeAgoPipe
|
TimeAgoPipe,
|
||||||
|
MatIconModule,
|
||||||
],
|
],
|
||||||
templateUrl: './vote.component.html',
|
templateUrl: './vote.component.html',
|
||||||
styleUrl: './vote.component.scss'
|
styleUrl: './vote.component.scss'
|
||||||
@@ -48,7 +50,7 @@ export class VoteComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.refreshSubscription = interval(300000).subscribe(() => {
|
this.refreshSubscription = interval(60000).subscribe(() => {
|
||||||
this.loadVoteStats();
|
this.loadVoteStats();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -94,9 +96,10 @@ export class VoteComponent implements OnInit, OnDestroy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const now: Date = new Date();
|
const now: Date = new Date();
|
||||||
return (
|
const voteInfo = this.voteStats.allVoteInfo.find(voteInfo => voteInfo.siteName === voteSite);
|
||||||
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
|
if (!voteInfo) {
|
||||||
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
|
return true;
|
||||||
)
|
}
|
||||||
|
return (now.getTime() - voteInfo.lastVoteTimestamp < 86400000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ time, mark, audio, video {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-height {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* flex end */
|
/* flex end */
|
||||||
|
|
||||||
/* main css */
|
/* main css */
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ tasks.register< GenerateTask>("generateJavaApi") {
|
|||||||
typeMappings.put("OffsetDateTime", "Instant")
|
typeMappings.put("OffsetDateTime", "Instant")
|
||||||
importMappings.put("java.time.OffsetDateTime", "java.time.Instant")
|
importMappings.put("java.time.OffsetDateTime", "java.time.Instant")
|
||||||
modelNameSuffix.set("Dto")
|
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)
|
generateModelTests.set(false)
|
||||||
generateModelDocumentation.set(false)
|
generateModelDocumentation.set(false)
|
||||||
generateApiTests.set(false)
|
generateApiTests.set(false)
|
||||||
|
|||||||
@@ -93,3 +93,5 @@ paths:
|
|||||||
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
||||||
/api/site/vote:
|
/api/site/vote:
|
||||||
$ref: './schemas/site/vote.yml#/VoteStats'
|
$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
|
||||||
Reference in New Issue
Block a user