diff --git a/cronus/routes/v1/auth.js b/cronus/routes/v1/auth.js index e991e90..9706eb5 100644 --- a/cronus/routes/v1/auth.js +++ b/cronus/routes/v1/auth.js @@ -907,7 +907,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, 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, social_links FROM users WHERE id = ?", [req.user.id]); if(!users.length) { return res.status(404).json({ message: "User not found" }); diff --git a/cronus/routes/v1/moderation.js b/cronus/routes/v1/moderation.js index e22ec99..3f520ec 100644 --- a/cronus/routes/v1/moderation.js +++ b/cronus/routes/v1/moderation.js @@ -4,6 +4,7 @@ const { clickhouse } = require("../../config/clickhouse"); const auth = require("../../middleware/auth"); const { sanitizePlainText } = require("../../utils/sanitize"); const { fanoutVersionReleaseNotifications, sendVersionApprovedOwnerNotification } = require("../../utils/versionNotifications"); +const { awardFirstApprovedProjectAchievement } = require("../../utils/achievements"); const router = express.Router(); const isModeratorRole = (role) => role === "admin" || role === "moderator"; @@ -582,7 +583,7 @@ router.post("/:id/moderate", auth, async (req, res) => { if(status === "approved") { try { const [projectRows] = await db.query( - `SELECT p.slug, p.title, p.summary, p.icon_url, u.username + `SELECT p.id, p.user_id, p.slug, p.title, p.summary, p.icon_url, u.username FROM projects p LEFT JOIN users u ON p.user_id = u.id WHERE p.id = ? @@ -595,6 +596,12 @@ router.post("/:id/moderate", auth, async (req, res) => { if(!project) { console.warn(`Project ${id} not found after approval, skipping published-mod notification`); } else { + await awardFirstApprovedProjectAchievement(db, { + projectId: project.id, + userId: project.user_id, + awardedByUserId: moderatorId, + }); + await fetch("https://api.hytalemodd.ing/published-mod", { method: "POST", headers: { diff --git a/cronus/routes/v1/projects.js b/cronus/routes/v1/projects.js index c864a92..2aafae8 100644 --- a/cronus/routes/v1/projects.js +++ b/cronus/routes/v1/projects.js @@ -18,6 +18,7 @@ const { getCacheJson, setCacheJson, deleteCacheByPattern } = require("../../util const { getProjectCacheVersion, bumpProjectCacheVersion, bumpProjectCacheVersionById, shouldSkipProjectCacheBump } = require("../../utils/projectCache"); const { fanoutVersionReleaseNotifications } = require("../../utils/versionNotifications"); const { notifyArgusAboutVersion } = require("../../utils/argus"); +const { awardFirstApprovedProjectAchievement, awardProjectDownloadAchievements } = require("../../utils/achievements"); const router = express.Router(); const parseJsonArrayField = (value) => { @@ -2609,6 +2610,14 @@ const trackProjectDownload = async ({ slug, versionId, ipAddress, countryCode, u [projectSlug] ); + if(shouldCount) { + await awardProjectDownloadAchievements(db, { + projectId: project[0].id, + userId: project[0].user_id, + totalDownloads, + }); + } + return { status: 200, body: { @@ -2682,6 +2691,22 @@ router.post("/:id/moderate", auth, async (req, res) => { try { await db.query("UPDATE projects SET status = ? WHERE id = ?", [status, id]); await bumpProjectCacheVersionById(db, id); + + if(status === "approved") { + const [projectRows] = await db.query( + "SELECT id, user_id FROM projects WHERE id = ? LIMIT 1", + [id] + ); + + if(projectRows[0]) { + await awardFirstApprovedProjectAchievement(db, { + projectId: projectRows[0].id, + userId: projectRows[0].user_id, + awardedByUserId: req.user.id, + }); + } + } + res.json({ success: true }); } catch (error) { console.error("Error moderating project:", error); diff --git a/cronus/routes/v1/users.js b/cronus/routes/v1/users.js index ca9f8bd..4273e98 100644 --- a/cronus/routes/v1/users.js +++ b/cronus/routes/v1/users.js @@ -20,18 +20,18 @@ const storage = multer.diskStorage({ }); const fileFilter = (req, file, cb) => { - const allowedTypes = ["image/jpeg", "image/png", "image/gif"]; + const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; if(allowedTypes.includes(file.mimetype)) { cb(null, true); } else { - cb(new Error("Invalid file type. Only images (JPEG, PNG, GIF) are allowed."), false); + cb(new Error("Invalid file type. Only images (JPEG, PNG, GIF, WEBP) are allowed."), false); } }; const upload = multer({ storage, limits: { - fileSize: 20 * 1024 * 1024, + fileSize: 10 * 1024 * 1024, }, fileFilter, }); @@ -132,15 +132,20 @@ const convertImageToWebp = async (file) => { }; }; -router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) => { +router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), async (req, res) => { const { username, slug, description, social_links } = req.body; let avatarFile = req.files?.avatar?.[0]; + let coverFile = req.files?.cover?.[0]; try { if(avatarFile) { avatarFile = await convertImageToWebp(avatarFile); } + if(coverFile) { + coverFile = await convertImageToWebp(coverFile); + } + const [currentUserRows] = await db.query("SELECT slug FROM users WHERE id = ? LIMIT 1", [req.user.id]); const currentUser = currentUserRows[0]; @@ -158,6 +163,10 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) => updates.avatar = `https://media.modifold.com/${avatarFile.filename}`; } + if(coverFile) { + updates.cover = `https://media.modifold.com/${coverFile.filename}`; + } + if(description !== undefined) { updates.description = description ? sanitizePlainText(description, { preserveNewlines: true }) : ""; } @@ -192,7 +201,7 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) => await db.query("UPDATE users SET ? WHERE id = ?", [updates, req.user.id]); - const [updatedUser] = await db.query("SELECT id, username, slug, avatar, 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, social_links FROM users WHERE id = ?", [req.user.id]); if(updatedUser[0]?.social_links) { updatedUser[0].social_links = JSON.parse(updatedUser[0].social_links); @@ -369,6 +378,12 @@ router.get("/:username/projects", async (req, res) => { try { const { username } = req.params; const { page = 1, limit = 20 } = req.query; + const sort = ["downloads", "recent", "updated"].includes(req.query.sort) ? req.query.sort : "downloads"; + const orderBy = { + downloads: "p.downloads DESC, p.updated_at DESC", + recent: "p.created_at DESC", + updated: "p.updated_at DESC", + }[sort]; if(isNaN(page) || page < 1) { return res.status(400).json({ message: "Invalid page number" }); @@ -398,7 +413,7 @@ router.get("/:username/projects", async (req, res) => { WHERE pm.project_id = p.id AND member_user.slug = ? ) ) - ORDER BY p.downloads DESC + ORDER BY ${orderBy} LIMIT ? OFFSET ? `; @@ -458,6 +473,7 @@ router.get("/:username/projects", async (req, res) => { totalProjects: Number(total || 0), totalDownloads: Number(totalDownloads || 0), currentPage: Number(page), + sort, }); } catch (error) { console.error("Error fetching user projects:", error); @@ -570,9 +586,55 @@ router.get("/:username/organizations", async (req, res) => { } }); +router.get("/:username/achievements", async (req, res) => { + try { + const { username } = req.params; + const [userRows] = await db.query("SELECT id FROM users WHERE slug = ? LIMIT 1", [username]); + if(!userRows.length) { + return res.status(404).json({ message: "User not found" }); + } + + const [rows] = await db.query( + `SELECT + ua.id, + ua.awarded_at, + ua.context_type, + ua.context_id, + ua.note, + a.code, + a.name, + a.description, + a.icon_url + FROM user_achievements ua + INNER JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = ? + AND a.is_active = 1 + ORDER BY ua.id ASC`, + [userRows[0].id] + ); + + return res.json({ + achievements: rows.map((row) => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + icon_url: row.icon_url, + awarded_at: Number(row.awarded_at || 0), + context_type: row.context_type || null, + context_id: row.context_id || null, + note: row.note || null, + })), + }); + } catch (error) { + console.error("Error fetching user achievements:", error); + return res.status(500).json({ message: "Error fetching user achievements", error: error.message }); + } +}); + router.get("/:username", async (req, res) => { try { - const [user] = await db.query("SELECT id, username, slug, description, avatar, 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, social_links FROM users WHERE slug = ?", [req.params.username]); if(!user.length) { return res.status(404).json({ message: "User not found" }); @@ -589,6 +651,7 @@ router.get("/:username", async (req, res) => { slug: user[0].slug, description: user[0].description, avatar: user[0].avatar, + cover: user[0].cover, created_at: user[0].created_at, isVerified: user[0].isVerified, isRole: user[0].isRole, diff --git a/cronus/utils/achievements.js b/cronus/utils/achievements.js new file mode 100644 index 0000000..0a2402e --- /dev/null +++ b/cronus/utils/achievements.js @@ -0,0 +1,135 @@ +const ACHIEVEMENT_CODES = { + FIRST_PROJECT: "first_project", + DOWNLOADS_100: "downloads_100", + DOWNLOADS_500: "downloads_500", + DOWNLOADS_10000: "downloads_10000", +}; + +const DOWNLOAD_ACHIEVEMENT_THRESHOLDS = [ + { minDownloads: 100, code: ACHIEVEMENT_CODES.DOWNLOADS_100 }, + { minDownloads: 500, code: ACHIEVEMENT_CODES.DOWNLOADS_500 }, + { minDownloads: 10000, code: ACHIEVEMENT_CODES.DOWNLOADS_10000 }, +]; + +const getAchievementLockName = (userId, code) => { + return `achievement:${String(userId).slice(0, 32)}:${code}`; +}; + +const withAchievementLock = async (db, userId, code, callback) => { + if(typeof db.getConnection !== "function") { + return callback(db); + } + + const connection = await db.getConnection(); + + try { + const [[lockRow]] = await connection.query( + "SELECT GET_LOCK(?, 5) AS lockAcquired", + [getAchievementLockName(userId, code)] + ); + + if(Number(lockRow?.lockAcquired || 0) !== 1) { + return false; + } + + try { + return await callback(connection); + } finally { + await connection.query("SELECT RELEASE_LOCK(?)", [getAchievementLockName(userId, code)]); + } + } finally { + connection.release(); + } +}; + +const awardAchievementToUser = async (db, { userId, code, contextType = null, contextId = null, awardedByUserId = null, note = null }) => { + if(!userId || !code) { + return false; + } + + return withAchievementLock(db, userId, code, async (connection) => { + const [result] = await connection.query( + `INSERT IGNORE INTO user_achievements + (user_id, achievement_id, awarded_at, awarded_by_user_id, context_type, context_id, note) + SELECT ?, a.id, ?, ?, ?, ?, ? + FROM achievements a + LEFT JOIN user_achievements ua + ON ua.user_id = ? + AND ua.achievement_id = a.id + WHERE a.code = ? + AND a.is_active = 1 + AND ua.id IS NULL + LIMIT 1`, + [ + userId, + Math.floor(Date.now() / 1000), + awardedByUserId || null, + contextType, + contextId ? String(contextId) : null, + note, + userId, + code, + ] + ); + + return result.affectedRows > 0; + }); +}; + +const awardFirstApprovedProjectAchievement = async (db, { projectId, userId, awardedByUserId = null }) => { + if(!projectId || !userId) { + return false; + } + + const [[{ approvedProjects }]] = await db.query( + "SELECT COUNT(*) AS approvedProjects FROM projects WHERE user_id = ? AND status = 'approved'", + [userId] + ); + + if(Number(approvedProjects || 0) !== 1) { + return false; + } + + return awardAchievementToUser(db, { + userId, + code: ACHIEVEMENT_CODES.FIRST_PROJECT, + contextType: "project", + contextId: projectId, + awardedByUserId, + }); +}; + +const awardProjectDownloadAchievements = async (db, { projectId, userId, totalDownloads }) => { + if(!projectId || !userId) { + return []; + } + + const downloads = Math.max(0, Number(totalDownloads) || 0); + const awardedCodes = []; + + for(const threshold of DOWNLOAD_ACHIEVEMENT_THRESHOLDS) { + if(downloads < threshold.minDownloads) { + continue; + } + + const awarded = await awardAchievementToUser(db, { + userId, + code: threshold.code, + contextType: "project", + contextId: projectId, + }); + + if(awarded) { + awardedCodes.push(threshold.code); + } + } + + return awardedCodes; +}; + +module.exports = { + ACHIEVEMENT_CODES, + awardAchievementToUser, + awardFirstApprovedProjectAchievement, + awardProjectDownloadAchievements, +}; \ No newline at end of file diff --git a/pegasus/app/user/[username]/page.js b/pegasus/app/user/[username]/page.js index 54942de..425f8d3 100644 --- a/pegasus/app/user/[username]/page.js +++ b/pegasus/app/user/[username]/page.js @@ -41,6 +41,8 @@ export default async function Page({ params, searchParams }) { const authToken = cookieStore.get("authToken")?.value; const requestedPage = Number(resolvedSearchParams?.page); const currentProjectsPage = Number.isFinite(requestedPage) && requestedPage > 0 ? Math.trunc(requestedPage) : 1; + const requestedSort = typeof resolvedSearchParams?.sort === "string" ? resolvedSearchParams.sort : ""; + const currentProjectsSort = ["downloads", "recent", "updated"].includes(requestedSort) ? requestedSort : "downloads"; const userRes = await fetch(`${apiBase}/users/${username}`, { headers: { Accept: "application/json" }, @@ -66,21 +68,26 @@ export default async function Page({ params, searchParams }) { const projectsFetchOptions = { headers: { Accept: "application/json" }, - next: { revalidate: 60, tags: [`user:${username}:projects:${currentProjectsPage}`] }, + next: { revalidate: 60, tags: [`user:${username}:projects:${currentProjectsPage}:${currentProjectsSort}`] }, }; - const [banRes, projectsRes, organizationsRes] = await Promise.all([ + const [banRes, projectsRes, organizationsRes, achievementsRes] = await Promise.all([ fetch(`${apiBase}/bans/${user.id}`, banFetchOptions), - fetch(`${apiBase}/users/${username}/projects?page=${currentProjectsPage}&limit=20`, projectsFetchOptions), + 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`] }, }), + fetch(`${apiBase}/users/${username}/achievements`, { + headers: { Accept: "application/json" }, + next: { revalidate: 60, tags: [`user:${username}:achievements`] }, + }), ]); const banData = banRes.ok ? await banRes.json() : { isBanned: false }; const projectsData = projectsRes.ok ? await projectsRes.json() : { projects: [], totalPages: 1, currentPage: currentProjectsPage }; const organizationsData = organizationsRes.ok ? await organizationsRes.json() : { organizations: [] }; + const achievementsData = achievementsRes.ok ? await achievementsRes.json() : { achievements: [] }; let isSubscribed = false; let subscriptionId = null; @@ -112,8 +119,10 @@ export default async function Page({ params, searchParams }) { totalProjects={projectsData.totalProjects} totalDownloads={projectsData.totalDownloads} organizations={organizationsData.organizations || []} + achievements={achievementsData.achievements || []} currentPage={projectsData.currentPage || currentProjectsPage} totalPages={projectsData.totalPages || 1} + currentSort={projectsData.sort || currentProjectsSort} /> ); } \ No newline at end of file diff --git a/pegasus/components/layout/Header.js b/pegasus/components/layout/Header.js index 77c99b2..477c0f0 100644 --- a/pegasus/components/layout/Header.js +++ b/pegasus/components/layout/Header.js @@ -294,12 +294,6 @@ export default function Header({ authToken }) {
- {(user?.isRole === "admin" || user?.isRole === "moderator") && ( - - - - )} -
{user?.username}
{user?.isVerified === 1 && ( diff --git a/pegasus/components/layout/HeaderMobile.js b/pegasus/components/layout/HeaderMobile.js index c77e453..e817c70 100644 --- a/pegasus/components/layout/HeaderMobile.js +++ b/pegasus/components/layout/HeaderMobile.js @@ -278,12 +278,6 @@ export default function HeaderMobile({ authToken }) {
- {(user?.isRole === "admin" || user?.isRole === "moderator") && ( - - - - )} -
{user?.username}
{user?.isVerified === 1 && ( diff --git a/pegasus/components/pages/ProfilePage.js b/pegasus/components/pages/ProfilePage.js index 821c8f4..58c6221 100644 --- a/pegasus/components/pages/ProfilePage.js +++ b/pegasus/components/pages/ProfilePage.js @@ -6,19 +6,23 @@ import axios from "axios"; import { toast } from "react-toastify"; import Link from "next/link"; import ProjectCard from "../project/ProjectCard"; -import { useTranslations, useLocale } from "next-intl"; +import { useTranslations } from "next-intl"; 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"; -import Tooltip from "@/components/ui/Tooltip"; +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"); } const DESCRIPTION_URL_RE = /\bhttps?:\/\/[^\s<]+/gi; +const PROFILE_IMAGE_MAX_SIZE = 10 * 1024 * 1024; const getSafeExternalUrl = (value) => { if(typeof value !== "string") { @@ -80,58 +84,34 @@ const renderDescription = (desc) => { }); }; -const getDateFormatOptions = (date) => { - const now = new Date(); - const options = { - day: "numeric", - month: "long", - }; - - if(date.getFullYear() !== now.getFullYear()) { - options.year = "numeric"; - } - - return options; -}; - -const formatJoinedDate = (timestamp, locale) => { - const date = new Date(timestamp); - return new Intl.DateTimeFormat(locale, getDateFormatOptions(date)).format(date); -}; - -const formatJoinedTooltip = (timestamp, locale) => { - const date = new Date(timestamp); - - return new Intl.DateTimeFormat(locale, { - ...getDateFormatOptions(date), - hour: "2-digit", - minute: "2-digit", - }).format(date); -}; - -const formatFullNumber = (num, locale) => new Intl.NumberFormat(locale).format(Math.max(0, Number(num) || 0)); - const getProjectDownloadsTotal = (projects) => projects.reduce((sum, project) => sum + Math.max(0, Number(project?.downloads) || 0), 0); -export default function ProfilePage({ user, isBanned, isSubscribed: initialSubscribed, subscriptionId: initialSubId, authToken, projects = [], totalProjects = null, totalDownloads = null, organizations = [], currentPage = 1, totalPages = 1 }) { +const isAllowedProfileImageFile = (file) => file?.type === "image/jpeg" || file?.type === "image/png" || file?.type === "image/webp" || file?.type === "image/gif"; + +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 tLinks = useTranslations("Organizations.settings.links"); - const locale = useLocale(); - const { isLoggedIn, user: currentUser } = useAuth(); + 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 [isModifoldVerifiedModalOpen, setIsModifoldVerifiedModalOpen] = useState(false); const [activeFollowModal, setActiveFollowModal] = useState(null); + const [uploadingProfileImage, setUploadingProfileImage] = useState(null); const popoverRef = useRef(null); const buttonRef = useRef(null); + const avatarInputRef = useRef(null); + const coverInputRef = useRef(null); const { lightboxOpen, lightboxImage, closeLightbox, getLightboxTriggerProps } = useImageLightbox(); useEffect(() => { setActiveFollowModal(null); }, [user.slug]); + useEffect(() => { + setProfileUser(user); + }, [user]); + useEffect(() => { const handleClickOutside = (event) => { if(isPopoverOpen && popoverRef.current && !popoverRef.current.contains(event.target) && buttonRef.current && !buttonRef.current.contains(event.target)) { @@ -159,7 +139,7 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setIsSubscribed(false); setSubscriptionId(null); } else { - const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE}/subscriptions`, { userId: user.id }, { headers: { Authorization: `Bearer ${authToken}` } }); + const res = await axios.post(`${process.env.NEXT_PUBLIC_API_BASE}/subscriptions`, { userId: profileUser.id }, { headers: { Authorization: `Bearer ${authToken}` } }); setIsSubscribed(true); setSubscriptionId(res.data.subscriptionId); } @@ -172,6 +152,53 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setIsPopoverOpen((prev) => !prev); }; + const handleProfileImageChange = async (field, event) => { + if(uploadingProfileImage) { + return; + } + + const file = event.target.files?.[0] || null; + event.target.value = ""; + + if(!file) { + return; + } + + if(!isAllowedProfileImageFile(file)) { + toast.error(t("errors.invalidImageType")); + return; + } + + if(file.size > PROFILE_IMAGE_MAX_SIZE) { + toast.error(t("errors.profileImageTooLarge")); + return; + } + + if(!authToken) { + toast.error(t("errors.profileImageUpload")); + return; + } + + const data = new FormData(); + data.append(field, file); + + try { + setUploadingProfileImage(field); + const res = await axios.put(`${process.env.NEXT_PUBLIC_API_BASE}/users/me`, data, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + const updatedUser = res.data || {}; + + setProfileUser((prev) => ({ ...prev, ...updatedUser })); + setUser((prev) => ({ ...prev, ...updatedUser })); + toast.success(t(field === "cover" ? "coverUploadSuccess" : "avatarUploadSuccess")); + } catch (err) { + toast.error(err.response?.data?.message || t("errors.profileImageUpload")); + } finally { + setUploadingProfileImage(null); + } + }; + const handleOpenFollowModal = (type) => { const count = type === "subscribers" ? countSubs : countUserSubs; if(count < 1) { @@ -181,66 +208,93 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc setActiveFollowModal(type); }; - const authorAva = isBanned ? "https://leonardo.osnova.io/8e95d9d3-932c-5f85-8b53-43da2e8ccaeb/-/format/webp/" : user.avatar || "https://cdn.modifold.com/default_avatar.png"; - const authorTitle = isBanned ? t("accountFrozen") : user.username; - const profileDescription = typeof user.description === "string" ? user.description.trim() : ""; + const authorAva = isBanned ? "https://leonardo.osnova.io/8e95d9d3-932c-5f85-8b53-43da2e8ccaeb/-/format/webp/" : profileUser.avatar || "https://cdn.modifold.com/default_avatar.png"; + const authorCover = isBanned ? null : profileUser.cover || null; + const authorTitle = isBanned ? t("accountFrozen") : profileUser.username; + const profileDescription = typeof profileUser.description === "string" ? profileUser.description.trim() : ""; const desc = isBanned ? null : (renderDescription(profileDescription) || t("defaultDescription")); - const countSubs = user.subscribers || 0; - const countUserSubs = user.subscriptions || 0; + const countSubs = profileUser.subscribers || 0; + const countUserSubs = profileUser.subscriptions || 0; const publishedProjectsCount = Math.max(0, Number(totalProjects ?? projects.length) || 0); const authorDownloadsCount = Math.max(0, Number(totalDownloads ?? getProjectDownloadsTotal(projects)) || 0); - const isOwnProfile = isLoggedIn && user.id === currentUser.id; + const isOwnProfile = isLoggedIn && profileUser.id === currentUser?.id; + const hasSocialLinks = Boolean( + getSafeExternalUrl(profileUser?.social_links?.discord) || + getSafeExternalUrl(profileUser?.social_links?.x) || + getSafeExternalUrl(profileUser?.social_links?.telegram) || + getSafeExternalUrl(profileUser?.social_links?.youtube) + ); + const hasSidebar = (!isBanned && achievements.length > 0) || hasSocialLinks || organizations.length > 0; - const getPageButtons = () => { - const maxButtons = 10; - let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); - let endPage = Math.min(totalPages, startPage + maxButtons - 1); - if(endPage - startPage + 1 < maxButtons) { - startPage = Math.max(1, endPage - maxButtons + 1); - } + return ( + <> +
+ handleProfileImageChange("avatar", event)} style={{ display: "none" }} /> + handleProfileImageChange("cover", event)} style={{ display: "none" }} /> + +
+
+
+
+ {authorCover ? ( + + ) : ( + - return ( - <> -
-
-
-
-
-
-
-
+
+
+
+
{authorTitle}
+ + {isOwnProfile && ( +
avatarInputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(event) => { + if(event.key === "Enter" || event.key === " ") { + event.preventDefault(); + avatarInputRef.current?.click(); + } + }}> + + + + +
+ )}
{isLoggedIn && ( -
- {currentUser.id === user.id ? ( - {t("edit")} +
+ {currentUser?.id === profileUser.id ? ( + {t("edit")} ) : ( - <> - - - - + )} {!isOwnProfile && ( -
+
+ )} + + + +
- +

{desc}

- {user.isVerified === 1 && ( - setIsVerifiedModalOpen(true)} src="/badges/creator.webp" alt="Verified" style={{ width: "20px", cursor: "pointer" }} /> - )} - - - +
+
-

{desc}

+
+ -
-
- - {t("joined")} {formatJoinedDate(user.created_at, locale)} - + {projects.length > 0 ? ( + <> +
+ {projects.map((project) => ( + + ))}
-
- -
- - - - - - - - - - + + + ) : ( +
+

{t("noProjects")}

-
-
- - {(user?.social_links?.discord || user?.social_links?.x || user?.social_links?.telegram || user?.social_links?.youtube) && ( -
-

{t("linksTitle")}

- -
+
- {user?.social_links?.telegram && ( -
  • - - - - - - {tLinks("fields.telegram")} + {hasSidebar && ( +
  • - )} + - {user?.social_links?.youtube && ( -
  • - - - - - + {organizations.length > 0 && ( +
    +

    {t("organizationsTitle")}

    - {tLinks("fields.youtube")} - - -
    -
  • - )} - -
    - )} - - {organizations.length > 0 && ( -
    -

    {t("organizationsTitle")}

    - -
    - {organizations.map((organization) => ( - - - {organization.name} +
    + {organizations.map((organization) => ( + + {organization.name} + {organization.name} - - ))} -
    -
    - )} - - -
    - -
    - {projects.length > 0 ? ( -
    - {projects.map((project) => ( - - ))} -
    - ) : ( -
    -

    {t("noProjects")}

    -
    - )} - - {totalPages > 1 && ( -
    - {currentPage === 1 ? ( - - ) : ( - - {t("previous")} - - )} - - {getPageButtons()} - - {currentPage === totalPages ? ( - - ) : ( - - {t("next")} - - )} -
    - )} -
    + ))} +
    + + )} + + )}
    + + setActiveFollowModal(null)} - username={user.slug} + username={profileUser.slug} type={activeFollowModal} /> @@ -507,28 +428,6 @@ export default function ProfilePage({ user, isBanned, isSubscribed: initialSubsc - - setIsModifoldVerifiedModalOpen(false)} className="modal active" overlayClassName="modal-overlay"> -
    -
    - -
    - -
    -
    - - - - -

    {t("verifiedModal.officialBadgeText")}

    -
    -
    -
    -
    ); } \ No newline at end of file diff --git a/pegasus/components/pages/SettingsBlogPage.js b/pegasus/components/pages/SettingsBlogPage.js index 1a5b02b..cc39894 100644 --- a/pegasus/components/pages/SettingsBlogPage.js +++ b/pegasus/components/pages/SettingsBlogPage.js @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { useAuth } from "../providers/AuthProvider"; import { useRouter } from "next/navigation"; import axios from "axios"; @@ -20,7 +20,6 @@ const getEmptySocialLinks = () => ({ const getInitialFormData = (user) => ({ username: user?.username || "", slug: user?.slug || "", - avatar: null, description: user?.description || "", social_links: user?.social_links || getEmptySocialLinks(), }); @@ -49,9 +48,6 @@ export default function SettingsBlogPage({ initialUser = null }) { const [savedSettings, setSavedSettings] = useState(() => getSettingsSnapshot(getInitialFormData(effectiveUser))); const [isSaving, setIsSaving] = useState(false); - const [previewAvatar, setPreviewAvatar] = useState(effectiveUser?.avatar || ""); - const avatarInputRef = useRef(null); - useEffect(() => { if(!isLoggedIn && !initialUser) { router.push("/403"); @@ -65,7 +61,6 @@ export default function SettingsBlogPage({ initialUser = null }) { setFormData(getInitialFormData(effectiveUser)); setSavedSettings(getSettingsSnapshot(getInitialFormData(effectiveUser))); - setPreviewAvatar(effectiveUser.avatar || ""); }, [effectiveUser]); const handleInputChange = (e) => { @@ -83,23 +78,6 @@ export default function SettingsBlogPage({ initialUser = null }) { } }; - const handleFileChange = (e) => { - const { name, files } = e.target; - const file = files[0]; - - if(file && file.size > 20 * 1024 * 1024) { - toast.error(t("errors.fileTooLarge")); - return; - } - - setFormData((prev) => ({ ...prev, [name]: file })); - - const previewUrl = URL.createObjectURL(file); - if(name === "avatar") { - setPreviewAvatar(previewUrl); - } - }; - const handleSubmit = async (e) => { if(e) { e.preventDefault(); @@ -119,10 +97,6 @@ export default function SettingsBlogPage({ initialUser = null }) { data.append("username", formData.username); data.append("slug", validation.normalized); - if(formData.avatar) { - data.append("avatar", formData.avatar); - } - data.append("description", formData.description); data.append("social_links", JSON.stringify(formData.social_links)); @@ -132,7 +106,7 @@ export default function SettingsBlogPage({ initialUser = null }) { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` }, }); - setUser(res.data); + setUser((prev) => ({ ...prev, ...res.data })); setSavedSettings(getSettingsSnapshot(formData)); toast.success(t("success")); } catch (err) { @@ -142,49 +116,25 @@ export default function SettingsBlogPage({ initialUser = null }) { } }; - const handleAvatarOverlayClick = () => { - avatarInputRef.current?.click(); - }; - if(!isLoggedIn && !effectiveUser) { return null; } const isTextSettingsDirty = !areSnapshotsEqual(getSettingsSnapshot(formData), savedSettings); - const isAvatarDirty = !!formData.avatar; - const isDirty = isTextSettingsDirty || isAvatarDirty; + const isDirty = isTextSettingsDirty; const handleReset = () => { setFormData((prev) => ({ ...prev, ...savedSettings, social_links: { ...savedSettings.social_links }, - avatar: null, })); - setPreviewAvatar(effectiveUser?.avatar || ""); }; return ( <>
    -
    -
    -
    - {previewAvatar && {t("avatarPreviewAlt")}} -
    - -
    - - - - -
    -
    -
    - - -

    {t("username")}