diff --git a/.env.example b/.env.example
index 92211b4..626a366 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,10 @@ LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
+# Required if you want to enable reviews from reviewdb
+REVIEW_DB=true
+
+
# https://www.steamgriddb.com/api/v2, if you want games to have images
STEAMGRIDDB_API_KEY=steamgrid_api_key
diff --git a/config/environment.ts b/config/environment.ts
index 4d48b88..dec203b 100644
--- a/config/environment.ts
+++ b/config/environment.ts
@@ -14,6 +14,11 @@ export const lanyardConfig: LanyardConfig = {
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
};
+export const reviewDb = {
+ enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1",
+ url: "https://manti.vendicated.dev/api/reviewdb",
+};
+
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
export const steamGridDbKey: string | undefined =
process.env.STEAMGRIDDB_API_KEY;
diff --git a/public/css/index.css b/public/css/index.css
index 63fe0c2..1593bf9 100644
--- a/public/css/index.css
+++ b/public/css/index.css
@@ -814,3 +814,155 @@ ul {
font-size: 0.95rem;
}
}
+
+/* reviews */
+.reviews {
+ width: 100%;
+ max-width: 700px;
+ margin-top: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ background-color: var(--card-bg);
+ padding: 1rem;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+ box-sizing: border-box;
+}
+
+.reviews h2 {
+ margin: 0 0 1rem;
+ font-size: 2rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.reviews-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.review {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ transition: background-color 0.3s ease;
+}
+
+.review:hover {
+ background-color: var(--card-hover-bg);
+}
+
+.review-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.review-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.review-username {
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.review-timestamp {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.review-content {
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.review-badges {
+ display: flex;
+ gap: 0.3rem;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 600px) {
+ .reviews {
+ max-width: 100%;
+ padding: 1rem;
+ border-radius: 0;
+ border: none;
+ background-color: transparent;
+ }
+
+ .reviews h2 {
+ font-size: 1.4rem;
+ text-align: center;
+ margin-bottom: 1rem;
+ }
+
+ .reviews-list {
+ gap: 0.75rem;
+ }
+
+ .review {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 1rem;
+ border-radius: 0;
+ }
+
+ .review-avatar {
+ width: 64px;
+ height: 64px;
+ }
+
+ .review-body {
+ width: 100%;
+ align-items: center;
+ }
+
+ .review-header {
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .review-username {
+ font-size: 1rem;
+ }
+
+ .review-timestamp {
+ font-size: 0.75rem;
+ }
+
+ .review-content {
+ font-size: 0.9rem;
+ }
+
+ .review-badges {
+ justify-content: center;
+ }
+}
diff --git a/public/js/index.js b/public/js/index.js
index a32bbd8..d80afa7 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -2,10 +2,18 @@ const head = document.querySelector("head");
const userId = head?.dataset.userId;
const activityProgressMap = new Map();
+const reviewURL = head?.dataset.reviewDb;
let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
let socket;
+
let badgesLoaded = false;
+let readmeLoaded = false;
+let cssLoaded = false;
+
+let currentReviewPage = 1;
+let hasMoreReviews = true;
+let isLoadingReviews = false;
function formatTime(ms) {
const totalSecs = Math.floor(ms / 1000);
@@ -119,6 +127,93 @@ function resolveActivityImage(img, applicationId) {
return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
}
+async function populateReviews(userId, page = 1) {
+ if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return;
+ const reviewSection = document.querySelector(".reviews");
+ const reviewList = reviewSection?.querySelector(".reviews-list");
+ if (!reviewList) return;
+
+ isLoadingReviews = true;
+
+ try {
+ const url = `${reviewURL}/users/${userId}/reviews?page=${page}`;
+ const res = await fetch(url);
+ const data = await res.json();
+
+ if (!data.success || !Array.isArray(data.reviews)) {
+ if (page === 1) reviewSection.classList.add("hidden");
+ isLoadingReviews = false;
+ return;
+ }
+
+ const reviewsHTML = data.reviews
+ .slice(1)
+ .map((review) => {
+ const sender = review.sender;
+ const username = sender.username;
+ const avatar = sender.profilePhoto;
+ const comment = review.comment;
+ const timestamp = review.timestamp
+ ? new Date(review.timestamp * 1000).toLocaleString()
+ : "N/A";
+
+ const badges = (sender.badges || [])
+ .map(
+ (b) =>
+ ``,
+ )
+ .join("");
+
+ return `
+