feat: two-step registration with avatar/color picker, fix focus ring clipping

Refactor RegisterPage into a two-step flow: credentials first, then
personalization (display name, avatar upload, avatar color). Replace the
dual-panel sliding layout with conditional rendering and CSS keyframe
animations to eliminate overflow-hidden clipping of focus rings.

Supporting changes:
- Server accepts avatarColor on registration
- Auth store resets all user-scoped stores on login/register/logout
- Voice store gains resetSession() for full session cleanup
- Sync presence status to federated instances
- Propagate presence_update to socialStore regardless of origin
This commit is contained in:
Jannis Braun
2026-03-11 03:43:20 +01:00
parent 10be7a44f8
commit c8e2945c07
8 changed files with 364 additions and 84 deletions
+290 -68
View File
@@ -1,29 +1,62 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { ImageCropModal } from '../ui/ImageCropModal';
import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
import { AVATAR_COLORS } from '@backspace/shared';
import type { AvatarColor } from '@backspace/shared';
import { api } from '../../api/client';
export function RegisterPage() {
// Step state
const [step, setStep] = useState<1 | 2>(1);
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
// Step 1 fields
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Step 2 fields
const [displayName, setDisplayName] = useState('');
const [avatarColor, setAvatarColor] = useState<AvatarColor>(
() => AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint'
);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarCropSrc, setAvatarCropSrc] = useState<string | null>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const updateProfile = useAuthStore((s) => s.updateProfile);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
};
}, [avatarPreview]);
// ── Step 1 validation ──
const handleContinue = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) {
const trimmed = username.trim();
if (!trimmed) {
setError('Username is required');
return;
}
if (username.trim().length < 3 || username.trim().length > 32) {
if (trimmed.length < 3 || trimmed.length > 32) {
setError('Username must be between 3 and 32 characters');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
@@ -35,86 +68,275 @@ export function RegisterPage() {
setError('Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setDirection('forward');
setStep(2);
};
// ── Avatar file selection ──
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setAvatarCropSrc(reader.result as string);
reader.readAsDataURL(file);
if (avatarInputRef.current) avatarInputRef.current.value = '';
};
const handleAvatarCropComplete = (blob: Blob) => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
const previewUrl = URL.createObjectURL(blob);
setAvatarPreview(previewUrl);
setAvatarFile(new File([blob], 'avatar.png', { type: 'image/png' }));
setAvatarCropSrc(null);
};
// ── Registration ──
const handleRegister = async (skip: boolean) => {
setError('');
setIsRegistering(true);
try {
await register(username.trim(), password, displayName.trim() || undefined);
const dn = skip ? undefined : displayName.trim() || undefined;
const ac = skip ? undefined : avatarColor;
await register(username.trim(), password, dn, ac);
// Upload avatar if chosen (non-fatal — account already created)
if (!skip && avatarFile) {
try {
const attachment = await api.uploads.upload(avatarFile);
await updateProfile({ avatar: attachment.filename });
} catch {
// Avatar upload failed — user can set it later in settings
}
}
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
setIsRegistering(false);
}
};
const effectiveDisplayName = displayName.trim() || username.trim();
const initial = effectiveDisplayName.charAt(0).toUpperCase();
const gradient = AVATAR_GRADIENT_MAP[avatarColor];
return (
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 overflow-hidden">
{/* Progress dots */}
<div className="flex justify-center gap-2 mb-5">
<div className={`w-2 h-2 rounded-full transition-colors duration-300 ${step === 1 ? 'bg-accent-primary' : 'bg-txt-tertiary/30'}`} />
<div className={`w-2 h-2 rounded-full transition-colors duration-300 ${step === 2 ? 'bg-accent-primary' : 'bg-txt-tertiary/30'}`} />
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
{step === 1 ? (
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Username <span className="text-txt-danger">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoFocus
autoComplete="username"
/>
<form onSubmit={handleContinue}>
{error && (
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Username <span className="text-txt-danger">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoFocus
autoComplete="username"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Password <span className="text-txt-danger">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoComplete="new-password"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Confirm Password <span className="text-txt-danger">*</span>
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoComplete="new-password"
/>
</div>
<button
type="submit"
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
>
Continue
</button>
<p className="mt-3 text-sm text-txt-tertiary">
Already have an account?{' '}
<Link to="/login" className="text-accent-primary hover:underline">
Log In
</Link>
</p>
</form>
</div>
) : (
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoComplete="name"
/>
{error && (
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Avatar preview */}
<div className="flex flex-col items-center mb-5">
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="relative group"
>
<Avatar
src={avatarPreview}
name={effectiveDisplayName}
size={80}
avatarColor={avatarColor}
/>
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
</div>
</button>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="text-xs text-accent-primary hover:underline mt-2"
>
Upload photo
</button>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarSelect}
/>
</div>
{/* Display Name */}
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={username.trim() || 'Display name'}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all placeholder:text-txt-tertiary"
autoComplete="name"
/>
</div>
{/* Avatar Color Picker */}
<div className="mb-6">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Avatar Color
</label>
<div className="flex gap-2.5 justify-center">
{AVATAR_COLORS.map((key) => {
const entry = AVATAR_GRADIENT_MAP[key];
return (
<button
key={key}
type="button"
onClick={() => setAvatarColor(key)}
className="w-8 h-8 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>
{/* Actions */}
<button
type="button"
onClick={() => handleRegister(false)}
disabled={isRegistering}
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRegistering ? 'Creating account...' : 'Get Started'}
</button>
<div className="flex items-center justify-between mt-3">
<button
type="button"
onClick={() => { setError(''); setDirection('back'); setStep(1); }}
disabled={isRegistering}
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
>
Back
</button>
<button
type="button"
onClick={() => handleRegister(true)}
disabled={isRegistering}
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
>
Skip for now
</button>
</div>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Password <span className="text-txt-danger">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Continue'}
</button>
<p className="mt-3 text-sm text-txt-tertiary">
Already have an account?{' '}
<Link to="/login" className="text-accent-primary hover:underline">
Log In
</Link>
</p>
</form>
)}
</div>
{/* Image Crop Modal */}
{avatarCropSrc && (
<ImageCropModal
isOpen={true}
onClose={() => setAvatarCropSrc(null)}
imageSrc={avatarCropSrc}
onCropComplete={handleAvatarCropComplete}
title="Crop Avatar"
aspectRatio={1}
cropShape="round"
/>
)}
</div>
);
}
+1 -3
View File
@@ -305,9 +305,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'presence_update':
updateMemberPresence(event.userId, event.status);
if (isHome) {
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
}
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
break;
case 'user_updated': {
+17 -9
View File
@@ -14,7 +14,7 @@ interface AuthState {
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, displayName?: string) => Promise<void>;
register: (username: string, password: string, displayName?: string, avatarColor?: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
@@ -22,6 +22,15 @@ interface AuthState {
clearError: () => void;
}
/** Reset all user-scoped stores to prevent data leaking between sessions */
function resetUserStores() {
useChatStore.getState().clearAllMessages();
useSpaceStore.getState().populateFromReady('', [], [], []);
useSocialStore.getState().reset();
useVoiceStore.getState().resetSession();
useInstanceStore.getState().reset();
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: localStorage.getItem('backspace_token'),
user: null,
@@ -32,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
try {
const response = await api.auth.login({ username, password });
resetUserStores();
localStorage.setItem('backspace_token', response.token);
set({ token: response.token, user: response.user, isLoading: false });
// Auto-connect to remote instances (fire-and-forget)
@@ -42,12 +52,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
register: async (username: string, password: string, displayName?: string) => {
register: async (username: string, password: string, displayName?: string, avatarColor?: string) => {
set({ isLoading: true, error: null });
try {
const response = await api.auth.register({ username, password, displayName });
const response = await api.auth.register({ username, password, displayName, avatarColor });
resetUserStores();
localStorage.setItem('backspace_token', response.token);
set({ token: response.token, user: response.user, isLoading: false });
// Auto-connect to remote instances (fire-and-forget)
useInstanceStore.getState().autoConnectAll().catch(() => {});
} catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
throw err;
@@ -56,12 +69,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => {
localStorage.removeItem('backspace_token');
// Clear all user-scoped state to prevent data leaking between sessions
useChatStore.getState().clearAllMessages();
useSpaceStore.getState().populateFromReady('', [], [], []);
useSocialStore.getState().reset();
useVoiceStore.getState().clearAllVoiceUsers();
useInstanceStore.getState().reset();
resetUserStores();
set({ token: null, user: null });
},
+33
View File
@@ -104,6 +104,7 @@ interface VoiceState {
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void;
resetSession: () => void;
clearVoiceUsersForOrigin: (origin: string) => void;
leaveVoice: () => void;
handleForceDisconnect: () => void;
@@ -365,6 +366,38 @@ export const useVoiceStore = create<VoiceState>()(
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
resetSession: () => set({
// Connection state
voiceUsers: new Map(),
voiceUserStates: new Map(),
currentVoiceChannelId: null,
participants: [],
speakingParticipantIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
focusedParticipantId: null,
// Call state
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
// Per-session media state
isCameraOn: false,
isScreenSharing: false,
// Per-session maps
participantVolumes: new Map(),
participantMutes: new Map(),
deafenedUserIds: new Set(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
unwatchedCameras: new Set(),
// Server-enforced restrictions
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
}),
clearVoiceUsersForOrigin: (origin: string) => {
const { channelOriginMap } = useSpaceStore.getState();
set((state) => {
+15
View File
@@ -260,3 +260,18 @@
100% { background-color: transparent; }
}
.search-highlight { animation: search-flash 2s ease-out; }
@keyframes stepForward {
from { transform: translateX(24px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes stepBack {
from { transform: translateX(-24px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-step-forward {
animation: stepForward 0.25s ease-out;
}
.animate-step-back {
animation: stepBack 0.25s ease-out;
}
+3 -2
View File
@@ -30,6 +30,7 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
accentColor: homeUser.accentColor || undefined,
bio: homeUser.bio || undefined,
customStatus: homeUser.customStatus || undefined,
status: homeUser.status || undefined,
};
// Sync avatar
@@ -66,8 +67,8 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
// ─── Incremental sync (profile update) ──────────────────────────────────────
/** Sync-eligible text fields (no status, replicatedInstances, homeUserId). */
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus'] as const;
/** Sync-eligible text fields (no replicatedInstances, homeUserId). */
const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus', 'status'] as const;
/**
* Push a partial profile update to all connected remote instances.