diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 9bb22cd7..cc173420 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -93,6 +93,12 @@ export function runMigrations(db: Database.Database): void { { name: 'accent_color', type: 'TEXT' }, { name: 'bio', type: 'TEXT' }, ] + }, + { + name: 'users', + columns: [ + { name: 'avatar_color', type: 'TEXT' }, + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f868c731..5af4358d 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -14,6 +14,7 @@ export const users = sqliteTable('users', { replicatedInstances: text('replicated_instances').default('[]'), banner: text('banner'), accentColor: text('accent_color'), + avatarColor: text('avatar_color'), bio: text('bio'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index f2ef6b42..ee3d9dce 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -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 { @@ -95,6 +96,8 @@ export async function authRoutes(app: FastifyInstance): Promise { 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 { isAdmin: isFirstUser ? 1 : 0, homeInstance: homeInstance || null, homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, + avatarColor, createdAt: now, }).run(); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 0de18928..a7429694 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -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 { @@ -38,7 +39,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); 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 = {}; @@ -79,6 +80,18 @@ export async function userRoutes(app: FastifyInstance): Promise { } } + 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(); diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index 72a8be3b..7b75f991 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -18,6 +18,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, banner: row.banner ?? null, accentColor: row.accentColor ?? null, + avatarColor: (row.avatarColor as User['avatarColor']) ?? null, bio: row.bio ?? null, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e2ade358..34b7ca8b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,5 +1,8 @@ // ─── User Types ───────────────────────────────────────────────────────────── +export const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const; +export type AvatarColor = (typeof AVATAR_COLORS)[number]; + export interface User { id: string; username: string; @@ -7,6 +10,7 @@ export interface User { avatar: string | null; banner: string | null; accentColor: string | null; + avatarColor: AvatarColor | null; bio: string | null; status: UserStatus; customStatus: string | null; @@ -345,6 +349,7 @@ export interface UpdateUserRequest { avatar?: string; banner?: string; accentColor?: string; + avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus; @@ -412,6 +417,7 @@ export interface Friend { avatar: string | null; banner: string | null; accentColor: string | null; + avatarColor: AvatarColor | null; bio: string | null; status: UserStatus; customStatus: string | null; diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 9dc5dd2d..d6aead21 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -50,6 +50,7 @@ const makeFriend = (overrides: Partial = {}): TaggedFriend => ({ avatar: null, banner: null, accentColor: null, + avatarColor: null, bio: null, status: 'online', customStatus: null, @@ -75,6 +76,7 @@ const makeRequest = (overrides: Partial = {}): TaggedFriend avatar: null, banner: null, accentColor: null, + avatarColor: null, bio: null, status: 'online', customStatus: null, @@ -235,6 +237,7 @@ describe('FriendsPage', () => { avatar: null, banner: null, accentColor: null, + avatarColor: null, bio: null, status: 'online', customStatus: null, @@ -286,6 +289,7 @@ describe('FriendsPage', () => { avatar: null, banner: null, accentColor: null, + avatarColor: null, bio: null, status: 'online', customStatus: null, @@ -332,6 +336,7 @@ describe('FriendsPage', () => { avatar: null, banner: null, accentColor: null, + avatarColor: null, bio: null, status: 'online', customStatus: null, diff --git a/packages/web/src/components/layout/ActivityPanel.tsx b/packages/web/src/components/layout/ActivityPanel.tsx index ee4445da..1dc63772 100644 --- a/packages/web/src/components/layout/ActivityPanel.tsx +++ b/packages/web/src/components/layout/ActivityPanel.tsx @@ -35,6 +35,7 @@ export function ActivityPanel() { avatar: friend.avatar, banner: friend.banner, accentColor: friend.accentColor, + avatarColor: friend.avatarColor, bio: friend.bio, status: friend.status, customStatus: friend.customStatus, diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 2eb39426..01607dfb 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -150,7 +150,7 @@ export function UserProfileModal() { : null; const bannerFallback = user.accentColor ? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})` - : getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient; + : getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient; const handleSendMessage = async () => { try { diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 92c1f456..014a83d5 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -3,8 +3,9 @@ import { useAuthStore } from '../../../stores/authStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { api } from '../../../api/client'; -import { getAvatarGradient, adjustColor } from '../../../utils/gradients'; -import type { UserStatus } from '@backspace/shared'; +import { getAvatarGradient, adjustColor, AVATAR_GRADIENT_MAP } from '../../../utils/gradients'; +import { AVATAR_COLORS } from '@backspace/shared'; +import type { User, UserStatus, AvatarColor } from '@backspace/shared'; const ACCENT_PRESETS = [ '#86efac', '#fca5a5', '#c4b5fd', '#7dd3fc', @@ -22,6 +23,7 @@ export function AccountPanel() { const [status, setStatus] = useState(user?.status ?? 'online'); const [bio, setBio] = useState(user?.bio ?? ''); const [accentColor, setAccentColor] = useState(user?.accentColor ?? null); + const [avatarColorState, setAvatarColorState] = useState(user?.avatarColor ?? null); const [customHex, setCustomHex] = useState(user?.accentColor ?? ''); // Avatar upload state @@ -49,6 +51,7 @@ export function AccountPanel() { setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); + setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); // Reset upload state if (avatarPreview) URL.revokeObjectURL(avatarPreview); @@ -58,12 +61,13 @@ export function AccountPanel() { setBannerPreview(null); setBannerFilename(null); } - }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatar, user?.banner]); + }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatarColor, user?.avatar, user?.banner]); if (!user) return null; const effectiveDisplayName = displayName.trim() || user.username; const effectiveAccent = accentColor; + const effectiveAvatarColor = avatarColorState; // Change detection const hasChanges = @@ -72,6 +76,7 @@ export function AccountPanel() { status !== (user.status ?? 'online') || bio !== (user.bio ?? '') || accentColor !== (user.accentColor ?? null) || + avatarColorState !== (user.avatarColor ?? null) || avatarFilename !== null || bannerFilename !== null; @@ -90,7 +95,7 @@ export function AccountPanel() { // Banner fallback: accent gradient or avatar gradient const bannerFallback = effectiveAccent ? `linear-gradient(135deg, ${effectiveAccent}, ${adjustColor(effectiveAccent, -40)})` - : getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName).gradient; + : getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor).gradient; // ── File selection handlers ── const handleAvatarSelect = (e: React.ChangeEvent) => { @@ -173,6 +178,7 @@ export function AccountPanel() { if (status !== (user.status ?? 'online')) updates.status = status; if (bio !== (user.bio ?? '')) updates.bio = bio.trim(); if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? ''; + if (avatarColorState !== (user.avatarColor ?? null)) updates.avatarColor = avatarColorState ?? ''; if (avatarFilename !== null) updates.avatar = avatarFilename; if (bannerFilename !== null) updates.banner = bannerFilename; @@ -192,6 +198,7 @@ export function AccountPanel() { setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); + setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); if (avatarPreview) URL.revokeObjectURL(avatarPreview); if (bannerPreview) URL.revokeObjectURL(bannerPreview); @@ -238,6 +245,7 @@ export function AccountPanel() { name={effectiveDisplayName} size={56} userId={user.homeUserId ?? user.id} + user={{ ...user, avatarColor: effectiveAvatarColor } as User} /> )} @@ -277,6 +285,7 @@ export function AccountPanel() { name={effectiveDisplayName} size={64} userId={user.homeUserId ?? user.id} + user={{ ...user, avatarColor: effectiveAvatarColor } as User} /> )} @@ -383,6 +392,30 @@ export function AccountPanel() { /> + {/* Avatar Color */} +
+ +
+ {AVATAR_COLORS.map((key) => { + const entry = AVATAR_GRADIENT_MAP[key]; + return ( +
+
+ {/* Accent Color */}
diff --git a/packages/web/src/components/ui/Avatar.tsx b/packages/web/src/components/ui/Avatar.tsx index 0f5dc8f7..32758181 100644 --- a/packages/web/src/components/ui/Avatar.tsx +++ b/packages/web/src/components/ui/Avatar.tsx @@ -48,7 +48,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick, const initials = name.charAt(0).toUpperCase(); // Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18; - const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name); + const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor); const handleClick = (e: React.MouseEvent) => { if (onClick) { diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index ab9d5369..cec9d164 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -72,7 +72,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout : null; const bannerFallback = user.accentColor ? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})` - : getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient; + : getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient; return (
= { + mint: AVATAR_GRADIENTS[0]!, + sky: AVATAR_GRADIENTS[1]!, + lavender: AVATAR_GRADIENTS[2]!, + coral: AVATAR_GRADIENTS[3]!, + rose: AVATAR_GRADIENTS[4]!, + teal: AVATAR_GRADIENTS[5]!, + amber: AVATAR_GRADIENTS[6]!, +}; + // ── Space icon gradients (space fallbacks) ── const SPACE_GRADIENTS: GradientEntry[] = [ { gradient: 'linear-gradient(135deg, #ef4444, #f97316)', glow: '#f97316' }, // red-orange @@ -45,8 +57,11 @@ function hashString(str: string): number { return hash; } -/** Deterministic gradient for a user avatar. Prefers ID for stability; falls back to name. */ -export function getAvatarGradient(id?: string | null, name?: string): GradientEntry { +/** Deterministic gradient for a user avatar. Uses stored avatarColor if available; falls back to hash. */ +export function getAvatarGradient(id?: string | null, name?: string, avatarColor?: string | null): GradientEntry { + if (avatarColor && avatarColor in AVATAR_GRADIENT_MAP) { + return AVATAR_GRADIENT_MAP[avatarColor as AvatarColor]; + } const key = id || name || 'unknown'; return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!; }