From 00bf7caec20542db88c2b0cd2c46e43d1de65084 Mon Sep 17 00:00:00 2001 From: akastijn Date: Fri, 24 Oct 2025 19:39:08 +0200 Subject: [PATCH] Add vote statistics feature and improve vote page functionality --- .../controllers/site/SiteController.java | 36 ++++++++ .../mappers/VotingStatsRowToVoteDataDto.java | 62 +++++++++++++ .../services/site/VoteService.java | 47 ++++++++++ .../alttd/altitudeweb/database/Databases.java | 3 +- .../votingplugin/VotingPluginUsersMapper.java | 28 ++++++ .../database/votingplugin/VotingStatsRow.java | 15 ++++ .../alttd/altitudeweb/setup/Connection.java | 1 + .../setup/InitializeVotingPlugin.java | 17 ++++ frontend/src/app/guards/auth.guard.ts | 2 +- .../src/app/pages/vote/vote.component.html | 88 ++++--------------- .../src/app/pages/vote/vote.component.scss | 5 ++ frontend/src/app/pages/vote/vote.component.ts | 81 +++++++++++++++-- frontend/src/app/pipes/TimeAgoPipe.ts | 55 ++++++++++++ frontend/src/app/services/auth.service.ts | 2 +- open_api/src/main/resources/api.yml | 4 + .../src/main/resources/schemas/site/vote.yml | 81 +++++++++++++++++ 16 files changed, 447 insertions(+), 80 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/mappers/VotingStatsRowToVoteDataDto.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/services/site/VoteService.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingPluginUsersMapper.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingStatsRow.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/setup/InitializeVotingPlugin.java create mode 100644 frontend/src/app/pipes/TimeAgoPipe.ts create mode 100644 open_api/src/main/resources/schemas/site/vote.yml diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java new file mode 100644 index 0000000..c9c47a1 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java @@ -0,0 +1,36 @@ +package com.alttd.altitudeweb.controllers.site; + +import com.alttd.altitudeweb.api.SiteApi; +import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid; +import com.alttd.altitudeweb.model.VoteDataDto; +import com.alttd.altitudeweb.model.VoteStatsDto; +import com.alttd.altitudeweb.services.limits.RateLimit; +import com.alttd.altitudeweb.services.site.VoteService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS) +public class SiteController implements SiteApi { + + private final VoteService voteService; + + @Override + public ResponseEntity getVoteStats() { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + Optional optionalVoteDataDto = voteService.getVoteStats(uuid); + if (optionalVoteDataDto.isEmpty()) { + return ResponseEntity.noContent().build(); + } + VoteDataDto voteDataDto = optionalVoteDataDto.get(); + return ResponseEntity.ok(voteDataDto); + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/mappers/VotingStatsRowToVoteDataDto.java b/backend/src/main/java/com/alttd/altitudeweb/mappers/VotingStatsRowToVoteDataDto.java new file mode 100644 index 0000000..9f346f1 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/mappers/VotingStatsRowToVoteDataDto.java @@ -0,0 +1,62 @@ +package com.alttd.altitudeweb.mappers; + +import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow; +import com.alttd.altitudeweb.model.VoteDataDto; +import com.alttd.altitudeweb.model.VoteInfoDto; +import com.alttd.altitudeweb.model.VoteStatsDto; +import com.alttd.altitudeweb.model.VoteStreakDto; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +@Service +public class VotingStatsRowToVoteDataDto { + + public VoteDataDto map(VotingStatsRow votingStatsRow) { + VoteDataDto voteDataDto = new VoteDataDto(); + voteDataDto.setVoteStats(getVoteStats(votingStatsRow)); + voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow)); + voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow)); + voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes())); + return voteDataDto; + } + + private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) { + VoteStreakDto voteStreakDto = new VoteStreakDto(); + voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak()); + voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak()); + voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak()); + return voteStreakDto; + } + + private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) { + VoteStreakDto voteStreakDto = new VoteStreakDto(); + voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak()); + voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak()); + voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak()); + return voteStreakDto; + } + + + private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) { + VoteStatsDto voteStatsDto = new VoteStatsDto(); + voteStatsDto.setDaily(votingStatsRow.totalVotesToday()); + voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek()); + voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth()); + voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime()); + return voteStatsDto; + } + + private List getVoteInfo(String lastVotes) { + return Arrays.stream(lastVotes.split("%line%")) + .map(voteInfo -> { + String[] siteAndTimestamp = voteInfo.split("//"); + VoteInfoDto voteInfoDto = new VoteInfoDto(); + voteInfoDto.setSiteName(siteAndTimestamp[0]); + voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1])); + return voteInfoDto; + }) + .toList(); + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/site/VoteService.java b/backend/src/main/java/com/alttd/altitudeweb/services/site/VoteService.java new file mode 100644 index 0000000..64fb5fa --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/site/VoteService.java @@ -0,0 +1,47 @@ +package com.alttd.altitudeweb.services.site; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper; +import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow; +import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto; +import com.alttd.altitudeweb.model.VoteDataDto; +import com.alttd.altitudeweb.setup.Connection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VoteService { + + private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto; + + public Optional getVoteStats(UUID uuid) { + CompletableFuture> voteDataDtoFuture = new CompletableFuture<>(); + Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> { + try { + VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class); + Optional optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid); + if (optionalVotingStatsRow.isEmpty()) { + log.debug("No voting stats found for {}", uuid); + voteDataDtoFuture.complete(Optional.empty()); + return; + } + + VotingStatsRow votingStatsRow = optionalVotingStatsRow.get(); + + VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow); + voteDataDtoFuture.complete(Optional.of(voteDataDto)); + } catch (Exception e) { + log.error("Failed to get vote data for {}", uuid, e); + voteDataDtoFuture.completeExceptionally(e); + } + }); + return voteDataDtoFuture.join();//TODO handle exception + } +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java index fa21d91..df90fad 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java @@ -7,7 +7,8 @@ public enum Databases { DEFAULT("web_db"), LUCK_PERMS("luckperms"), LITE_BANS("litebans"), - DISCORD("discordLink"); + DISCORD("discordLink"), + VOTING_PLUGIN("votingplugin"); private final String internalName; diff --git a/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingPluginUsersMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingPluginUsersMapper.java new file mode 100644 index 0000000..69ef9bd --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingPluginUsersMapper.java @@ -0,0 +1,28 @@ +package com.alttd.altitudeweb.database.votingplugin; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.Optional; +import java.util.UUID; + +public interface VotingPluginUsersMapper { + + @Select(""" + SELECT + LastVotes as lastVotes, + BestDayVoteStreak as bestDailyStreak, + BestWeekVoteStreak as bestWeeklyStreak, + BestMonthVoteStreak as bestMonthlyStreak, + DayVoteStreak as dailyStreak, + WeekVoteStreak as weeklyStreak, + MonthVoteStreak as monthlyStreak, + DailyTotal as totalVotesToday, + WeeklyTotal as totalVotesThisWeek, + MonthTotal as totalVotesThisMonth, + AllTimeTotal as totalVotesAllTime + FROM votingplugin.votingplugin_users + WHERE uuid = #{uuid} + """) + Optional getStatsByUuid(@Param("uuid") UUID uuid); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingStatsRow.java b/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingStatsRow.java new file mode 100644 index 0000000..65af65a --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/votingplugin/VotingStatsRow.java @@ -0,0 +1,15 @@ +package com.alttd.altitudeweb.database.votingplugin; + +public record VotingStatsRow( + String lastVotes, + Integer bestDailyStreak, + Integer bestWeeklyStreak, + Integer bestMonthlyStreak, + Integer dailyStreak, + Integer weeklyStreak, + Integer monthlyStreak, + Integer totalVotesToday, + Integer totalVotesThisWeek, + Integer totalVotesThisMonth, + Integer totalVotesAllTime + ) { } diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java index aba2fda..3c286b8 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java @@ -38,6 +38,7 @@ public class Connection { InitializeLiteBans.init(); InitializeLuckPerms.init(); InitializeDiscord.init(); + InitializeVotingPlugin.init(); } @FunctionalInterface diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeVotingPlugin.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeVotingPlugin.java new file mode 100644 index 0000000..4c6dfa0 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeVotingPlugin.java @@ -0,0 +1,17 @@ +package com.alttd.altitudeweb.setup; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class InitializeVotingPlugin { + + protected static void init() { + log.info("Initializing VotingPlugin"); + Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> { + configuration.addMapper(VotingPluginUsersMapper.class); + }).join(); + log.debug("Initialized VotingPlugin"); + } +} diff --git a/frontend/src/app/guards/auth.guard.ts b/frontend/src/app/guards/auth.guard.ts index 60ac5fa..4fb9fe7 100644 --- a/frontend/src/app/guards/auth.guard.ts +++ b/frontend/src/app/guards/auth.guard.ts @@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate { route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean | UrlTree { - if (!this.authService.checkAuthStatus()) { + if (!this.authService.isAuthenticated$()) { this.router.createUrlTree(['/']); const dialogRef = this.dialog.open(LoginDialogComponent, { width: '400px', diff --git a/frontend/src/app/pages/vote/vote.component.html b/frontend/src/app/pages/vote/vote.component.html index a3eb590..0326b6e 100644 --- a/frontend/src/app/pages/vote/vote.component.html +++ b/frontend/src/app/pages/vote/vote.component.html @@ -41,78 +41,26 @@
-
-
-

