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 ? (
+

+ ) : (
+
+ )}
- const buttons = [];
- for(let i = startPage; i <= endPage; i++) {
- buttons.push(
-
- {i}
-
- );
- }
+ {isOwnProfile && (
+
{t("newProfileBadge")}
+ )}
- return buttons;
- };
+ {isOwnProfile && (
+
+ )}
+
- return (
- <>
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {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 && (
-
+
-
-
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 (
<>