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:
@@ -222,7 +222,7 @@ function SpaceCard({
|
||||
const [joinError, setJoinError] = useState('');
|
||||
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 isJoined = space.joined === true;
|
||||
const originLabel = space._instanceOrigin
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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() {
|
||||
</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 ? (
|
||||
<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" />
|
||||
@@ -147,11 +155,8 @@ export function CreateSpaceModal() {
|
||||
</svg>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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" />
|
||||
<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>
|
||||
<span className="text-2xl font-bold">{(name || 'S').charAt(0).toUpperCase()}</span>
|
||||
<span className="text-[9px] font-medium opacity-60">Upload</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -175,6 +180,32 @@ export function CreateSpaceModal() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
|
||||
@@ -433,7 +433,7 @@ export function UserProfileModal() {
|
||||
) : (
|
||||
<div
|
||||
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()}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
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 { useAuthStore } from '../../../stores/authStore';
|
||||
import { useUIStore } from '../../../stores/uiStore';
|
||||
@@ -41,6 +43,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||
const bannerFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [avatarColorState, setAvatarColorState] = useState<AvatarColor | null>(space?.avatarColor ?? null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
@@ -59,6 +63,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
useEffect(() => {
|
||||
if (space) {
|
||||
setSpaceName(space.name);
|
||||
setAvatarColorState(space.avatarColor ?? null);
|
||||
setIconFilename(null);
|
||||
if (iconPreview) {
|
||||
URL.revokeObjectURL(iconPreview);
|
||||
@@ -70,14 +75,15 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
setBannerPreview(null);
|
||||
}
|
||||
}
|
||||
}, [space?.name, space?.icon, space?.banner]);
|
||||
}, [space?.name, space?.icon, space?.banner, space?.avatarColor]);
|
||||
|
||||
if (!space) return null;
|
||||
|
||||
const hasNameChange = spaceName.trim() !== space.name;
|
||||
const hasIconChange = iconFilename !== 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
|
||||
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
||||
@@ -170,7 +176,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
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 (hasIconChange) {
|
||||
updates.icon = iconFilename === '' ? '' : iconFilename!;
|
||||
@@ -178,6 +184,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
if (hasBannerChange) {
|
||||
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
|
||||
}
|
||||
if (hasAvatarColorChange) {
|
||||
updates.avatarColor = avatarColorState ?? '';
|
||||
}
|
||||
await updateSpace(spaceId, updates);
|
||||
setIconFilename(null);
|
||||
if (iconPreview) {
|
||||
@@ -200,6 +209,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
|
||||
const handleDiscard = () => {
|
||||
setSpaceName(space.name);
|
||||
setAvatarColorState(space.avatarColor ?? null);
|
||||
setIconFilename(null);
|
||||
if (iconPreview) {
|
||||
URL.revokeObjectURL(iconPreview);
|
||||
@@ -296,7 +306,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
) : (
|
||||
<div
|
||||
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()}
|
||||
</div>
|
||||
@@ -329,6 +339,33 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
</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 */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
|
||||
Reference in New Issue
Block a user