feat: Implement username change functionality

This commit is contained in:
gpt-engineer-app[bot]
2025-09-28 17:53:37 +00:00
parent 3e1ea57c11
commit 6b329f6887
6 changed files with 282 additions and 22 deletions

8
package-lock.json generated
View File

@@ -57,7 +57,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
"zod": "^4.1.11"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
@@ -7172,9 +7172,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -60,7 +60,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
"zod": "^4.1.11"
},
"devDependencies": {
"@eslint/js": "^9.32.0",

17
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { usernameSchema } from '@/lib/validation';
import { useDebounce } from './useDebounce';
export type UsernameValidationState = {
isValid: boolean;
isAvailable: boolean | null;
isChecking: boolean;
error: string | null;
};
export function useUsernameValidation(username: string, currentUsername?: string) {
const [state, setState] = useState<UsernameValidationState>({
isValid: false,
isAvailable: null,
isChecking: false,
error: null,
});
const debouncedUsername = useDebounce(username, 500);
useEffect(() => {
if (!debouncedUsername || debouncedUsername === currentUsername) {
setState({
isValid: debouncedUsername === currentUsername,
isAvailable: null,
isChecking: false,
error: null,
});
return;
}
// Validate format first
const validation = usernameSchema.safeParse(debouncedUsername);
if (!validation.success) {
setState({
isValid: false,
isAvailable: null,
isChecking: false,
error: validation.error.issues[0].message,
});
return;
}
// Check availability
setState(prev => ({ ...prev, isChecking: true, error: null }));
checkUsernameAvailability(validation.data);
}, [debouncedUsername, currentUsername]);
const checkUsernameAvailability = async (normalizedUsername: string) => {
try {
const { data, error } = await supabase
.from('profiles')
.select('username')
.eq('username', normalizedUsername)
.maybeSingle();
if (error) throw error;
const isAvailable = !data;
setState({
isValid: isAvailable,
isAvailable,
isChecking: false,
error: isAvailable ? null : 'Username is already taken',
});
} catch (error) {
setState({
isValid: false,
isAvailable: null,
isChecking: false,
error: 'Error checking username availability',
});
}
};
return state;
}

17
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const usernameSchema = z
.string()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username must be less than 30 characters')
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens')
.regex(/^[a-zA-Z0-9]/, 'Username must start with a letter or number')
.transform(val => val.toLowerCase());
export const profileEditSchema = z.object({
username: usernameSchema,
display_name: z.string().max(100, 'Display name must be less than 100 characters').optional(),
bio: z.string().max(500, 'Bio must be less than 500 characters').optional(),
});
export type ProfileEditForm = z.infer<typeof profileEditSchema>;

View File

@@ -10,7 +10,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { useAuth } from '@/hooks/useAuth';
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
import {
User,
MapPin,
@@ -22,12 +24,16 @@ import {
Edit3,
Save,
X,
ArrowLeft
ArrowLeft,
Check,
AlertCircle,
Loader2
} from 'lucide-react';
import { Profile as ProfileType } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { PhotoUpload } from '@/components/upload/PhotoUpload';
import { profileEditSchema } from '@/lib/validation';
export default function Profile() {
const { username } = useParams<{ username?: string }>();
@@ -39,12 +45,18 @@ export default function Profile() {
const [editing, setEditing] = useState(false);
const [currentUser, setCurrentUser] = useState<any>(null);
const [editForm, setEditForm] = useState({
username: '',
display_name: '',
bio: '',
});
const [showUsernameDialog, setShowUsernameDialog] = useState(false);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [avatarUrl, setAvatarUrl] = useState<string>('');
const [avatarImageId, setAvatarImageId] = useState<string>('');
// Username validation
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
useEffect(() => {
getCurrentUser();
if (username) {
@@ -72,6 +84,7 @@ export default function Profile() {
if (data) {
setProfile(data as ProfileType);
setEditForm({
username: data.username || '',
display_name: data.display_name || '',
bio: data.bio || '',
});
@@ -110,6 +123,7 @@ export default function Profile() {
if (data) {
setProfile(data as ProfileType);
setEditForm({
username: data.username || '',
display_name: data.display_name || '',
bio: data.bio || '',
});
@@ -128,35 +142,80 @@ export default function Profile() {
}
};
const validateForm = () => {
const result = profileEditSchema.safeParse(editForm);
if (!result.success) {
const errors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
if (issue.path[0]) {
errors[issue.path[0] as string] = issue.message;
}
});
setFormErrors(errors);
return false;
}
if (!usernameValidation.isValid && editForm.username !== profile?.username) {
setFormErrors({ username: usernameValidation.error || 'Invalid username' });
return false;
}
setFormErrors({});
return true;
};
const handleSaveProfile = async () => {
if (!profile || !currentUser) return;
if (!validateForm()) return;
const usernameChanged = editForm.username !== profile.username;
if (usernameChanged && !showUsernameDialog) {
setShowUsernameDialog(true);
return;
}
try {
const { error } = await supabase
.from('profiles')
.update({
const updateData: any = {
display_name: editForm.display_name,
bio: editForm.bio,
avatar_url: avatarUrl,
avatar_image_id: avatarImageId
})
};
if (usernameChanged) {
updateData.username = editForm.username;
}
const { error } = await supabase
.from('profiles')
.update(updateData)
.eq('user_id', currentUser.id);
if (error) throw error;
setProfile(prev => prev ? {
...prev,
display_name: editForm.display_name,
bio: editForm.bio,
avatar_url: avatarUrl,
avatar_image_id: avatarImageId
...updateData
} : null);
setEditing(false);
setShowUsernameDialog(false);
if (usernameChanged) {
toast({
title: "Profile updated",
description: "Your username and profile URL have been updated successfully.",
});
// Navigate to new username URL
navigate(`/profile/${editForm.username}`);
} else {
toast({
title: "Profile updated",
description: "Your profile has been updated successfully.",
});
}
} catch (error: any) {
toast({
variant: "destructive",
@@ -166,6 +225,11 @@ export default function Profile() {
}
};
const confirmUsernameChange = () => {
setShowUsernameDialog(false);
handleSaveProfile();
};
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
if (!currentUser || !urls[0]) return;
@@ -298,6 +362,42 @@ export default function Profile() {
<div className="flex-1">
{editing && isOwnProfile ? (
<div className="space-y-4">
<div>
<Label htmlFor="username">Username</Label>
<div className="relative">
<Input
id="username"
value={editForm.username}
onChange={(e) => setEditForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="your_username"
className={`pr-10 ${formErrors.username ? 'border-destructive' : ''}`}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{usernameValidation.isChecking ? (
<Loader2 className="w-4 h-4 text-muted-foreground animate-spin" />
) : editForm.username === profile?.username ? (
<Check className="w-4 h-4 text-muted-foreground" />
) : usernameValidation.isValid ? (
<Check className="w-4 h-4 text-green-500" />
) : usernameValidation.error ? (
<AlertCircle className="w-4 h-4 text-destructive" />
) : null}
</div>
</div>
{formErrors.username && (
<p className="text-sm text-destructive mt-1">{formErrors.username}</p>
)}
{usernameValidation.error && editForm.username !== profile?.username && (
<p className="text-sm text-destructive mt-1">{usernameValidation.error}</p>
)}
{usernameValidation.isValid && editForm.username !== profile?.username && (
<p className="text-sm text-green-600 mt-1">Username is available!</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Your profile URL will be /profile/{editForm.username}
</p>
</div>
<div>
<Label htmlFor="display_name">Display Name</Label>
<Input
@@ -305,7 +405,11 @@ export default function Profile() {
value={editForm.display_name}
onChange={(e) => setEditForm(prev => ({ ...prev, display_name: e.target.value }))}
placeholder="Your display name"
className={formErrors.display_name ? 'border-destructive' : ''}
/>
{formErrors.display_name && (
<p className="text-sm text-destructive mt-1">{formErrors.display_name}</p>
)}
</div>
<div>
@@ -316,17 +420,33 @@ export default function Profile() {
onChange={(e) => setEditForm(prev => ({ ...prev, bio: e.target.value }))}
placeholder="Tell us about yourself..."
rows={3}
className={formErrors.bio ? 'border-destructive' : ''}
/>
{formErrors.bio && (
<p className="text-sm text-destructive mt-1">{formErrors.bio}</p>
)}
</div>
<div className="flex gap-2">
<Button onClick={handleSaveProfile} size="sm">
<Button
onClick={handleSaveProfile}
size="sm"
disabled={usernameValidation.isChecking || (editForm.username !== profile?.username && !usernameValidation.isValid)}
>
<Save className="w-4 h-4 mr-2" />
Save Changes
</Button>
<Button
variant="outline"
onClick={() => setEditing(false)}
onClick={() => {
setEditing(false);
setFormErrors({});
setEditForm({
username: profile?.username || '',
display_name: profile?.display_name || '',
bio: profile?.bio || '',
});
}}
size="sm"
>
<X className="w-4 h-4 mr-2" />
@@ -493,6 +613,32 @@ export default function Profile() {
</TabsContent>
</Tabs>
</main>
{/* Username Change Confirmation Dialog */}
<AlertDialog open={showUsernameDialog} onOpenChange={setShowUsernameDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Change Username?</AlertDialogTitle>
<AlertDialogDescription>
Changing your username will also change your profile URL from{' '}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
/profile/{profile?.username}
</code>{' '}
to{' '}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
/profile/{editForm.username}
</code>
. This means any existing bookmarks or links to your profile will no longer work.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUsernameChange}>
Change Username
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}