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:
Jannis Braun
2026-03-11 18:34:33 +01:00
parent 373d685a55
commit 7572f165de
16 changed files with 148 additions and 27 deletions
@@ -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">