diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index bb78929c..f4d3b29f 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -105,6 +105,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'is_deleted', type: 'INTEGER DEFAULT 0' }, ] + }, + { + name: 'spaces', + columns: [ + { name: 'avatar_color', type: 'TEXT' }, + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index d85a4fd0..31fdcdcb 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -25,6 +25,7 @@ export const spaces = sqliteTable('spaces', { name: text('name').notNull(), icon: text('icon'), banner: text('banner'), + avatarColor: text('avatar_color'), ownerId: text('owner_id').notNull().references(() => users.id), inviteCode: text('invite_code').unique(), visibility: text('visibility').default('private'), diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts index 628b9e1b..8dfebc96 100644 --- a/packages/server/src/routes/explore.ts +++ b/packages/server/src/routes/explore.ts @@ -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 { 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 { 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 { 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, diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 9c275f42..2d6ff74a 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -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 { 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 { // 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 { 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 { 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 { 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)) { diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index fb847b76..743b11d3 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -493,7 +493,7 @@ export async function userRoutes(app: FastifyInstance): Promise { 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() diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 70a74ac3..acb63136 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -7,6 +7,7 @@ import { handleClientEvent } from './events.js'; import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; import type { User, + Space, SpaceWithChannelsAndMembers, MemberWithUser, Channel, @@ -793,6 +794,7 @@ function buildReadyPayload(userId: string): { name: spaceRow.name, icon: spaceRow.icon, banner: spaceRow.banner ?? null, + avatarColor: (spaceRow.avatarColor as Space['avatarColor']) ?? null, ownerId: spaceRow.ownerId, inviteCode: spaceRow.inviteCode, visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'], diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 29a7a080..b85f6ae7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -43,6 +43,7 @@ export interface Space { name: string; icon: string | null; banner: string | null; + avatarColor: AvatarColor | null; ownerId: string; inviteCode: string | null; visibility: SpaceVisibility; @@ -55,6 +56,7 @@ export interface ExploreSpace { name: string; icon: string | null; banner: string | null; + avatarColor: AvatarColor | null; description: string | null; visibility: SpaceVisibility; memberCount: number; @@ -323,6 +325,7 @@ export interface CreateSpaceRequest { name: string; icon?: string; banner?: string; + avatarColor?: string; visibility?: SpaceVisibility; description?: string; } @@ -343,6 +346,7 @@ export interface UpdateSpaceRequest { name?: string; icon?: string; banner?: string; + avatarColor?: string; visibility?: SpaceVisibility; description?: string; } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 91792392..a185906b 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -60,7 +60,7 @@ export class BackspaceApiClient { verifyPassword: (password: string) => Promise; changePassword: (data: ChangePasswordRequest) => Promise; deleteAccount: (data: DeleteAccountRequest) => Promise<{ success: boolean }>; - getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>; + getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>; }; readonly spaces: { @@ -256,7 +256,7 @@ export class BackspaceApiClient { const params = new URLSearchParams(); if (homeUserId) params.set('homeUserId', homeUserId); const qs = params.toString(); - return request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>( + return request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>( 'GET', `/users/${id}/mutuals${qs ? `?${qs}` : ''}` ); }, diff --git a/packages/web/src/components/chat/ExplorePage.tsx b/packages/web/src/components/chat/ExplorePage.tsx index 7620f486..880a7cb1 100644 --- a/packages/web/src/components/chat/ExplorePage.tsx +++ b/packages/web/src/components/chat/ExplorePage.tsx @@ -222,7 +222,7 @@ function SpaceCard({ const [joinError, setJoinError] = useState(''); const [iconGradient, setIconGradient] = useState(null); - const fallbackGradient = getSpaceGradient(space.id, space.name).gradient; + const fallbackGradient = getSpaceGradient(space.id, space.name, space.avatarColor).gradient; const isPublic = space.visibility === 'public'; const isJoined = space.joined === true; const originLabel = space._instanceOrigin diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 5d6c4f62..3050ecee 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -14,6 +14,7 @@ interface SidebarItemProps { id: string; name: string; icon?: string | null; + avatarColor?: string | null; active: boolean; onClick: () => void; onContextMenu?: (e: React.MouseEvent) => void; @@ -26,7 +27,7 @@ interface SidebarItemProps { tooltipText?: string; } -function SidebarItem({ id, name, icon, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) { +function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) { const [isHovered, setIsHovered] = useState(false); const firstLetter = name.charAt(0).toUpperCase(); @@ -51,9 +52,9 @@ function SidebarItem({ id, name, icon, active, onClick, onContextMenu, type = 's // Space type — if it has a custom icon image, no gradient needed if (icon) return undefined; - const spaceGrad = getSpaceGradient(id, name); + const spaceGrad = getSpaceGradient(id, name, avatarColor); return { background: spaceGrad.gradient }; - }, [type, id, name, icon, isHovered]); + }, [type, id, name, icon, avatarColor, isHovered]); const getButtonClasses = () => { const base = 'w-10 h-10 flex items-center justify-center duration-200 overflow-hidden [transition:border-radius_0.2s,background_0.2s,color_0.2s]'; @@ -562,6 +563,7 @@ export function SpaceSidebar() { id={space.id} name={space.name} icon={space.icon} + avatarColor={space.avatarColor} active={currentSpaceId === space.id} onClick={() => handleSpaceClick(space.id)} onContextMenu={(e) => handleSpaceContextMenu(space.id, e)} @@ -588,6 +590,7 @@ export function SpaceSidebar() { id={space.id} name={space.name} icon={space.icon} + avatarColor={space.avatarColor} active={currentSpaceId === space.id} onClick={() => handleSpaceClick(space.id)} onContextMenu={(e) => handleSpaceContextMenu(space.id, e)} diff --git a/packages/web/src/components/modals/CreateSpace.tsx b/packages/web/src/components/modals/CreateSpace.tsx index 239abd11..131c58eb 100644 --- a/packages/web/src/components/modals/CreateSpace.tsx +++ b/packages/web/src/components/modals/CreateSpace.tsx @@ -5,7 +5,9 @@ import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; -import type { SpaceVisibility } from '@backspace/shared'; +import { AVATAR_COLORS } from '@backspace/shared'; +import type { SpaceVisibility, AvatarColor } from '@backspace/shared'; +import { SPACE_GRADIENT_MAP, getSpaceGradient } from '../../utils/gradients'; const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [ { value: 'private', label: 'Private', desc: 'Only people with an invite link can join' }, @@ -21,6 +23,9 @@ export function CreateSpaceModal() { const [iconPreview, setIconPreview] = useState(null); const [uploadingIcon, setUploadingIcon] = useState(false); const [cropSrc, setCropSrc] = useState(null); + const [avatarColor, setAvatarColor] = useState( + AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint' + ); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const fileInputRef = useRef(null); @@ -81,6 +86,7 @@ export function CreateSpaceModal() { if (iconPreview) URL.revokeObjectURL(iconPreview); setIconPreview(null); setCropSrc(null); + setAvatarColor(AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint'); setError(''); }; @@ -98,6 +104,7 @@ export function CreateSpaceModal() { const space = await createSpace({ name: name.trim(), icon: iconFilename ?? undefined, + avatarColor, visibility, description: description.trim() || undefined, }); @@ -126,7 +133,8 @@ export function CreateSpaceModal() { type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingIcon} - className="relative w-20 h-20 rounded-full bg-surface-input border-2 border-dashed border-border-subtle hover:border-accent-primary transition-colors flex items-center justify-center overflow-hidden group" + className="relative w-20 h-20 rounded-full border-2 border-dashed border-border-subtle hover:border-accent-primary transition-colors flex items-center justify-center overflow-hidden group" + style={!iconPreview ? { background: getSpaceGradient(undefined, name || 'S', avatarColor).gradient } : undefined} > {iconPreview ? ( <> @@ -139,7 +147,7 @@ export function CreateSpaceModal() { ) : ( -
+
{uploadingIcon ? ( @@ -147,11 +155,8 @@ export function CreateSpaceModal() { ) : ( <> - - - - - Icon + {(name || 'S').charAt(0).toUpperCase()} + Upload )}
@@ -175,6 +180,32 @@ export function CreateSpaceModal() { )}
+ {/* Icon Color */} +
+ +
+ {AVATAR_COLORS.map((key) => { + const entry = SPACE_GRADIENT_MAP[key]; + return ( +
+
+ {/* Space Name */}
+ {/* Space Avatar Color */} +
+ +
+ {AVATAR_COLORS.map((key) => { + const entry = SPACE_GRADIENT_MAP[key]; + return ( +
+
+ {/* Space Banner */}