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:
@@ -105,6 +105,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'is_deleted', type: 'INTEGER DEFAULT 0' },
|
{ name: 'is_deleted', type: 'INTEGER DEFAULT 0' },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'spaces',
|
||||||
|
columns: [
|
||||||
|
{ name: 'avatar_color', type: 'TEXT' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const spaces = sqliteTable('spaces', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
icon: text('icon'),
|
icon: text('icon'),
|
||||||
banner: text('banner'),
|
banner: text('banner'),
|
||||||
|
avatarColor: text('avatar_color'),
|
||||||
ownerId: text('owner_id').notNull().references(() => users.id),
|
ownerId: text('owner_id').notNull().references(() => users.id),
|
||||||
inviteCode: text('invite_code').unique(),
|
inviteCode: text('invite_code').unique(),
|
||||||
visibility: text('visibility').default('private'),
|
visibility: text('visibility').default('private'),
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
|
|||||||
name: space.name,
|
name: space.name,
|
||||||
icon: space.icon,
|
icon: space.icon,
|
||||||
banner: space.banner ?? null,
|
banner: space.banner ?? null,
|
||||||
|
avatarColor: (space.avatarColor as SpaceWithChannelsAndMembers['avatarColor']) ?? null,
|
||||||
ownerId: space.ownerId,
|
ownerId: space.ownerId,
|
||||||
inviteCode: space.inviteCode,
|
inviteCode: space.inviteCode,
|
||||||
visibility: (space.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
|
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 countSql = `SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`;
|
||||||
let querySql = `
|
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
|
COUNT(sm.user_id) as member_count
|
||||||
FROM spaces s
|
FROM spaces s
|
||||||
LEFT JOIN space_members sm ON sm.space_id = s.id
|
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;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
banner: string | null;
|
banner: string | null;
|
||||||
|
avatar_color: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@@ -238,6 +240,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
icon: r.icon,
|
icon: r.icon,
|
||||||
banner: r.banner,
|
banner: r.banner,
|
||||||
|
avatarColor: (r.avatar_color as ExploreSpace['avatarColor']) ?? null,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
visibility: r.visibility as ExploreSpace['visibility'],
|
visibility: r.visibility as ExploreSpace['visibility'],
|
||||||
memberCount: r.member_count,
|
memberCount: r.member_count,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
SpaceWithChannelsAndMembers,
|
SpaceWithChannelsAndMembers,
|
||||||
Role,
|
Role,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { checkVoicePermissions } from '../ws/events.js';
|
import { checkVoicePermissions } from '../ws/events.js';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
icon: row.icon,
|
icon: row.icon,
|
||||||
banner: row.banner ?? null,
|
banner: row.banner ?? null,
|
||||||
|
avatarColor: (row.avatarColor as Space['avatarColor']) ?? null,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
inviteCode: row.inviteCode,
|
inviteCode: row.inviteCode,
|
||||||
visibility: (row.visibility ?? 'private') as Space['visibility'],
|
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', {
|
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, 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') {
|
if (!name || typeof name !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
|
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
|
// Validate description
|
||||||
const safeDescription = description ? description.trim().slice(0, 200) || null : null;
|
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 db = getDb();
|
||||||
const spaceId = generateSnowflake();
|
const spaceId = generateSnowflake();
|
||||||
const channelId = generateSnowflake();
|
const channelId = generateSnowflake();
|
||||||
@@ -87,6 +94,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
banner: banner ?? null,
|
banner: banner ?? null,
|
||||||
|
avatarColor: safeAvatarColor,
|
||||||
ownerId: request.userId,
|
ownerId: request.userId,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
visibility: safeVisibility,
|
visibility: safeVisibility,
|
||||||
@@ -272,7 +280,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
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 db = getDb();
|
||||||
|
|
||||||
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
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;
|
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) {
|
if (visibility !== undefined) {
|
||||||
const validVisibilities = ['public', 'request', 'private'];
|
const validVisibilities = ['public', 'request', 'private'];
|
||||||
if (!validVisibilities.includes(visibility)) {
|
if (!validVisibilities.includes(visibility)) {
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id));
|
const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id));
|
||||||
|
|
||||||
const mutualSpaces = mutualSpaceIds.length > 0
|
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)
|
.from(schema.spaces)
|
||||||
.where(inArray(schema.spaces.id, mutualSpaceIds))
|
.where(inArray(schema.spaces.id, mutualSpaceIds))
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { handleClientEvent } from './events.js';
|
|||||||
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
|
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
|
||||||
import type {
|
import type {
|
||||||
User,
|
User,
|
||||||
|
Space,
|
||||||
SpaceWithChannelsAndMembers,
|
SpaceWithChannelsAndMembers,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
Channel,
|
Channel,
|
||||||
@@ -793,6 +794,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
name: spaceRow.name,
|
name: spaceRow.name,
|
||||||
icon: spaceRow.icon,
|
icon: spaceRow.icon,
|
||||||
banner: spaceRow.banner ?? null,
|
banner: spaceRow.banner ?? null,
|
||||||
|
avatarColor: (spaceRow.avatarColor as Space['avatarColor']) ?? null,
|
||||||
ownerId: spaceRow.ownerId,
|
ownerId: spaceRow.ownerId,
|
||||||
inviteCode: spaceRow.inviteCode,
|
inviteCode: spaceRow.inviteCode,
|
||||||
visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
|
visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface Space {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
banner: string | null;
|
banner: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
inviteCode: string | null;
|
inviteCode: string | null;
|
||||||
visibility: SpaceVisibility;
|
visibility: SpaceVisibility;
|
||||||
@@ -55,6 +56,7 @@ export interface ExploreSpace {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
banner: string | null;
|
banner: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
visibility: SpaceVisibility;
|
visibility: SpaceVisibility;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
@@ -323,6 +325,7 @@ export interface CreateSpaceRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
banner?: string;
|
banner?: string;
|
||||||
|
avatarColor?: string;
|
||||||
visibility?: SpaceVisibility;
|
visibility?: SpaceVisibility;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -343,6 +346,7 @@ export interface UpdateSpaceRequest {
|
|||||||
name?: string;
|
name?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
banner?: string;
|
banner?: string;
|
||||||
|
avatarColor?: string;
|
||||||
visibility?: SpaceVisibility;
|
visibility?: SpaceVisibility;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class BackspaceApiClient {
|
|||||||
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
|
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
|
||||||
changePassword: (data: ChangePasswordRequest) => Promise<ChangePasswordResponse>;
|
changePassword: (data: ChangePasswordRequest) => Promise<ChangePasswordResponse>;
|
||||||
deleteAccount: (data: DeleteAccountRequest) => Promise<{ success: boolean }>;
|
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: {
|
readonly spaces: {
|
||||||
@@ -256,7 +256,7 @@ export class BackspaceApiClient {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (homeUserId) params.set('homeUserId', homeUserId);
|
if (homeUserId) params.set('homeUserId', homeUserId);
|
||||||
const qs = params.toString();
|
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}` : ''}`
|
'GET', `/users/${id}/mutuals${qs ? `?${qs}` : ''}`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ function SpaceCard({
|
|||||||
const [joinError, setJoinError] = useState('');
|
const [joinError, setJoinError] = useState('');
|
||||||
const [iconGradient, setIconGradient] = useState<string | null>(null);
|
const [iconGradient, setIconGradient] = useState<string | null>(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 isPublic = space.visibility === 'public';
|
||||||
const isJoined = space.joined === true;
|
const isJoined = space.joined === true;
|
||||||
const originLabel = space._instanceOrigin
|
const originLabel = space._instanceOrigin
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface SidebarItemProps {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
|
avatarColor?: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent) => void;
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
@@ -26,7 +27,7 @@ interface SidebarItemProps {
|
|||||||
tooltipText?: string;
|
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 [isHovered, setIsHovered] = useState(false);
|
||||||
const firstLetter = name.charAt(0).toUpperCase();
|
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
|
// Space type — if it has a custom icon image, no gradient needed
|
||||||
if (icon) return undefined;
|
if (icon) return undefined;
|
||||||
|
|
||||||
const spaceGrad = getSpaceGradient(id, name);
|
const spaceGrad = getSpaceGradient(id, name, avatarColor);
|
||||||
return { background: spaceGrad.gradient };
|
return { background: spaceGrad.gradient };
|
||||||
}, [type, id, name, icon, isHovered]);
|
}, [type, id, name, icon, avatarColor, isHovered]);
|
||||||
|
|
||||||
const getButtonClasses = () => {
|
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]';
|
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}
|
id={space.id}
|
||||||
name={space.name}
|
name={space.name}
|
||||||
icon={space.icon}
|
icon={space.icon}
|
||||||
|
avatarColor={space.avatarColor}
|
||||||
active={currentSpaceId === space.id}
|
active={currentSpaceId === space.id}
|
||||||
onClick={() => handleSpaceClick(space.id)}
|
onClick={() => handleSpaceClick(space.id)}
|
||||||
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
||||||
@@ -588,6 +590,7 @@ export function SpaceSidebar() {
|
|||||||
id={space.id}
|
id={space.id}
|
||||||
name={space.name}
|
name={space.name}
|
||||||
icon={space.icon}
|
icon={space.icon}
|
||||||
|
avatarColor={space.avatarColor}
|
||||||
active={currentSpaceId === space.id}
|
active={currentSpaceId === space.id}
|
||||||
onClick={() => handleSpaceClick(space.id)}
|
onClick={() => handleSpaceClick(space.id)}
|
||||||
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { useSpaceStore } from '../../stores/spaceStore';
|
|||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
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 }[] = [
|
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
|
||||||
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
|
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
|
||||||
@@ -21,6 +23,9 @@ export function CreateSpaceModal() {
|
|||||||
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||||
const [uploadingIcon, setUploadingIcon] = useState(false);
|
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||||
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||||
|
const [avatarColor, setAvatarColor] = useState<AvatarColor>(
|
||||||
|
AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint'
|
||||||
|
);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -81,6 +86,7 @@ export function CreateSpaceModal() {
|
|||||||
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
setIconPreview(null);
|
setIconPreview(null);
|
||||||
setCropSrc(null);
|
setCropSrc(null);
|
||||||
|
setAvatarColor(AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint');
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +104,7 @@ export function CreateSpaceModal() {
|
|||||||
const space = await createSpace({
|
const space = await createSpace({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
icon: iconFilename ?? undefined,
|
icon: iconFilename ?? undefined,
|
||||||
|
avatarColor,
|
||||||
visibility,
|
visibility,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
});
|
});
|
||||||
@@ -126,7 +133,8 @@ export function CreateSpaceModal() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingIcon}
|
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 ? (
|
{iconPreview ? (
|
||||||
<>
|
<>
|
||||||
@@ -139,7 +147,7 @@ export function CreateSpaceModal() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-1 text-txt-tertiary group-hover:text-accent-primary transition-colors">
|
<div className="flex flex-col items-center gap-1 text-white/90 group-hover:text-white transition-colors">
|
||||||
{uploadingIcon ? (
|
{uploadingIcon ? (
|
||||||
<svg className="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
@@ -147,11 +155,8 @@ export function CreateSpaceModal() {
|
|||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<span className="text-2xl font-bold">{(name || 'S').charAt(0).toUpperCase()}</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
<span className="text-[9px] font-medium opacity-60">Upload</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] font-medium">Icon</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +180,32 @@ export function CreateSpaceModal() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Color */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Icon Color
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
{AVATAR_COLORS.map((key) => {
|
||||||
|
const entry = SPACE_GRADIENT_MAP[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAvatarColor(key)}
|
||||||
|
className="w-7 h-7 rounded-full border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: entry.gradient,
|
||||||
|
borderColor: avatarColor === key ? 'white' : 'transparent',
|
||||||
|
boxShadow: avatarColor === key ? `0 0 0 2px ${entry.glow}40` : 'none',
|
||||||
|
}}
|
||||||
|
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Space Name */}
|
{/* Space Name */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export function UserProfileModal() {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-semibold text-white"
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-semibold text-white"
|
||||||
style={{ background: getSpaceGradient(space.id, space.name).gradient }}
|
style={{ background: getSpaceGradient(space.id, space.name, space.avatarColor).gradient }}
|
||||||
>
|
>
|
||||||
{space.name.charAt(0).toUpperCase()}
|
{space.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
import { getSpaceGradient } from '../../../utils/gradients';
|
import { getSpaceGradient, SPACE_GRADIENT_MAP } from '../../../utils/gradients';
|
||||||
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
|
import type { AvatarColor } from '@backspace/shared';
|
||||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../../stores/authStore';
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
@@ -41,6 +43,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||||
const bannerFileInputRef = useRef<HTMLInputElement>(null);
|
const bannerFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [avatarColorState, setAvatarColorState] = useState<AvatarColor | null>(space?.avatarColor ?? null);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
const [saveError, setSaveError] = useState('');
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
@@ -59,6 +63,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (space) {
|
if (space) {
|
||||||
setSpaceName(space.name);
|
setSpaceName(space.name);
|
||||||
|
setAvatarColorState(space.avatarColor ?? null);
|
||||||
setIconFilename(null);
|
setIconFilename(null);
|
||||||
if (iconPreview) {
|
if (iconPreview) {
|
||||||
URL.revokeObjectURL(iconPreview);
|
URL.revokeObjectURL(iconPreview);
|
||||||
@@ -70,14 +75,15 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [space?.name, space?.icon, space?.banner]);
|
}, [space?.name, space?.icon, space?.banner, space?.avatarColor]);
|
||||||
|
|
||||||
if (!space) return null;
|
if (!space) return null;
|
||||||
|
|
||||||
const hasNameChange = spaceName.trim() !== space.name;
|
const hasNameChange = spaceName.trim() !== space.name;
|
||||||
const hasIconChange = iconFilename !== null;
|
const hasIconChange = iconFilename !== null;
|
||||||
const hasBannerChange = bannerFilename !== null;
|
const hasBannerChange = bannerFilename !== null;
|
||||||
const hasChanges = hasNameChange || hasIconChange || hasBannerChange;
|
const hasAvatarColorChange = avatarColorState !== (space.avatarColor ?? null);
|
||||||
|
const hasChanges = hasNameChange || hasIconChange || hasBannerChange || hasAvatarColorChange;
|
||||||
|
|
||||||
const currentIconUrl = space.icon
|
const currentIconUrl = space.icon
|
||||||
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
||||||
@@ -170,7 +176,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
setSaveError('');
|
setSaveError('');
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
try {
|
try {
|
||||||
const updates: { name?: string; icon?: string; banner?: string } = {};
|
const updates: { name?: string; icon?: string; banner?: string; avatarColor?: string } = {};
|
||||||
if (hasNameChange) updates.name = spaceName.trim();
|
if (hasNameChange) updates.name = spaceName.trim();
|
||||||
if (hasIconChange) {
|
if (hasIconChange) {
|
||||||
updates.icon = iconFilename === '' ? '' : iconFilename!;
|
updates.icon = iconFilename === '' ? '' : iconFilename!;
|
||||||
@@ -178,6 +184,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
if (hasBannerChange) {
|
if (hasBannerChange) {
|
||||||
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
|
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
|
||||||
}
|
}
|
||||||
|
if (hasAvatarColorChange) {
|
||||||
|
updates.avatarColor = avatarColorState ?? '';
|
||||||
|
}
|
||||||
await updateSpace(spaceId, updates);
|
await updateSpace(spaceId, updates);
|
||||||
setIconFilename(null);
|
setIconFilename(null);
|
||||||
if (iconPreview) {
|
if (iconPreview) {
|
||||||
@@ -200,6 +209,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
setSpaceName(space.name);
|
setSpaceName(space.name);
|
||||||
|
setAvatarColorState(space.avatarColor ?? null);
|
||||||
setIconFilename(null);
|
setIconFilename(null);
|
||||||
if (iconPreview) {
|
if (iconPreview) {
|
||||||
URL.revokeObjectURL(iconPreview);
|
URL.revokeObjectURL(iconPreview);
|
||||||
@@ -296,7 +306,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-full h-full rounded-full flex items-center justify-center text-white text-xl font-bold"
|
className="w-full h-full rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||||
style={{ background: getSpaceGradient(space.id, space.name).gradient }}
|
style={{ background: getSpaceGradient(space.id, space.name, avatarColorState).gradient }}
|
||||||
>
|
>
|
||||||
{space.name.charAt(0).toUpperCase()}
|
{space.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +339,33 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Space Avatar Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
|
Icon Color
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{AVATAR_COLORS.map((key) => {
|
||||||
|
const entry = SPACE_GRADIENT_MAP[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => canManageSpace && setAvatarColorState(key)}
|
||||||
|
disabled={!canManageSpace}
|
||||||
|
className="w-7 h-7 rounded-full border-2 transition-all hover:scale-110 disabled:cursor-default disabled:hover:scale-100"
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Space Banner */}
|
{/* Space Banner */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
icon: s.icon,
|
icon: s.icon,
|
||||||
banner: s.banner ?? null,
|
banner: s.banner ?? null,
|
||||||
|
avatarColor: s.avatarColor ?? null,
|
||||||
ownerId: s.ownerId,
|
ownerId: s.ownerId,
|
||||||
inviteCode: s.inviteCode,
|
inviteCode: s.inviteCode,
|
||||||
visibility: s.visibility ?? 'private' as const,
|
visibility: s.visibility ?? 'private' as const,
|
||||||
@@ -488,6 +489,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
name: space.name,
|
name: space.name,
|
||||||
icon: space.icon,
|
icon: space.icon,
|
||||||
banner: space.banner ?? null,
|
banner: space.banner ?? null,
|
||||||
|
avatarColor: space.avatarColor ?? null,
|
||||||
ownerId: space.ownerId,
|
ownerId: space.ownerId,
|
||||||
inviteCode: space.inviteCode,
|
inviteCode: space.inviteCode,
|
||||||
visibility: space.visibility,
|
visibility: space.visibility,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const AVATAR_GRADIENT_MAP: Record<AvatarColor, GradientEntry> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Space icon gradients (space fallbacks) ──
|
// ── Space icon gradients (space fallbacks) ──
|
||||||
const SPACE_GRADIENTS: GradientEntry[] = [
|
export const SPACE_GRADIENTS: GradientEntry[] = [
|
||||||
grad('#ef4444', '#f97316', '#f97316'), // red-orange
|
grad('#ef4444', '#f97316', '#f97316'), // red-orange
|
||||||
grad('#ec4899', '#f472b6', '#ec4899'), // pink
|
grad('#ec4899', '#f472b6', '#ec4899'), // pink
|
||||||
grad('#14b8a6', '#06b6d4', '#06b6d4'), // teal-cyan
|
grad('#14b8a6', '#06b6d4', '#06b6d4'), // teal-cyan
|
||||||
@@ -48,6 +48,16 @@ const SPACE_GRADIENTS: GradientEntry[] = [
|
|||||||
grad('#059669', '#10b981', '#10b981'), // mint
|
grad('#059669', '#10b981', '#10b981'), // mint
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const SPACE_GRADIENT_MAP: Record<AvatarColor, GradientEntry> = {
|
||||||
|
coral: SPACE_GRADIENTS[0]!, // red-orange
|
||||||
|
rose: SPACE_GRADIENTS[1]!, // pink
|
||||||
|
teal: SPACE_GRADIENTS[2]!, // teal-cyan
|
||||||
|
amber: SPACE_GRADIENTS[3]!, // amber-yellow
|
||||||
|
sky: SPACE_GRADIENTS[4]!, // blue-indigo
|
||||||
|
lavender: SPACE_GRADIENTS[5]!, // lavender
|
||||||
|
mint: SPACE_GRADIENTS[6]!, // mint
|
||||||
|
};
|
||||||
|
|
||||||
// ── Home button (DM / Backspace) — fixed indigo-purple gradient ──
|
// ── Home button (DM / Backspace) — fixed indigo-purple gradient ──
|
||||||
export const HOME_GRADIENT: GradientEntry = grad('#6366f1', '#8b5cf6', '#8b5cf6');
|
export const HOME_GRADIENT: GradientEntry = grad('#6366f1', '#8b5cf6', '#8b5cf6');
|
||||||
|
|
||||||
@@ -69,8 +79,11 @@ export function getAvatarGradient(id?: string | null, name?: string, avatarColor
|
|||||||
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
|
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic gradient for a space icon. Prefers ID for stability; falls back to name. */
|
/** Deterministic gradient for a space icon. Uses stored avatarColor if available; falls back to hash. */
|
||||||
export function getSpaceGradient(id?: string | null, name?: string): GradientEntry {
|
export function getSpaceGradient(id?: string | null, name?: string, avatarColor?: string | null): GradientEntry {
|
||||||
|
if (avatarColor && avatarColor in SPACE_GRADIENT_MAP) {
|
||||||
|
return SPACE_GRADIENT_MAP[avatarColor as AvatarColor];
|
||||||
|
}
|
||||||
const key = id || name || 'unknown';
|
const key = id || name || 'unknown';
|
||||||
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
|
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface MutualSpace {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
avatarColor: string | null;
|
||||||
_instanceOrigin: string;
|
_instanceOrigin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user