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:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user