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:
@@ -93,6 +93,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
{ name: 'accent_color', type: 'TEXT' },
|
{ name: 'accent_color', type: 'TEXT' },
|
||||||
{ name: 'bio', type: 'TEXT' },
|
{ name: 'bio', type: 'TEXT' },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'users',
|
||||||
|
columns: [
|
||||||
|
{ name: 'avatar_color', type: 'TEXT' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const users = sqliteTable('users', {
|
|||||||
replicatedInstances: text('replicated_instances').default('[]'),
|
replicatedInstances: text('replicated_instances').default('[]'),
|
||||||
banner: text('banner'),
|
banner: text('banner'),
|
||||||
accentColor: text('accent_color'),
|
accentColor: text('accent_color'),
|
||||||
|
avatarColor: text('avatar_color'),
|
||||||
bio: text('bio'),
|
bio: text('bio'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
|||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
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 userCount = db.select().from(schema.users).all().length;
|
||||||
const isFirstUser = userCount === 0 && !homeInstance;
|
const isFirstUser = userCount === 0 && !homeInstance;
|
||||||
|
|
||||||
|
const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
||||||
|
|
||||||
db.insert(schema.users).values({
|
db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
username: trimmedUsername,
|
username: trimmedUsername,
|
||||||
@@ -104,6 +107,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
isAdmin: isFirstUser ? 1 : 0,
|
isAdmin: isFirstUser ? 1 : 0,
|
||||||
homeInstance: homeInstance || null,
|
homeInstance: homeInstance || null,
|
||||||
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
|
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
|
||||||
|
avatarColor,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js';
|
|||||||
import { authenticate, verifyPassword } from '../utils/auth.js';
|
import { authenticate, verifyPassword } from '../utils/auth.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared';
|
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ReplicatedInstance } from '@backspace/shared';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
|
||||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
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) => {
|
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 db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
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 !== undefined) {
|
||||||
if (bio && typeof bio === 'string') {
|
if (bio && typeof bio === 'string') {
|
||||||
const trimmed = bio.trim();
|
const trimmed = bio.trim();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
banner: row.banner ?? null,
|
banner: row.banner ?? null,
|
||||||
accentColor: row.accentColor ?? null,
|
accentColor: row.accentColor ?? null,
|
||||||
|
avatarColor: (row.avatarColor as User['avatarColor']) ?? null,
|
||||||
bio: row.bio ?? null,
|
bio: row.bio ?? null,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// ─── User Types ─────────────────────────────────────────────────────────────
|
// ─── 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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -7,6 +10,7 @@ export interface User {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
banner: string | null;
|
banner: string | null;
|
||||||
accentColor: string | null;
|
accentColor: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
@@ -345,6 +349,7 @@ export interface UpdateUserRequest {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
banner?: string;
|
banner?: string;
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
|
avatarColor?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
customStatus?: string;
|
customStatus?: string;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
@@ -412,6 +417,7 @@ export interface Friend {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
banner: string | null;
|
banner: string | null;
|
||||||
accentColor: string | null;
|
accentColor: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const makeFriend = (overrides: Partial<TaggedFriend> = {}): TaggedFriend => ({
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
banner: null,
|
banner: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
@@ -75,6 +76,7 @@ const makeRequest = (overrides: Partial<TaggedFriendRequest> = {}): TaggedFriend
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
banner: null,
|
banner: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
@@ -235,6 +237,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
banner: null,
|
banner: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
@@ -286,6 +289,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
banner: null,
|
banner: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
@@ -332,6 +336,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
banner: null,
|
banner: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function ActivityPanel() {
|
|||||||
avatar: friend.avatar,
|
avatar: friend.avatar,
|
||||||
banner: friend.banner,
|
banner: friend.banner,
|
||||||
accentColor: friend.accentColor,
|
accentColor: friend.accentColor,
|
||||||
|
avatarColor: friend.avatarColor,
|
||||||
bio: friend.bio,
|
bio: friend.bio,
|
||||||
status: friend.status,
|
status: friend.status,
|
||||||
customStatus: friend.customStatus,
|
customStatus: friend.customStatus,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function UserProfileModal() {
|
|||||||
: null;
|
: null;
|
||||||
const bannerFallback = user.accentColor
|
const bannerFallback = user.accentColor
|
||||||
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
|
? `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 () => {
|
const handleSendMessage = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useAuthStore } from '../../../stores/authStore';
|
|||||||
import { Avatar } from '../../ui/Avatar';
|
import { Avatar } from '../../ui/Avatar';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
import { getAvatarGradient, adjustColor } from '../../../utils/gradients';
|
import { getAvatarGradient, adjustColor, AVATAR_GRADIENT_MAP } from '../../../utils/gradients';
|
||||||
import type { UserStatus } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
|
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||||
|
|
||||||
const ACCENT_PRESETS = [
|
const ACCENT_PRESETS = [
|
||||||
'#86efac', '#fca5a5', '#c4b5fd', '#7dd3fc',
|
'#86efac', '#fca5a5', '#c4b5fd', '#7dd3fc',
|
||||||
@@ -22,6 +23,7 @@ export function AccountPanel() {
|
|||||||
const [status, setStatus] = useState<UserStatus>(user?.status ?? 'online');
|
const [status, setStatus] = useState<UserStatus>(user?.status ?? 'online');
|
||||||
const [bio, setBio] = useState(user?.bio ?? '');
|
const [bio, setBio] = useState(user?.bio ?? '');
|
||||||
const [accentColor, setAccentColor] = useState<string | null>(user?.accentColor ?? null);
|
const [accentColor, setAccentColor] = useState<string | null>(user?.accentColor ?? null);
|
||||||
|
const [avatarColorState, setAvatarColorState] = useState<AvatarColor | null>(user?.avatarColor ?? null);
|
||||||
const [customHex, setCustomHex] = useState(user?.accentColor ?? '');
|
const [customHex, setCustomHex] = useState(user?.accentColor ?? '');
|
||||||
|
|
||||||
// Avatar upload state
|
// Avatar upload state
|
||||||
@@ -49,6 +51,7 @@ export function AccountPanel() {
|
|||||||
setStatus(user.status ?? 'online');
|
setStatus(user.status ?? 'online');
|
||||||
setBio(user.bio ?? '');
|
setBio(user.bio ?? '');
|
||||||
setAccentColor(user.accentColor ?? null);
|
setAccentColor(user.accentColor ?? null);
|
||||||
|
setAvatarColorState(user.avatarColor ?? null);
|
||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
// Reset upload state
|
// Reset upload state
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
@@ -58,12 +61,13 @@ export function AccountPanel() {
|
|||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
setBannerFilename(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;
|
if (!user) return null;
|
||||||
|
|
||||||
const effectiveDisplayName = displayName.trim() || user.username;
|
const effectiveDisplayName = displayName.trim() || user.username;
|
||||||
const effectiveAccent = accentColor;
|
const effectiveAccent = accentColor;
|
||||||
|
const effectiveAvatarColor = avatarColorState;
|
||||||
|
|
||||||
// Change detection
|
// Change detection
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
@@ -72,6 +76,7 @@ export function AccountPanel() {
|
|||||||
status !== (user.status ?? 'online') ||
|
status !== (user.status ?? 'online') ||
|
||||||
bio !== (user.bio ?? '') ||
|
bio !== (user.bio ?? '') ||
|
||||||
accentColor !== (user.accentColor ?? null) ||
|
accentColor !== (user.accentColor ?? null) ||
|
||||||
|
avatarColorState !== (user.avatarColor ?? null) ||
|
||||||
avatarFilename !== null ||
|
avatarFilename !== null ||
|
||||||
bannerFilename !== null;
|
bannerFilename !== null;
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ export function AccountPanel() {
|
|||||||
// Banner fallback: accent gradient or avatar gradient
|
// Banner fallback: accent gradient or avatar gradient
|
||||||
const bannerFallback = effectiveAccent
|
const bannerFallback = effectiveAccent
|
||||||
? `linear-gradient(135deg, ${effectiveAccent}, ${adjustColor(effectiveAccent, -40)})`
|
? `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 ──
|
// ── File selection handlers ──
|
||||||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -173,6 +178,7 @@ export function AccountPanel() {
|
|||||||
if (status !== (user.status ?? 'online')) updates.status = status;
|
if (status !== (user.status ?? 'online')) updates.status = status;
|
||||||
if (bio !== (user.bio ?? '')) updates.bio = bio.trim();
|
if (bio !== (user.bio ?? '')) updates.bio = bio.trim();
|
||||||
if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? '';
|
if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? '';
|
||||||
|
if (avatarColorState !== (user.avatarColor ?? null)) updates.avatarColor = avatarColorState ?? '';
|
||||||
if (avatarFilename !== null) updates.avatar = avatarFilename;
|
if (avatarFilename !== null) updates.avatar = avatarFilename;
|
||||||
if (bannerFilename !== null) updates.banner = bannerFilename;
|
if (bannerFilename !== null) updates.banner = bannerFilename;
|
||||||
|
|
||||||
@@ -192,6 +198,7 @@ export function AccountPanel() {
|
|||||||
setStatus(user.status ?? 'online');
|
setStatus(user.status ?? 'online');
|
||||||
setBio(user.bio ?? '');
|
setBio(user.bio ?? '');
|
||||||
setAccentColor(user.accentColor ?? null);
|
setAccentColor(user.accentColor ?? null);
|
||||||
|
setAvatarColorState(user.avatarColor ?? null);
|
||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||||
@@ -238,6 +245,7 @@ export function AccountPanel() {
|
|||||||
name={effectiveDisplayName}
|
name={effectiveDisplayName}
|
||||||
size={56}
|
size={56}
|
||||||
userId={user.homeUserId ?? user.id}
|
userId={user.homeUserId ?? user.id}
|
||||||
|
user={{ ...user, avatarColor: effectiveAvatarColor } as User}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +285,7 @@ export function AccountPanel() {
|
|||||||
name={effectiveDisplayName}
|
name={effectiveDisplayName}
|
||||||
size={64}
|
size={64}
|
||||||
userId={user.homeUserId ?? user.id}
|
userId={user.homeUserId ?? user.id}
|
||||||
|
user={{ ...user, avatarColor: effectiveAvatarColor } as User}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -383,6 +392,30 @@ export function AccountPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">Avatar Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{AVATAR_COLORS.map((key) => {
|
||||||
|
const entry = AVATAR_GRADIENT_MAP[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAvatarColorState(key)}
|
||||||
|
className="w-7 h-7 rounded-full border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: entry.gradient,
|
||||||
|
borderColor: avatarColorState === key ? 'white' : 'transparent',
|
||||||
|
boxShadow: avatarColorState === key ? `0 0 0 2px ${entry.glow}40` : 'none',
|
||||||
|
}}
|
||||||
|
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Accent Color */}
|
{/* Accent Color */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">Accent Color</label>
|
<label className="block text-xs text-txt-secondary mb-1.5">Accent Color</label>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
|||||||
const initials = name.charAt(0).toUpperCase();
|
const initials = name.charAt(0).toUpperCase();
|
||||||
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
||||||
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
|
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) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
: null;
|
: null;
|
||||||
const bannerFallback = user.accentColor
|
const bannerFallback = user.accentColor
|
||||||
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
|
? `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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Deterministic color assignment based on entity ID (snowflake) or name.
|
* Deterministic color assignment based on entity ID (snowflake) or name.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AvatarColor } from '@backspace/shared';
|
||||||
|
|
||||||
export interface GradientEntry {
|
export interface GradientEntry {
|
||||||
gradient: string;
|
gradient: string;
|
||||||
glow: string;
|
glow: string;
|
||||||
@@ -19,6 +21,16 @@ const AVATAR_GRADIENTS: GradientEntry[] = [
|
|||||||
{ gradient: 'linear-gradient(135deg, #f59e0b, #eab308)', glow: '#f59e0b' }, // amber
|
{ gradient: 'linear-gradient(135deg, #f59e0b, #eab308)', glow: '#f59e0b' }, // amber
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const AVATAR_GRADIENT_MAP: Record<AvatarColor, GradientEntry> = {
|
||||||
|
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) ──
|
// ── Space icon gradients (space fallbacks) ──
|
||||||
const SPACE_GRADIENTS: GradientEntry[] = [
|
const SPACE_GRADIENTS: GradientEntry[] = [
|
||||||
{ gradient: 'linear-gradient(135deg, #ef4444, #f97316)', glow: '#f97316' }, // red-orange
|
{ gradient: 'linear-gradient(135deg, #ef4444, #f97316)', glow: '#f97316' }, // red-orange
|
||||||
@@ -45,8 +57,11 @@ function hashString(str: string): number {
|
|||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic gradient for a user avatar. Prefers ID for stability; falls back to name. */
|
/** Deterministic gradient for a user avatar. Uses stored avatarColor if available; falls back to hash. */
|
||||||
export function getAvatarGradient(id?: string | null, name?: string): GradientEntry {
|
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';
|
const key = id || name || 'unknown';
|
||||||
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
|
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user