feat: space avatar color with color picker UI

Add avatarColor field to spaces, matching the user avatar color system.
Spaces get a random color on creation and owners can change it in space
settings. The color controls the fallback gradient when no icon is uploaded,
replacing the old deterministic hash-based gradient. Includes full
federation support, explore page, mutual spaces, and color picker in both
create and settings modals.
This commit is contained in:
Jannis Braun
2026-03-11 18:34:33 +01:00
parent 373d685a55
commit 7572f165de
16 changed files with 148 additions and 27 deletions
+4 -1
View File
@@ -146,6 +146,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
name: space.name,
icon: space.icon,
banner: space.banner ?? null,
avatarColor: (space.avatarColor as SpaceWithChannelsAndMembers['avatarColor']) ?? null,
ownerId: space.ownerId,
inviteCode: space.inviteCode,
visibility: (space.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
@@ -203,7 +204,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`;
let querySql = `
SELECT s.id, s.name, s.icon, s.banner, s.description, s.visibility, s.created_at,
SELECT s.id, s.name, s.icon, s.banner, s.avatar_color, s.description, s.visibility, s.created_at,
COUNT(sm.user_id) as member_count
FROM spaces s
LEFT JOIN space_members sm ON sm.space_id = s.id
@@ -227,6 +228,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
name: string;
icon: string | null;
banner: string | null;
avatar_color: string | null;
description: string | null;
visibility: string;
created_at: number;
@@ -238,6 +240,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
name: r.name,
icon: r.icon,
banner: r.banner,
avatarColor: (r.avatar_color as ExploreSpace['avatarColor']) ?? null,
description: r.description,
visibility: r.visibility as ExploreSpace['visibility'],
memberCount: r.member_count,
+20 -2
View File
@@ -18,6 +18,7 @@ import type {
SpaceWithChannelsAndMembers,
Role,
} from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { checkVoicePermissions } from '../ws/events.js';
@@ -27,6 +28,7 @@ function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
name: row.name,
icon: row.icon,
banner: row.banner ?? null,
avatarColor: (row.avatarColor as Space['avatarColor']) ?? null,
ownerId: row.ownerId,
inviteCode: row.inviteCode,
visibility: (row.visibility ?? 'private') as Space['visibility'],
@@ -56,7 +58,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
preHandler: authenticate,
}, async (request, reply) => {
const { name, icon, banner, visibility, description } = request.body;
const { name, icon, banner, avatarColor, visibility, description } = request.body;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
@@ -74,6 +76,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Validate description
const safeDescription = description ? description.trim().slice(0, 200) || null : null;
// Validate avatarColor — assign random if not provided
const safeAvatarColor = avatarColor && (AVATAR_COLORS as readonly string[]).includes(avatarColor)
? avatarColor
: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
const db = getDb();
const spaceId = generateSnowflake();
const channelId = generateSnowflake();
@@ -87,6 +94,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
name: trimmedName,
icon: icon ?? null,
banner: banner ?? null,
avatarColor: safeAvatarColor,
ownerId: request.userId,
inviteCode,
visibility: safeVisibility,
@@ -272,7 +280,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, icon, banner, visibility, description } = request.body;
const { name, icon, banner, avatarColor, visibility, description } = request.body;
const db = getDb();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
@@ -302,6 +310,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
updates.banner = banner || null;
}
if (avatarColor !== undefined) {
if (avatarColor === '') {
updates.avatarColor = null;
} else if ((AVATAR_COLORS as readonly string[]).includes(avatarColor)) {
updates.avatarColor = avatarColor;
} else {
return reply.code(400).send({ error: 'Invalid avatar color', statusCode: 400 });
}
}
if (visibility !== undefined) {
const validVisibilities = ['public', 'request', 'private'];
if (!validVisibilities.includes(visibility)) {
+1 -1
View File
@@ -493,7 +493,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
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 })
? db.select({ id: schema.spaces.id, name: schema.spaces.name, icon: schema.spaces.icon, avatarColor: schema.spaces.avatarColor })
.from(schema.spaces)
.where(inArray(schema.spaces.id, mutualSpaceIds))
.all()