MinecraftServers

-
- -
- Vote! +
+ @for (voteSite of Object.keys(voteSites); track voteSite) { +
+

{{ voteSite }}

+
+ @if (voteStats) { +
+

Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}

- + }
-
-
-

TopMinecraftServers

- -
-
-

MCSL

- -
-
-

Minecraft-Server

- -
-
-

PlanetMinecraft

- -
-
-

Minecraft-MP

- -
+ }
diff --git a/frontend/src/app/pages/vote/vote.component.scss b/frontend/src/app/pages/vote/vote.component.scss index fc93297..93c54d4 100644 --- a/frontend/src/app/pages/vote/vote.component.scss +++ b/frontend/src/app/pages/vote/vote.component.scss @@ -4,6 +4,11 @@ margin: 0 auto; } +.voteContainer { + padding: 50px 0 0 0; + justify-content: center; +} + .voteSection { background-color: var(--link-color); transition: 0.5s ease; diff --git a/frontend/src/app/pages/vote/vote.component.ts b/frontend/src/app/pages/vote/vote.component.ts index 9256856..5f53681 100644 --- a/frontend/src/app/pages/vote/vote.component.ts +++ b/frontend/src/app/pages/vote/vote.component.ts @@ -1,24 +1,91 @@ -import {Component} from '@angular/core'; +import {Component, effect, inject, OnDestroy, OnInit} from '@angular/core'; import {ScrollService} from '@services/scroll.service'; import {HeaderComponent} from '@header/header.component'; +import {SiteService, VoteData} from '@api'; +import {AuthService} from '@services/auth.service'; +import {interval, Subscription} from 'rxjs'; +import {TimeAgoPipe} from '@pipes/TimeAgoPipe'; @Component({ selector: 'app-vote', standalone: true, imports: [ - HeaderComponent -], + HeaderComponent, + TimeAgoPipe + ], templateUrl: './vote.component.html', styleUrl: './vote.component.scss' }) -export class VoteComponent { - constructor(public scrollService: ScrollService) { +export class VoteComponent implements OnInit, OnDestroy { + private readonly defaultVoteMessage = 'Vote!'; + private readonly clickedVoteMessage = 'Clicked!'; + + private voteMessages: { [key: string]: string } = {} + private refreshSubscription: Subscription | null = null; + + protected readonly voteSites: { [key: string]: string } = { + 'PlanetMinecraft': 'https://www.planetminecraft.com/server/alttd/vote/', + 'TopMinecraftServers': 'https://topminecraftservers.org/vote/4906', + 'Minecraft-Server': 'https://minecraft-server.net/vote/Altitude/', + 'MinecraftServers': 'https://minecraftservers.org/vote/284208', + 'MCSL': 'https://minecraft-server-list.com/server/298238/vote/', + 'Minecraft-MP': 'https://minecraft-mp.com/server/98955/vote/', } - voteMessage: string = ''; + protected scrollService: ScrollService = inject(ScrollService); + protected siteService = inject(SiteService) + protected authService = inject(AuthService) + + protected voteStats: VoteData | null = null + + constructor() { + effect(() => { + if (this.authService.isAuthenticated$()) { + this.loadVoteStats(); + } + }); + } + + ngOnInit(): void { + this.refreshSubscription = interval(300000).subscribe(() => { + this.loadVoteStats(); + }); + } + + ngOnDestroy(): void { + this.refreshSubscription?.unsubscribe(); + } clickVote(id: string) { - this.voteMessage = 'Clicked!'; + this.voteMessages[id] = this.clickedVoteMessage; } + + getVoteText(id: string) { + return this.voteMessages[id] || this.defaultVoteMessage; + } + + private loadVoteStats(): void { + if (!this.authService.isAuthenticated$()) { + return + } + this.siteService.getVoteStats().subscribe(voteStats => { + this.voteStats = voteStats; + }); + } + + protected getLastVoted(id: string): Date | null { + if (!this.voteStats) { + return null; + } + const filteredVoteInfo = this.voteStats.allVoteInfo + .filter(voteInfo => voteInfo.siteName === id); + if (filteredVoteInfo.length !== 1) { + return null; + } + + return new Date(filteredVoteInfo[0].lastVoteTimestamp); + } + + protected readonly Object = Object; } diff --git a/frontend/src/app/pipes/TimeAgoPipe.ts b/frontend/src/app/pipes/TimeAgoPipe.ts new file mode 100644 index 0000000..3d6513c --- /dev/null +++ b/frontend/src/app/pipes/TimeAgoPipe.ts @@ -0,0 +1,55 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'TimeAgo', + standalone: true +}) +export class TimeAgoPipe implements PipeTransform { + transform(value: Date | string | number | null, short?: boolean): string { + if (!value) { + return ''; + } + + const date = new Date(value); + const now = new Date(); + + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) { + return 'just now'; + } + + let returnText = 'ago'; + + const allMinutes = Math.floor(seconds / 60); + const minutes = allMinutes % 60; + if (short) { + returnText = `${minutes}m ${returnText}` + } else { + returnText = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ${returnText}` + } + if (allMinutes < 60) { + return returnText + } + + + const allHours = Math.floor(allMinutes / 60); + const hours = allHours % 24; + if (short) { + returnText = `${hours}h ${returnText}` + } else { + returnText = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${returnText}` + } + if (allHours < 24) { + return returnText + } + + const days = Math.floor(allHours / 24); + if (short) { + returnText = `${days}d ${returnText}` + } else { + returnText = `${days} ${days === 1 ? 'day' : 'days'} ${returnText}` + } + return returnText + } +} diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 1524b1c..8e6f475 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -68,7 +68,7 @@ export class AuthService { /** * Check if the user is authenticated */ - public checkAuthStatus(): boolean { + private checkAuthStatus(): boolean { const jwt = this.getJwt(); if (!jwt) { console.log("No JWT found"); diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 213504d..0888a6b 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -28,6 +28,8 @@ tags: description: All action related to appeals - name: mail description: All actions related to user email verification + - name: site + description: Actions related to small features on the site such as displaying vote stats or pt/rank stats paths: /api/team/{team}: $ref: './schemas/team/team.yml#/getTeam' @@ -89,3 +91,5 @@ paths: $ref: './schemas/forms/mail/mail.yml#/DeleteEmail' /api/mail/list: $ref: './schemas/forms/mail/mail.yml#/GetEmails' + /api/site/vote: + $ref: './schemas/site/vote.yml#/VoteStats' diff --git a/open_api/src/main/resources/schemas/site/vote.yml b/open_api/src/main/resources/schemas/site/vote.yml new file mode 100644 index 0000000..6689817 --- /dev/null +++ b/open_api/src/main/resources/schemas/site/vote.yml @@ -0,0 +1,81 @@ +VoteStats: + get: + tags: + - site + summary: Get vote stats + description: Get vote stats for current user + operationId: getVoteStats + responses: + '200': + description: Vote stats retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/VoteData' +components: + schemas: + VoteData: + type: object + required: + - allVoteInfo + - voteStats + - voteStreak + - bestVoteStreak + properties: + allVoteInfo: + type: array + items: + $ref: '#/components/schemas/VoteInfo' + voteStats: + $ref: '#/components/schemas/VoteStats' + voteStreak: + $ref: '#/components/schemas/VoteStreak' + bestVoteStreak: + $ref: '#/components/schemas/VoteStreak' + VoteInfo: + type: object + required: + - siteName + - lastVoteTimestamp + properties: + siteName: + type: string + lastVoteTimestamp: + type: integer + format: int64 + VoteStats: + type: object + required: + - total + - monthly + - weekly + - daily + properties: + total: + type: integer + format: int32 + monthly: + type: integer + format: int32 + weekly: + type: integer + format: int32 + daily: + type: integer + format: int32 + VoteStreak: + type: object + required: + - dailyStreak + - weeklyStreak + - monthlyStreak + properties: + dailyStreak: + type: integer + format: int32 + weeklyStreak: + type: integer + format: int32 + monthlyStreak: + type: integer + format: int32