diff --git a/cronus/routes/v1/auth.js b/cronus/routes/v1/auth.js index 9706eb5..1e5affc 100644 --- a/cronus/routes/v1/auth.js +++ b/cronus/routes/v1/auth.js @@ -8,6 +8,7 @@ const { authenticator } = require("otplib"); const { sendMail } = require("../../utils/smtpMailer"); const router = express.Router(); const auth = require("../../middleware/auth"); +const { getVisibleProfileBadge } = require("../../utils/profileBadges"); const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; const EMAIL_CODE_MAX_ATTEMPTS = 5; const BCRYPT_COST = 12; @@ -907,7 +908,7 @@ router.get("/github-callback", async (req, res) => { router.get("/user", auth, async (req, res) => { try { - const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, telegram_id, github_id, isRole, social_links FROM users WHERE id = ?", [req.user.id]); + const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, telegram_id, github_id, isRole, active_profile_badge, social_links FROM users WHERE id = ?", [req.user.id]); if(!users.length) { return res.status(404).json({ message: "User not found" }); @@ -915,8 +916,10 @@ router.get("/user", auth, async (req, res) => { const userData = { ...users[0], + activeProfileBadge: await getVisibleProfileBadge(db, users[0]), social_links: users[0].social_links ? JSON.parse(users[0].social_links) : {}, }; + delete userData.active_profile_badge; res.json({ user: userData, success: true }); } catch (error) { diff --git a/cronus/routes/v1/mod-jams.js b/cronus/routes/v1/mod-jams.js index e83352d..a6b53c5 100644 --- a/cronus/routes/v1/mod-jams.js +++ b/cronus/routes/v1/mod-jams.js @@ -336,6 +336,7 @@ const formatJam = (jam) => { slug: jam.owner_slug, avatar: jam.owner_avatar, isVerified: jam.owner_isVerified, + activeProfileBadge: jam.owner_activeProfileBadge, } : null, submissions_count: Number(jam.submissions_count) || 0, votes_count: Number(jam.votes_count) || 0, @@ -344,7 +345,7 @@ const formatJam = (jam) => { const getJamBySlug = async (slug) => { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count @@ -386,7 +387,7 @@ const getParticipantsCount = async (jamId) => { const getJamJury = async (jamId) => { const [rows] = await db.query( - `SELECT mjj.id, mjj.user_id, mjj.created_at, u.username, u.slug, u.avatar, u.isVerified + `SELECT mjj.id, mjj.user_id, mjj.created_at, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM mod_jam_jury mjj LEFT JOIN users u ON u.id = mjj.user_id WHERE mjj.jam_id = ? @@ -404,6 +405,7 @@ const getJamJury = async (jamId) => { slug: row.slug, avatar: row.avatar, isVerified: row.isVerified, + activeProfileBadge: row.activeProfileBadge, }, })); }; @@ -473,7 +475,7 @@ const getSubmissions = async ({ jamId, userId, resultsOnly = false }) => { p.slug AS project_slug, p.title AS project_title, p.summary AS project_summary, p.icon_url AS project_icon_url, p.downloads AS project_downloads, p.followers AS project_followers, p.updated_at AS project_updated_at, p.tags AS project_tags, p.color AS project_color, - u.username AS submitter_username, u.slug AS submitter_slug, u.avatar AS submitter_avatar, u.isVerified AS submitter_isVerified, + u.username AS submitter_username, u.slug AS submitter_slug, u.avatar AS submitter_avatar, u.isVerified AS submitter_isVerified, u.active_profile_badge AS submitter_activeProfileBadge, COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) AS votes_count, (SELECT user_vote.submission_id FROM mod_jam_votes user_vote WHERE user_vote.jam_id = mjs.jam_id AND user_vote.user_id = ? AND COALESCE(user_vote.nomination_id, 0) = 0 LIMIT 1) AS user_voted_submission_id FROM mod_jam_submissions mjs @@ -540,6 +542,7 @@ const getSubmissions = async ({ jamId, userId, resultsOnly = false }) => { slug: row.submitter_slug, avatar: row.submitter_avatar, isVerified: row.submitter_isVerified, + activeProfileBadge: row.submitter_activeProfileBadge, }, votes_count: Number(row.votes_count) || 0, nomination_votes: nominationScoresBySubmissionId.get(Number(row.id)) || {}, @@ -558,7 +561,7 @@ const getUserBySlug = async (slug) => { } const [rows] = await db.query( - "SELECT id, username, slug, avatar, isVerified FROM users WHERE slug = ? LIMIT 1", + "SELECT id, username, slug, avatar, isVerified, active_profile_badge AS activeProfileBadge FROM users WHERE slug = ? LIMIT 1", [normalizedSlug] ); @@ -727,7 +730,7 @@ router.get("/moderation", auth, async (req, res) => { try { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge FROM mod_jams mj LEFT JOIN users u ON u.id = mj.owner_user_id WHERE mj.status = 'pending_review' @@ -744,7 +747,7 @@ router.get("/moderation", auth, async (req, res) => { router.get("/mine", auth, async (req, res) => { try { const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count @@ -790,7 +793,7 @@ router.get("/", async (req, res) => { } const [rows] = await db.query( - `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, + `SELECT mj.*, u.username AS owner_username, u.slug AS owner_slug, u.avatar AS owner_avatar, u.isVerified AS owner_isVerified, u.active_profile_badge AS owner_activeProfileBadge, (SELECT COUNT(*) FROM mod_jam_submissions mjs WHERE mjs.jam_id = mj.id AND mjs.status = 'submitted') AS submissions_count, ${PARTICIPANTS_COUNT_SELECT} AS participants_count, (SELECT COALESCE(SUM(COALESCE(mjv.vote_weight, 1)), 0) FROM mod_jam_votes mjv WHERE mjv.jam_id = mj.id) AS votes_count diff --git a/cronus/routes/v1/notifications.js b/cronus/routes/v1/notifications.js index ff6e324..7b4a4e7 100644 --- a/cronus/routes/v1/notifications.js +++ b/cronus/routes/v1/notifications.js @@ -188,7 +188,8 @@ router.get("/", auth, async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM notification_events ne INNER JOIN users u ON u.id = ne.actor_user_id WHERE ne.recipient_user_id = ? @@ -237,6 +238,7 @@ router.get("/", auth, async (req, res) => { slug: actor.slug, avatar: actor.avatar, isVerified: Number(actor.isVerified || 0), + activeProfileBadge: actor.activeProfileBadge, createdAt: Number(actor.created_at), })), project: row.object_type === "project" ? (projectMap.get(String(row.object_id)) || null) : null, diff --git a/cronus/routes/v1/organizations.js b/cronus/routes/v1/organizations.js index 30302aa..1823487 100644 --- a/cronus/routes/v1/organizations.js +++ b/cronus/routes/v1/organizations.js @@ -70,6 +70,7 @@ const getMemberView = (row) => ({ slug: row.slug, avatar: row.avatar, isVerified: row.isVerified, + activeProfileBadge: row.activeProfileBadge, role: row.role, status: row.status, project_permissions: parsePermissions(row.project_permissions), @@ -331,7 +332,8 @@ router.get("/:slug", async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM organization_members om INNER JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? @@ -396,7 +398,8 @@ router.get("/:slug/settings", auth, async (req, res) => { u.username, u.slug, u.avatar, - u.isVerified + u.isVerified, + u.active_profile_badge AS activeProfileBadge FROM organization_members om INNER JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? @@ -407,7 +410,7 @@ router.get("/:slug/settings", auth, async (req, res) => { const [pendingInvites] = await db.query( `SELECT oi.id, oi.invited_user_id, oi.invited_by_user_id, oi.role, oi.project_permissions, oi.organization_permissions, oi.created_at, - u.username, u.slug, u.avatar, u.isVerified + u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM organization_invitations oi INNER JOIN users u ON u.id = oi.invited_user_id WHERE oi.organization_id = ? AND oi.status = 'pending' @@ -439,6 +442,7 @@ router.get("/:slug/settings", auth, async (req, res) => { slug: invite.slug, avatar: invite.avatar, isVerified: invite.isVerified, + activeProfileBadge: invite.activeProfileBadge, role: invite.role, project_permissions: parsePermissions(invite.project_permissions), organization_permissions: parsePermissions(invite.organization_permissions), diff --git a/cronus/routes/v1/projects.js b/cronus/routes/v1/projects.js index 2aafae8..e3327ee 100644 --- a/cronus/routes/v1/projects.js +++ b/cronus/routes/v1/projects.js @@ -1201,7 +1201,7 @@ router.get("/", async (req, res) => { let query = ` SELECT p.id, p.slug, p.title, p.summary, p.icon_url, p.color, p.downloads, p.followers, p.created_at, p.updated_at, p.project_type, p.tags, p.license_id, p.license_name, p.show_players_last_14d, - ANY_VALUE(u.username) AS username, ANY_VALUE(u.slug) AS user_slug, ANY_VALUE(u.avatar) AS avatar, ANY_VALUE(u.id) AS user_id, ANY_VALUE(u.isVerified) AS isVerified, + ANY_VALUE(u.username) AS username, ANY_VALUE(u.slug) AS user_slug, ANY_VALUE(u.avatar) AS avatar, ANY_VALUE(u.id) AS user_id, ANY_VALUE(u.isVerified) AS isVerified, ANY_VALUE(u.active_profile_badge) AS activeProfileBadge, ANY_VALUE(o.id) AS organization_id, ANY_VALUE(o.slug) AS organization_slug, ANY_VALUE(o.name) AS organization_name, ANY_VALUE(o.icon_url) AS organization_icon_url, ANY_VALUE(o.summary) AS organization_summary, ANY_VALUE(pv.game_versions) AS game_versions, ANY_VALUE(pv.loaders) AS loaders, (SELECT url FROM project_gallery WHERE project_id = p.id AND featured = 1 LIMIT 1) AS featured_image @@ -1327,6 +1327,7 @@ router.get("/", async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -1422,6 +1423,7 @@ router.get('/user/projects', auth, async (req, res) => { u.slug AS user_slug, u.avatar, u.isVerified, + u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, @@ -1482,6 +1484,7 @@ router.get('/user/projects', auth, async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: profileUrl, }, @@ -2191,7 +2194,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { const [project] = await db.query( `SELECT p.*, - u.username, u.slug AS user_slug, u.avatar, u.id AS user_id, u.isVerified AS isVerified, + u.username, u.slug AS user_slug, u.avatar, u.id AS user_id, u.isVerified AS isVerified, u.active_profile_badge AS activeProfileBadge, (SELECT COUNT(*) FROM project_likes pl WHERE pl.project_id = p.id) AS followers_count, (SELECT 1 FROM project_likes pl WHERE pl.project_id = p.id AND pl.user_id = ?) AS is_liked FROM projects p @@ -2220,7 +2223,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { ); const [members] = await db.query( - `SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified + `SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM project_members pm LEFT JOIN users u ON pm.user_id = u.id WHERE pm.project_id = ?`, @@ -2320,6 +2323,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { slug: projectData.user_slug, avatar: projectData.avatar, isVerified: projectData.isVerified, + activeProfileBadge: projectData.activeProfileBadge, type: "user", profile_url: `/user/${projectData.user_slug}`, }, @@ -2338,6 +2342,7 @@ router.get('/:slug', optionalAuth, async (req, res) => { slug: member.slug, avatar: member.avatar, isVerified: member.isVerified, + activeProfileBadge: member.activeProfileBadge, })), mod_jam_participations: modJamParticipations.map((jam) => ({ ...(() => { @@ -3056,7 +3061,7 @@ router.get('/:slug/members', async (req, res) => { } const [members] = await db.query(` - SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified + SELECT pm.user_id, pm.role, pm.status, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM project_members pm LEFT JOIN users u ON pm.user_id = u.id WHERE pm.project_id = ? @@ -3110,7 +3115,7 @@ router.get("/:slug/issues", optionalAuth, async (req, res) => { const params = [project.id]; let query = ` SELECT i.id, i.title, i.status, i.created_at, i.updated_at, i.author_user_id, i.is_pinned, - u.username, u.slug, u.avatar, u.isVerified, u.isRole, + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge, ( SELECT COUNT(*) FROM project_issue_comments ic WHERE ic.issue_id = i.id AND ic.status = 'visible' @@ -3177,6 +3182,7 @@ router.get("/:slug/issues", optionalAuth, async (req, res) => { avatar: issue.avatar, isVerified: issue.isVerified, isRole: issue.isRole, + activeProfileBadge: issue.activeProfileBadge, } : null, })); @@ -3669,7 +3675,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { const [issues] = await db.query( `SELECT i.id, i.title, i.body, i.status, i.created_at, i.updated_at, i.closed_at, i.closed_by, i.template_id, - i.author_user_id, u.username, u.slug, u.avatar, u.isVerified, u.isRole + i.author_user_id, u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issues i LEFT JOIN users u ON u.id = i.author_user_id WHERE i.project_id = ? AND i.id = ? LIMIT 1`, @@ -3702,7 +3708,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { const commentsQuery = ` SELECT c.id, c.parent_id, c.content, c.created_at, c.updated_at, c.status, c.user_id, - u.username, u.slug, u.avatar, u.isVerified, u.isRole + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issue_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.issue_id = ? @@ -3728,13 +3734,14 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: comment.avatar, isVerified: comment.isVerified, isRole: comment.isRole, + activeProfileBadge: comment.activeProfileBadge, }, }; }); const [eventRows] = await db.query( `SELECT e.id, e.event_type, e.created_at, e.actor_user_id, e.meta, - u.username, u.slug, u.avatar, u.isVerified, u.isRole + u.username, u.slug, u.avatar, u.isVerified, u.isRole, u.active_profile_badge AS activeProfileBadge FROM project_issue_events e LEFT JOIN users u ON u.id = e.actor_user_id WHERE e.issue_id = ? @@ -3754,6 +3761,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: row.avatar, isVerified: row.isVerified, isRole: row.isRole, + activeProfileBadge: row.activeProfileBadge, } : null, })); @@ -3787,6 +3795,7 @@ router.get("/:slug/issues/:issueId", optionalAuth, async (req, res) => { avatar: issue.avatar, isVerified: issue.isVerified, isRole: issue.isRole, + activeProfileBadge: issue.activeProfileBadge, } : null, }, canManage: !!access.canManage, @@ -4274,7 +4283,7 @@ router.post("/:slug/issues/:issueId/comments", auth, async (req, res) => { [issueId, userId, parent_id || null, trimmed, now, now] ); - const [users] = await db.query("SELECT id, username, slug, avatar, isVerified, isRole FROM users WHERE id = ? LIMIT 1", [userId]); + const [users] = await db.query("SELECT id, username, slug, avatar, isVerified, isRole, active_profile_badge AS activeProfileBadge FROM users WHERE id = ? LIMIT 1", [userId]); const author = users[0]; return res.status(201).json({ @@ -4291,6 +4300,7 @@ router.post("/:slug/issues/:issueId/comments", auth, async (req, res) => { avatar: author.avatar, isVerified: author.isVerified, isRole: author.isRole, + activeProfileBadge: author.activeProfileBadge, }, }); } catch (error) { diff --git a/cronus/routes/v1/recommended.js b/cronus/routes/v1/recommended.js index 07ac182..b1a65ad 100644 --- a/cronus/routes/v1/recommended.js +++ b/cronus/routes/v1/recommended.js @@ -55,6 +55,7 @@ router.get("/", async (req, res) => { u.username, u.slug AS user_slug, u.avatar AS user_avatar, + u.active_profile_badge AS activeProfileBadge, o.slug AS organization_slug, o.name AS organization_name, o.icon_url AS organization_icon_url, @@ -87,6 +88,7 @@ router.get("/", async (req, res) => { username: project.username, slug: project.user_slug, avatar: project.user_avatar || "https://media.modifold.com/static/no-project-icon.svg", + activeProfileBadge: project.activeProfileBadge, profile_url: `/user/${project.user_slug}`, }, })), diff --git a/cronus/routes/v1/users.js b/cronus/routes/v1/users.js index 4273e98..9eaef6c 100644 --- a/cronus/routes/v1/users.js +++ b/cronus/routes/v1/users.js @@ -11,6 +11,7 @@ const fs = require("fs").promises; const path = require("path"); const { sanitizePlainText, sanitizeSocialLinks } = require("../../utils/sanitize"); const { normalizeSlugInput, validateSlug, getSlugValidationMessage } = require("../../utils/slug"); +const { getUnlockedProfileBadges, getVisibleProfileBadge, normalizeProfileBadgeCode } = require("../../utils/profileBadges"); const storage = multer.diskStorage({ destination: process.env.MEDIA_ROOT, @@ -201,12 +202,15 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), await db.query("UPDATE users SET ? WHERE id = ?", [updates, req.user.id]); - const [updatedUser] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, social_links FROM users WHERE id = ?", [req.user.id]); + const [updatedUser] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE id = ?", [req.user.id]); if(updatedUser[0]?.social_links) { updatedUser[0].social_links = JSON.parse(updatedUser[0].social_links); } + updatedUser[0].activeProfileBadge = await getVisibleProfileBadge(db, updatedUser[0]); + delete updatedUser[0].active_profile_badge; + res.json(updatedUser[0]); } catch (error) { console.error("Error updating user:", error); @@ -214,6 +218,47 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), } }); +router.put("/me/profile-badge", auth, async (req, res) => { + try { + const rawBadge = req.body?.badge; + const hasBadgeValue = rawBadge !== null && rawBadge !== undefined && String(rawBadge).trim() !== ""; + const nextBadge = normalizeProfileBadgeCode(rawBadge); + + if(hasBadgeValue && !nextBadge) { + return res.status(400).json({ message: "Invalid profile badge" }); + } + + const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE id = ? LIMIT 1", [req.user.id]); + const user = users[0]; + + if(!user) { + return res.status(404).json({ message: "User not found" }); + } + + if(nextBadge) { + const unlockedBadges = await getUnlockedProfileBadges(db, user); + if(!unlockedBadges.includes(nextBadge)) { + return res.status(403).json({ message: "Profile badge is not available" }); + } + } + + await db.query("UPDATE users SET active_profile_badge = ? WHERE id = ?", [nextBadge, req.user.id]); + + const updatedUser = { + ...user, + activeProfileBadge: nextBadge, + social_links: user.social_links ? JSON.parse(user.social_links) : {}, + }; + + delete updatedUser.active_profile_badge; + + return res.json(updatedUser); + } catch (error) { + console.error("Error updating profile badge:", error); + return res.status(500).json({ message: "Error updating profile badge", error: error.message }); + } +}); + router.get("/slug-availability/:slug", auth, async (req, res) => { try { const candidateSlug = normalizeSlugInput(req.params.slug); @@ -296,6 +341,7 @@ router.get("/me/likes", auth, async (req, res) => { u.slug AS user_slug, u.avatar, u.isVerified, + u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, @@ -358,6 +404,7 @@ router.get("/me/likes", auth, async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -397,7 +444,7 @@ router.get("/:username/projects", async (req, res) => { let query = ` SELECT p.id, p.slug, p.title, p.summary, p.icon_url, p.downloads, p.created_at, p.updated_at, p.project_type, p.tags, - u.username, u.slug AS user_slug, u.avatar, u.isVerified, + u.username, u.slug AS user_slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge, o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, o.icon_url AS organization_icon_url, o.summary AS organization_summary, (SELECT url FROM project_gallery WHERE project_id = p.id AND featured = 1 LIMIT 1) AS featured_image FROM projects p @@ -465,6 +512,7 @@ router.get("/:username/projects", async (req, res) => { slug: project.user_slug, avatar: project.avatar, isVerified: project.isVerified, + activeProfileBadge: project.activeProfileBadge, type: "user", profile_url: `/user/${project.user_slug}`, }, @@ -507,7 +555,7 @@ router.get("/:username/follows", async (req, res) => { const whereColumn = type === "subscribers" ? "s.userid" : "s.author_id"; const [rows] = await db.query( - `SELECT u.id, u.username, u.slug, u.avatar, u.isVerified + `SELECT u.id, u.username, u.slug, u.avatar, u.isVerified, u.active_profile_badge AS activeProfileBadge FROM subs s INNER JOIN users u ON u.id = ${joinColumn} WHERE ${whereColumn} = ? AND s.type = 'user' @@ -523,6 +571,7 @@ router.get("/:username/follows", async (req, res) => { slug: item.slug, avatar: item.avatar, isVerified: item.isVerified, + activeProfileBadge: item.activeProfileBadge, })); const responseData = { @@ -634,7 +683,7 @@ router.get("/:username/achievements", async (req, res) => { router.get("/:username", async (req, res) => { try { - const [user] = await db.query("SELECT id, username, slug, description, avatar, cover, created_at, isVerified, isRole, social_links FROM users WHERE slug = ?", [req.params.username]); + const [user] = await db.query("SELECT id, username, slug, description, avatar, cover, created_at, isVerified, isRole, active_profile_badge, social_links FROM users WHERE slug = ?", [req.params.username]); if(!user.length) { return res.status(404).json({ message: "User not found" }); @@ -645,6 +694,7 @@ router.get("/:username", async (req, res) => { const [subs] = await db.query("SELECT COUNT(*) as count FROM subs WHERE userid = ?", [userId]); const [userSubs] = await db.query("SELECT COUNT(*) as count FROM subs WHERE author_id = ?", [userId]); + const activeProfileBadge = await getVisibleProfileBadge(db, user[0]); const userWithoutSensitiveData = { id: user[0].id, username: user[0].username, @@ -655,6 +705,7 @@ router.get("/:username", async (req, res) => { created_at: user[0].created_at, isVerified: user[0].isVerified, isRole: user[0].isRole, + activeProfileBadge, subscribers: subs[0].count, subscriptions: userSubs[0].count, social_links: user[0].social_links ? JSON.parse(user[0].social_links) : {}, diff --git a/cronus/utils/profileBadges.js b/cronus/utils/profileBadges.js new file mode 100644 index 0000000..ee9b910 --- /dev/null +++ b/cronus/utils/profileBadges.js @@ -0,0 +1,81 @@ +const PROFILE_BADGE_CODES = { + CREATOR: "creator_badge", + STAFF: "staff", + FIRST_PROJECT: "first_project", + MOD_JAM: "hytalemodjam_2026", + DOWNLOADS_100: "downloads_100", + DOWNLOADS_500: "downloads_500", + DOWNLOADS_10000: "downloads_10000", +}; + +const PROFILE_BADGE_CODE_SET = new Set(Object.values(PROFILE_BADGE_CODES)); +const ACHIEVEMENT_PROFILE_BADGES = [ + PROFILE_BADGE_CODES.FIRST_PROJECT, + PROFILE_BADGE_CODES.MOD_JAM, + PROFILE_BADGE_CODES.DOWNLOADS_100, + PROFILE_BADGE_CODES.DOWNLOADS_500, + PROFILE_BADGE_CODES.DOWNLOADS_10000, +]; + +const normalizeProfileBadgeCode = (value) => { + if(value === null || value === undefined || value === "") { + return null; + } + + const code = String(value).trim(); + return PROFILE_BADGE_CODE_SET.has(code) ? code : null; +}; + +const isStaffRole = (role) => role === "admin" || role === "moderator" || role === "staff"; + +const getUnlockedProfileBadges = async (db, user) => { + if(!user?.id) { + return []; + } + + const unlocked = []; + + if(Number(user.isVerified || 0) === 1) { + unlocked.push(PROFILE_BADGE_CODES.CREATOR); + } + + if(isStaffRole(user.isRole)) { + unlocked.push(PROFILE_BADGE_CODES.STAFF); + } + + const [achievementRows] = await db.query( + `SELECT a.code + FROM user_achievements ua + INNER JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = ? + AND a.code IN (?) + AND a.is_active = 1`, + [user.id, ACHIEVEMENT_PROFILE_BADGES] + ); + + const awardedCodes = new Set(achievementRows.map((row) => row.code)); + for(const code of ACHIEVEMENT_PROFILE_BADGES) { + if(awardedCodes.has(code)) { + unlocked.push(code); + } + } + + return unlocked; +}; + +const getVisibleProfileBadge = async (db, user) => { + const activeBadge = normalizeProfileBadgeCode(user?.active_profile_badge || user?.activeProfileBadge); + if(!activeBadge) { + return null; + } + + const unlockedBadges = await getUnlockedProfileBadges(db, user); + return unlockedBadges.includes(activeBadge) ? activeBadge : null; +}; + +module.exports = { + PROFILE_BADGE_CODES, + getUnlockedProfileBadges, + getVisibleProfileBadge, + normalizeProfileBadgeCode, +}; \ No newline at end of file diff --git a/pegasus/app/user/[username]/page.js b/pegasus/app/user/[username]/page.js index 425f8d3..66a7fb6 100644 --- a/pegasus/app/user/[username]/page.js +++ b/pegasus/app/user/[username]/page.js @@ -46,7 +46,7 @@ export default async function Page({ params, searchParams }) { const userRes = await fetch(`${apiBase}/users/${username}`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}`] }, + cache: "no-store", }); if(!userRes.ok) { @@ -63,12 +63,12 @@ export default async function Page({ params, searchParams }) { const banFetchOptions = { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:ban`] }, + cache: "no-store", }; const projectsFetchOptions = { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:projects:${currentProjectsPage}:${currentProjectsSort}`] }, + cache: "no-store", }; const [banRes, projectsRes, organizationsRes, achievementsRes] = await Promise.all([ @@ -76,11 +76,11 @@ export default async function Page({ params, searchParams }) { fetch(`${apiBase}/users/${username}/projects?page=${currentProjectsPage}&limit=20&sort=${currentProjectsSort}`, projectsFetchOptions), fetch(`${apiBase}/users/${username}/organizations`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:organizations`] }, + cache: "no-store", }), fetch(`${apiBase}/users/${username}/achievements`, { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:achievements`] }, + cache: "no-store", }), ]); diff --git a/pegasus/components/layout/Header.js b/pegasus/components/layout/Header.js index 477c0f0..0a783c7 100644 --- a/pegasus/components/layout/Header.js +++ b/pegasus/components/layout/Header.js @@ -8,6 +8,8 @@ import Link from "next/link"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { toast } from "react-toastify"; +import ProfileBadgeIcon from "@/components/ui/ProfileBadgeIcon"; +import { getProfileBadgeCode } from "@/utils/profileBadges"; export default function Header({ authToken }) { const t = useTranslations("Header"); @@ -21,6 +23,7 @@ export default function Header({ authToken }) { const buttonRef = useRef(null); const browseWrapperRef = useRef(null); const browseCloseTimeoutRef = useRef(null); + const activeProfileBadgeCode = getProfileBadgeCode(user); useEffect(() => { @@ -296,8 +299,8 @@ export default function Header({ authToken }) {
{user?.username}
- {user?.isVerified === 1 && ( - Verified + {activeProfileBadgeCode && ( + )}
diff --git a/pegasus/components/layout/HeaderMobile.js b/pegasus/components/layout/HeaderMobile.js index e817c70..f5ce7ad 100644 --- a/pegasus/components/layout/HeaderMobile.js +++ b/pegasus/components/layout/HeaderMobile.js @@ -7,6 +7,8 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuth } from "../providers/AuthProvider"; import LoginModal from "../../modal/LoginModal"; +import ProfileBadgeIcon from "@/components/ui/ProfileBadgeIcon"; +import { getProfileBadgeCode } from "@/utils/profileBadges"; export default function HeaderMobile({ authToken }) { const t = useTranslations("Header"); @@ -20,6 +22,7 @@ export default function HeaderMobile({ authToken }) { const [theme, setThemeState] = useState("system"); const menuRef = useRef(null); const buttonRef = useRef(null); + const activeProfileBadgeCode = getProfileBadgeCode(user); const applyTheme = (nextTheme) => { const resolvedTheme = nextTheme === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : nextTheme === "dark" ? "dark" : "light"; @@ -280,8 +283,8 @@ export default function HeaderMobile({ authToken }) {
{user?.username}
- {user?.isVerified === 1 && ( - Verified + {activeProfileBadgeCode && ( + )}
diff --git a/pegasus/components/pages/ProfilePage.js b/pegasus/components/pages/ProfilePage.js index 58c6221..1162232 100644 --- a/pegasus/components/pages/ProfilePage.js +++ b/pegasus/components/pages/ProfilePage.js @@ -1,14 +1,14 @@ "use client"; -import { Fragment, useState, useRef, useEffect } from "react"; +import { Fragment, useState, useRef, useEffect, useMemo } from "react"; import { useAuth } from "../providers/AuthProvider"; import axios from "axios"; import { toast } from "react-toastify"; import Link from "next/link"; import ProjectCard from "../project/ProjectCard"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; import UserName from "../ui/UserName"; -import Modal from "react-modal"; import ImageLightbox, { useImageLightbox } from "../ui/ImageLightbox"; import RoleBadge from "../ui/RoleBadge"; import ProfileSubscriptionsModal from "@/modal/ProfileSubscriptionsModal"; @@ -16,10 +16,8 @@ import ProfileAchievements from "@/components/ui/ProfileAchievements"; import ProfileLinks from "@/components/ui/ProfileLinks"; import ProfileProjectFeedToolbar from "@/components/ui/ProfileProjectFeedToolbar"; import ProfileStats from "@/components/ui/ProfileStats"; - -if(typeof window !== "undefined") { - Modal.setAppElement("body"); -} +import ProfileBadgeIcon from "@/components/ui/ProfileBadgeIcon"; +import { PROFILE_BADGES, getProfileBadgeCode } from "@/utils/profileBadges"; const DESCRIPTION_URL_RE = /\bhttps?:\/\/[^\s<]+/gi; const PROFILE_IMAGE_MAX_SIZE = 10 * 1024 * 1024; @@ -90,16 +88,20 @@ const isAllowedProfileImageFile = (file) => file?.type === "image/jpeg" || file? export default function ProfilePage({ user, isBanned, isSubscribed: initialSubscribed, subscriptionId: initialSubId, authToken, projects = [], totalProjects = null, totalDownloads = null, organizations = [], achievements = [], currentPage = 1, totalPages = 1, currentSort = "downloads" }) { const t = useTranslations("ProfilePage"); + const router = useRouter(); const { isLoggedIn, user: currentUser, setUser } = useAuth(); const [profileUser, setProfileUser] = useState(user); const [isSubscribed, setIsSubscribed] = useState(initialSubscribed); const [subscriptionId, setSubscriptionId] = useState(initialSubId); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [isVerifiedModalOpen, setIsVerifiedModalOpen] = useState(false); + const [isBadgePopoverOpen, setIsBadgePopoverOpen] = useState(false); + const [isProfileBadgeSaving, setIsProfileBadgeSaving] = useState(false); const [activeFollowModal, setActiveFollowModal] = useState(null); const [uploadingProfileImage, setUploadingProfileImage] = useState(null); const popoverRef = useRef(null); const buttonRef = useRef(null); + const badgePopoverRef = useRef(null); + const badgeButtonRef = useRef(null); const avatarInputRef = useRef(null); const coverInputRef = useRef(null); const { lightboxOpen, lightboxImage, closeLightbox, getLightboxTriggerProps } = useImageLightbox(); @@ -118,11 +120,15 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setIsPopoverOpen(false); } + if(isBadgePopoverOpen && badgePopoverRef.current && !badgePopoverRef.current.contains(event.target) && badgeButtonRef.current && !badgeButtonRef.current.contains(event.target)) { + setIsBadgePopoverOpen(false); + } + }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isPopoverOpen]); + }, [isPopoverOpen, isBadgePopoverOpen]); const handleSubscribe = async () => { if(!isLoggedIn || !authToken) { @@ -152,6 +158,10 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setIsPopoverOpen((prev) => !prev); }; + const refreshProfileData = () => { + router.refresh(); + }; + const handleProfileImageChange = async (field, event) => { if(uploadingProfileImage) { return; @@ -191,6 +201,7 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setProfileUser((prev) => ({ ...prev, ...updatedUser })); setUser((prev) => ({ ...prev, ...updatedUser })); + refreshProfileData(); toast.success(t(field === "cover" ? "coverUploadSuccess" : "avatarUploadSuccess")); } catch (err) { toast.error(err.response?.data?.message || t("errors.profileImageUpload")); @@ -199,6 +210,30 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc } }; + const handleProfileBadgeChange = async (badgeCode) => { + if(!authToken || isProfileBadgeSaving) { + return; + } + + try { + setIsProfileBadgeSaving(true); + const res = await axios.put(`${process.env.NEXT_PUBLIC_API_BASE}/users/me/profile-badge`, { badge: badgeCode }, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + const updatedUser = res.data || {}; + + setProfileUser((prev) => ({ ...prev, ...updatedUser })); + setUser((prev) => ({ ...prev, ...updatedUser })); + setIsBadgePopoverOpen(false); + refreshProfileData(); + toast.success(t("profileBadgeSaveSuccess")); + } catch (err) { + toast.error(err.response?.data?.message || t("errors.profileBadgeUpdate")); + } finally { + setIsProfileBadgeSaving(false); + } + }; + const handleOpenFollowModal = (type) => { const count = type === "subscribers" ? countSubs : countUserSubs; if(count < 1) { @@ -227,6 +262,43 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc getSafeExternalUrl(profileUser?.social_links?.youtube) ); const hasSidebar = (!isBanned && achievements.length > 0) || hasSocialLinks || organizations.length > 0; + const achievementCodes = useMemo(() => new Set((Array.isArray(achievements) ? achievements : []).map((achievement) => achievement?.code).filter(Boolean)), [achievements]); + const availableProfileBadges = useMemo(() => { + const availableCodes = new Set(); + + if(Number(profileUser?.isVerified || 0) === 1) { + availableCodes.add("creator_badge"); + } + + if(profileUser?.isRole === "admin" || profileUser?.isRole === "moderator" || profileUser?.isRole === "staff") { + availableCodes.add("staff"); + } + + if(achievementCodes.has("hytalemodjam_2026")) { + availableCodes.add("hytalemodjam_2026"); + } + + if(achievementCodes.has("first_project")) { + availableCodes.add("first_project"); + } + + if(achievementCodes.has("downloads_100")) { + availableCodes.add("downloads_100"); + } + + if(achievementCodes.has("downloads_500")) { + availableCodes.add("downloads_500"); + } + + if(achievementCodes.has("downloads_10000")) { + availableCodes.add("downloads_10000"); + } + + return PROFILE_BADGES.filter((badge) => availableCodes.has(badge.code)); + }, [achievementCodes, profileUser?.isRole, profileUser?.isVerified]); + const activeProfileBadgeCode = getProfileBadgeCode(profileUser); + const canChooseProfileBadge = isOwnProfile && !isBanned && availableProfileBadges.length > 0; + const showProfileBadgeButton = Boolean(activeProfileBadgeCode || canChooseProfileBadge); return ( <> @@ -320,12 +392,44 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc

- + + + {showProfileBadgeButton && ( + + - {profileUser.isVerified === 1 && ( - + {canChooseProfileBadge && isBadgePopoverOpen && ( + + + + + {availableProfileBadges.map((badge) => ( + + ))} + + + )} + )}

@@ -404,30 +508,6 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc username={profileUser.slug} type={activeFollowModal} /> - - setIsVerifiedModalOpen(false)} className="modal active" overlayClassName="modal-overlay"> -
-
- -
- -
-
- {t("verifiedModal.creatorBadgeAlt")} - -

{t("verifiedModal.creatorBadgeText")}

- - - {t("verifiedModal.learnMore")} - -
-
-
-
); } \ No newline at end of file diff --git a/pegasus/components/ui/ProfileBadgeIcon.js b/pegasus/components/ui/ProfileBadgeIcon.js new file mode 100644 index 0000000..9514315 --- /dev/null +++ b/pegasus/components/ui/ProfileBadgeIcon.js @@ -0,0 +1,13 @@ +import { getProfileBadge } from "@/utils/profileBadges"; + +export default function ProfileBadgeIcon({ badge, className = "", alt = "", ...props }) { + const profileBadge = getProfileBadge(badge); + + if(!profileBadge) { + return null; + } + + return ( + {alt} + ); +} \ No newline at end of file diff --git a/pegasus/components/ui/UserName.js b/pegasus/components/ui/UserName.js index ce905b1..f20e3bc 100644 --- a/pegasus/components/ui/UserName.js +++ b/pegasus/components/ui/UserName.js @@ -1,16 +1,19 @@ +import ProfileBadgeIcon from "./ProfileBadgeIcon"; +import { getProfileBadgeCode } from "@/utils/profileBadges"; + export default function UserName({ user, className = "", showVerifiedIcon = true }) { if(!user) { return null; } - const isVerified = user.isVerified === 1; + const profileBadgeCode = getProfileBadgeCode(user); return ( {user.username} - {isVerified && showVerifiedIcon && ( - Verified + {profileBadgeCode && showVerifiedIcon && ( + )} ); diff --git a/pegasus/i18n/messages/en.json b/pegasus/i18n/messages/en.json index bfc30de..f9b2e35 100644 --- a/pegasus/i18n/messages/en.json +++ b/pegasus/i18n/messages/en.json @@ -994,7 +994,8 @@ "loadingFollowList": "Error loading follow list", "profileImageTooLarge": "File is too large. Maximum size is 10 MB", "invalidImageType": "Only JPG, PNG, WEBP, and GIF images are allowed", - "profileImageUpload": "Error uploading profile image" + "profileImageUpload": "Error uploading profile image", + "profileBadgeUpdate": "Error updating profile badge" }, "loginToSubscribe": "Log in to subscribe", "edit": "Edit", @@ -1015,6 +1016,9 @@ "newProfileBadge": "This is your new profile!", "avatarUploadSuccess": "Avatar updated", "coverUploadSuccess": "Cover updated", + "profileBadgeSaveSuccess": "Profile badge updated", + "profileBadgePickerAria": "Choose profile badge", + "profileBadgeClearAria": "Remove profile badge", "projectsLabel": "{count, plural, one {project} other {projects}}", "downloadsLabel": "{count, plural, one {download} other {downloads}}", "publishedProjectsTooltip": "Published projects", diff --git a/pegasus/i18n/messages/es.json b/pegasus/i18n/messages/es.json index 0c07d0f..4eb4778 100644 --- a/pegasus/i18n/messages/es.json +++ b/pegasus/i18n/messages/es.json @@ -994,7 +994,8 @@ "loadingFollowList": "Error al cargar la lista de seguimiento", "profileImageTooLarge": "El archivo es demasiado grande. El tamaño máximo es 10 MB", "invalidImageType": "Solo se permiten imágenes JPG, PNG, WEBP y GIF", - "profileImageUpload": "Error al subir la imagen del perfil" + "profileImageUpload": "Error al subir la imagen del perfil", + "profileBadgeUpdate": "Error al actualizar la insignia del perfil" }, "loginToSubscribe": "Inicia sesión para suscribirte", "edit": "Editar", @@ -1015,6 +1016,9 @@ "newProfileBadge": "¡Este es tu nuevo perfil!", "avatarUploadSuccess": "Avatar actualizado", "coverUploadSuccess": "Portada actualizada", + "profileBadgeSaveSuccess": "Insignia del perfil actualizada", + "profileBadgePickerAria": "Elegir insignia del perfil", + "profileBadgeClearAria": "Quitar insignia del perfil", "projectsLabel": "{count, plural, one {proyecto} other {proyectos}}", "downloadsLabel": "{count, plural, one {descarga} other {descargas}}", "publishedProjectsTooltip": "Proyectos publicados", diff --git a/pegasus/i18n/messages/pt.json b/pegasus/i18n/messages/pt.json index a2aa077..ed73879 100644 --- a/pegasus/i18n/messages/pt.json +++ b/pegasus/i18n/messages/pt.json @@ -994,7 +994,8 @@ "loadingFollowList": "Erro ao carregar a lista de seguidores", "profileImageTooLarge": "O arquivo é muito grande. O tamanho máximo é 10 MB", "invalidImageType": "Somente imagens JPG, PNG, WEBP e GIF são permitidas", - "profileImageUpload": "Erro ao enviar imagem do perfil" + "profileImageUpload": "Erro ao enviar imagem do perfil", + "profileBadgeUpdate": "Erro ao atualizar selo do perfil" }, "loginToSubscribe": "Faça login para se inscrever", "edit": "Editar", @@ -1015,6 +1016,9 @@ "newProfileBadge": "Este é o seu novo perfil!", "avatarUploadSuccess": "Avatar atualizado", "coverUploadSuccess": "Capa atualizada", + "profileBadgeSaveSuccess": "Selo do perfil atualizado", + "profileBadgePickerAria": "Escolher selo do perfil", + "profileBadgeClearAria": "Remover selo do perfil", "projectsLabel": "{count, plural, one {projeto} other {projetos}}", "downloadsLabel": "{count, plural, one {download} other {downloads}}", "publishedProjectsTooltip": "Projetos publicados", diff --git a/pegasus/i18n/messages/ru.json b/pegasus/i18n/messages/ru.json index 789ec40..d4acc3c 100644 --- a/pegasus/i18n/messages/ru.json +++ b/pegasus/i18n/messages/ru.json @@ -994,7 +994,8 @@ "loadingFollowList": "Ошибка загрузки списка подписок", "profileImageTooLarge": "Файл слишком большой. Максимум 10 МБ", "invalidImageType": "Можно загружать только JPG, PNG, WEBP и GIF", - "profileImageUpload": "Ошибка загрузки изображения профиля" + "profileImageUpload": "Ошибка загрузки изображения профиля", + "profileBadgeUpdate": "Ошибка обновления бейджа профиля" }, "loginToSubscribe": "Войдите, чтобы подписаться", "edit": "Редактировать", @@ -1015,6 +1016,9 @@ "newProfileBadge": "Это ваш новый профиль!", "avatarUploadSuccess": "Аватар обновлен", "coverUploadSuccess": "Обложка обновлена", + "profileBadgeSaveSuccess": "Бейдж профиля обновлен", + "profileBadgePickerAria": "Выбрать бейдж профиля", + "profileBadgeClearAria": "Убрать бейдж профиля", "projectsLabel": "{count, plural, one {проект} few {проекта} many {проектов} other {проекта}}", "downloadsLabel": "{count, plural, one {скачивание} few {скачивания} many {скачиваний} other {скачивания}}", "publishedProjectsTooltip": "Опубликованные проекты", diff --git a/pegasus/i18n/messages/tr.json b/pegasus/i18n/messages/tr.json index 6cdefb6..394a1b2 100644 --- a/pegasus/i18n/messages/tr.json +++ b/pegasus/i18n/messages/tr.json @@ -994,7 +994,8 @@ "loadingFollowList": "Takip listesi yüklenemedi", "profileImageTooLarge": "Dosya çok büyük. Maksimum boyut 10 MB", "invalidImageType": "Yalnızca JPG, PNG, WEBP ve GIF görsellerine izin verilir", - "profileImageUpload": "Profil görseli yüklenirken hata oluştu" + "profileImageUpload": "Profil görseli yüklenirken hata oluştu", + "profileBadgeUpdate": "Profil rozeti güncellenirken hata oluştu" }, "loginToSubscribe": "Abone olmak için giriş yapın", "edit": "Düzenle", @@ -1015,6 +1016,9 @@ "newProfileBadge": "Bu yeni profiliniz!", "avatarUploadSuccess": "Avatar güncellendi", "coverUploadSuccess": "Kapak güncellendi", + "profileBadgeSaveSuccess": "Profil rozeti güncellendi", + "profileBadgePickerAria": "Profil rozeti seç", + "profileBadgeClearAria": "Profil rozetini kaldır", "projectsLabel": "{count, plural, one {proje} other {proje}}", "downloadsLabel": "{count, plural, one {indirme} other {indirme}}", "publishedProjectsTooltip": "Yayınlanan projeler", diff --git a/pegasus/i18n/messages/uk.json b/pegasus/i18n/messages/uk.json index 0616f6c..edcbf3e 100644 --- a/pegasus/i18n/messages/uk.json +++ b/pegasus/i18n/messages/uk.json @@ -995,7 +995,8 @@ "loadingFollowList": "Помилка завантаження списку підписок", "profileImageTooLarge": "Файл завеликий. Максимум 10 МБ", "invalidImageType": "Можна завантажувати лише JPG, PNG, WEBP і GIF", - "profileImageUpload": "Помилка завантаження зображення профілю" + "profileImageUpload": "Помилка завантаження зображення профілю", + "profileBadgeUpdate": "Помилка оновлення значка профілю" }, "loginToSubscribe": "Увійдіть, щоб підписатися", "edit": "Редагувати", @@ -1016,6 +1017,9 @@ "newProfileBadge": "Це ваш новий профіль!", "avatarUploadSuccess": "Аватар оновлено", "coverUploadSuccess": "Обкладинку оновлено", + "profileBadgeSaveSuccess": "Значок профілю оновлено", + "profileBadgePickerAria": "Вибрати значок профілю", + "profileBadgeClearAria": "Прибрати значок профілю", "projectsLabel": "{count, plural, one {проєкт} few {проєкти} many {проєктів} other {проєкта}}", "downloadsLabel": "{count, plural, one {завантаження} few {завантаження} many {завантажень} other {завантаження}}", "publishedProjectsTooltip": "Опубліковані проєкти", diff --git a/pegasus/public/badges/hytalemodjam_2026_badge.png b/pegasus/public/badges/hytalemodjam_2026_badge.png index 9a8f537..01aa1fa 100644 Binary files a/pegasus/public/badges/hytalemodjam_2026_badge.png and b/pegasus/public/badges/hytalemodjam_2026_badge.png differ diff --git a/pegasus/styles/Subsite.css b/pegasus/styles/Subsite.css index b5fbf65..9fe795e 100644 --- a/pegasus/styles/Subsite.css +++ b/pegasus/styles/Subsite.css @@ -230,11 +230,75 @@ background: transparent; cursor: pointer; } +.profile-hero__badge-picker { + position: relative; + display: inline-flex; +} .profile-hero__verified img { width: 24px; height: 24px; object-fit: contain; } +.profile-hero__badge-placeholder { + cursor: pointer; + border-radius: 999px; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; +} +.profile-hero__badge-placeholder:hover { + background: var(--theme-color-background-content); +} +.profile-hero__badge-placeholder svg { + fill: none; + color: var(--theme-color-text-secondary); +} +.profile-hero__badge-popover { + --width: auto; + --position: absolute; + --top: 34px; + --left: 50%; + --right: auto; + z-index: 40; + padding: 8px; + transform: translateX(-50%); +} +.profile-badge-picker__grid { + display: grid; + grid-template-columns: repeat(4, 36px); + gap: 6px; +} +.profile-badge-picker__option { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 8px; + background: var(--theme-color-background); + color: var(--theme-color-text-secondary); + cursor: pointer; +} +.profile-badge-picker__option img { + width: 24px; + height: 24px; + object-fit: contain; +} +.profile-badge-picker__option--selected { + background: var(--theme-color-popover-item-bg-active); +} +.profile-badge-picker__option:disabled { + cursor: default; + opacity: 0.64; +} +@media (hover: hover) { + .profile-badge-picker__option:not(:disabled):hover { + background: var(--theme-color-popover-item-bg-active-hover); + } +} .profile-hero__description { max-width: 720px; color: var(--theme-color-text-primary); diff --git a/pegasus/utils/profileBadges.js b/pegasus/utils/profileBadges.js new file mode 100644 index 0000000..82f8aa9 --- /dev/null +++ b/pegasus/utils/profileBadges.js @@ -0,0 +1,45 @@ +export const PROFILE_BADGES = [ + { + code: "creator_badge", + icon: "/badges/creator.webp", + }, + { + code: "staff", + icon: "/badges/staff_badge.png", + }, + { + code: "hytalemodjam_2026", + icon: "/badges/hytalemodjam_2026_badge.png", + }, + { + code: "first_project", + icon: "/badges/first_project_badge.png", + }, + { + code: "downloads_100", + icon: "/badges/100_downloads_badge.png", + }, + { + code: "downloads_500", + icon: "/badges/500_downloads_badge.png", + }, + { + code: "downloads_10000", + icon: "/badges/10000_downloads_badge.png", + }, +]; + +export const PROFILE_BADGE_BY_CODE = PROFILE_BADGES.reduce((acc, badge) => { + acc[badge.code] = badge; + return acc; +}, {}); + +export const getProfileBadgeCode = (user) => { + const code = user?.activeProfileBadge ?? user?.active_profile_badge ?? null; + return PROFILE_BADGE_BY_CODE[code] ? code : null; +}; + +export const getProfileBadge = (userOrCode) => { + const code = typeof userOrCode === "string" ? userOrCode : getProfileBadgeCode(userOrCode); + return PROFILE_BADGE_BY_CODE[code] || null; +}; \ No newline at end of file