feat: user profile customization with banner, accent color, bio, and full profile modal

Add banner image, accent color, and bio fields to user profiles with
full-stack support: schema migration, API validation (hex color format,
190-char bio limit), sanitizeUser propagation, and new GET /users/:id/mutuals
endpoint. Rewrite AccountPanel with live preview card, avatar/banner upload
via ImageCropModal, 16-preset accent color picker, and bio editor. Enhance
UserProfilePopout with banner display, accent-colored names, bio rendering,
and mutual counts. Add new UserProfileModal with About/Mutual Friends/Mutual
Spaces tabs and friend action buttons.
This commit is contained in:
Jannis Braun
2026-03-10 17:08:29 +01:00
parent 88bfc17580
commit 003eff2268
16 changed files with 1066 additions and 72 deletions
+67 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate, verifyPassword } from '../utils/auth.js';
import { connectionManager } from '../ws/handler.js';
@@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus, status, replicatedInstances, homeUserId } = request.body;
const { displayName, avatar, banner, accentColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
@@ -59,6 +59,38 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
updateData.avatar = avatar;
}
if (banner !== undefined) {
if (banner && typeof banner === 'string' && banner.trim().length > 0) {
updateData.banner = banner.trim();
} else {
updateData.banner = null;
}
}
if (accentColor !== undefined) {
if (accentColor && typeof accentColor === 'string' && accentColor.trim().length > 0) {
const hex = accentColor.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
return reply.code(400).send({ error: 'Accent color must be a valid hex color (e.g. #ff0000)', statusCode: 400 });
}
updateData.accentColor = hex;
} else {
updateData.accentColor = null;
}
}
if (bio !== undefined) {
if (bio && typeof bio === 'string') {
const trimmed = bio.trim();
if (trimmed.length > 190) {
return reply.code(400).send({ error: 'Bio must be 190 characters or less', statusCode: 400 });
}
updateData.bio = trimmed || null;
} else {
updateData.bio = null;
}
}
if (customStatus !== undefined) {
if (customStatus !== null && typeof customStatus === 'string') {
const trimmed = customStatus.trim();
@@ -149,4 +181,37 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(sanitizeUser(user));
});
app.get<{ Params: { id: string } }>('/api/users/:id/mutuals', { preHandler: authenticate }, async (request, reply) => {
const { id: targetId } = request.params;
const myId = request.userId;
const db = getDb();
// Mutual friends: users who are friends with both me and the target
const myFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, myId)).all();
const targetFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, targetId)).all();
const myFriendIds = new Set(myFriendRows.map((f) => f.friendId));
const targetFriendIds = new Set(targetFriendRows.map((f) => f.friendId));
const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id));
const mutualFriends = mutualFriendIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(sanitizeUser)
: [];
// Mutual spaces: spaces both me and the target are members of
const myMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, myId)).all();
const targetMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, targetId)).all();
const mySpaceIds = new Set(myMemberships.map((m) => m.spaceId));
const targetSpaceIds = new Set(targetMemberships.map((m) => m.spaceId));
const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id));
const mutualSpaces = mutualSpaceIds.length > 0
? db.select({ id: schema.spaces.id, name: schema.spaces.name, icon: schema.spaces.icon })
.from(schema.spaces)
.where(inArray(schema.spaces.id, mutualSpaceIds))
.all()
: [];
return reply.code(200).send({ mutualFriends, mutualSpaces });
});
}