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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user