feat: user-choosable avatar colors with settings picker
Add avatarColor as a stored, user-selectable field (mint, sky, lavender, coral, rose, teal, amber). Randomly assigned on registration, changeable in profile settings. Existing users keep hash-based fallback until they choose a color. Includes DB migration, API validation, gradient map, live preview in settings, and banner fallback integration.
This commit is contained in:
@@ -5,6 +5,7 @@ import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { config } from '../config.js';
|
||||
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -95,6 +96,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const userCount = db.select().from(schema.users).all().length;
|
||||
const isFirstUser = userCount === 0 && !homeInstance;
|
||||
|
||||
const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
||||
|
||||
db.insert(schema.users).values({
|
||||
id: userId,
|
||||
username: trimmedUsername,
|
||||
@@ -104,6 +107,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
isAdmin: isFirstUser ? 1 : 0,
|
||||
homeInstance: homeInstance || null,
|
||||
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
|
||||
avatarColor,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate, verifyPassword } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -38,7 +39,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { displayName, avatar, banner, accentColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body;
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const updateData: Record<string, string | null | undefined> = {};
|
||||
@@ -79,6 +80,18 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (avatarColor !== undefined) {
|
||||
if (avatarColor && typeof avatarColor === 'string' && avatarColor.trim().length > 0) {
|
||||
const trimmed = avatarColor.trim();
|
||||
if (!(AVATAR_COLORS as readonly string[]).includes(trimmed)) {
|
||||
return reply.code(400).send({ error: `Invalid avatar color. Must be one of: ${AVATAR_COLORS.join(', ')}`, statusCode: 400 });
|
||||
}
|
||||
updateData.avatarColor = trimmed;
|
||||
} else {
|
||||
updateData.avatarColor = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (bio !== undefined) {
|
||||
if (bio && typeof bio === 'string') {
|
||||
const trimmed = bio.trim();
|
||||
|
||||
Reference in New Issue
Block a user