mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
feat: Implement username change functionality
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -57,7 +57,7 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^3.25.76"
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
@@ -7172,9 +7172,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^3.25.76"
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
|||||||
17
src/hooks/useDebounce.ts
Normal file
17
src/hooks/useDebounce.ts
Normal 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;
|
||||||
|
}
|
||||||
80
src/hooks/useUsernameValidation.ts
Normal file
80
src/hooks/useUsernameValidation.ts
Normal 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
17
src/lib/validation.ts
Normal 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>;
|
||||||
@@ -10,7 +10,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
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 { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -22,12 +24,16 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Save,
|
Save,
|
||||||
X,
|
X,
|
||||||
ArrowLeft
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Profile as ProfileType } from '@/types/database';
|
import { Profile as ProfileType } from '@/types/database';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||||
|
import { profileEditSchema } from '@/lib/validation';
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { username } = useParams<{ username?: string }>();
|
const { username } = useParams<{ username?: string }>();
|
||||||
@@ -39,12 +45,18 @@ export default function Profile() {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
|
username: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
});
|
});
|
||||||
|
const [showUsernameDialog, setShowUsernameDialog] = useState(false);
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
||||||
const [avatarImageId, setAvatarImageId] = useState<string>('');
|
const [avatarImageId, setAvatarImageId] = useState<string>('');
|
||||||
|
|
||||||
|
// Username validation
|
||||||
|
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCurrentUser();
|
getCurrentUser();
|
||||||
if (username) {
|
if (username) {
|
||||||
@@ -72,6 +84,7 @@ export default function Profile() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setProfile(data as ProfileType);
|
setProfile(data as ProfileType);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
username: data.username || '',
|
||||||
display_name: data.display_name || '',
|
display_name: data.display_name || '',
|
||||||
bio: data.bio || '',
|
bio: data.bio || '',
|
||||||
});
|
});
|
||||||
@@ -110,6 +123,7 @@ export default function Profile() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setProfile(data as ProfileType);
|
setProfile(data as ProfileType);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
username: data.username || '',
|
||||||
display_name: data.display_name || '',
|
display_name: data.display_name || '',
|
||||||
bio: data.bio || '',
|
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 () => {
|
const handleSaveProfile = async () => {
|
||||||
if (!profile || !currentUser) return;
|
if (!profile || !currentUser) return;
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
const usernameChanged = editForm.username !== profile.username;
|
||||||
|
|
||||||
|
if (usernameChanged && !showUsernameDialog) {
|
||||||
|
setShowUsernameDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const updateData: any = {
|
||||||
.from('profiles')
|
|
||||||
.update({
|
|
||||||
display_name: editForm.display_name,
|
display_name: editForm.display_name,
|
||||||
bio: editForm.bio,
|
bio: editForm.bio,
|
||||||
avatar_url: avatarUrl,
|
avatar_url: avatarUrl,
|
||||||
avatar_image_id: avatarImageId
|
avatar_image_id: avatarImageId
|
||||||
})
|
};
|
||||||
|
|
||||||
|
if (usernameChanged) {
|
||||||
|
updateData.username = editForm.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(updateData)
|
||||||
.eq('user_id', currentUser.id);
|
.eq('user_id', currentUser.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setProfile(prev => prev ? {
|
setProfile(prev => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
display_name: editForm.display_name,
|
...updateData
|
||||||
bio: editForm.bio,
|
|
||||||
avatar_url: avatarUrl,
|
|
||||||
avatar_image_id: avatarImageId
|
|
||||||
} : null);
|
} : null);
|
||||||
|
|
||||||
setEditing(false);
|
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({
|
toast({
|
||||||
title: "Profile updated",
|
title: "Profile updated",
|
||||||
description: "Your profile has been updated successfully.",
|
description: "Your profile has been updated successfully.",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -166,6 +225,11 @@ export default function Profile() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmUsernameChange = () => {
|
||||||
|
setShowUsernameDialog(false);
|
||||||
|
handleSaveProfile();
|
||||||
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
|
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
|
||||||
if (!currentUser || !urls[0]) return;
|
if (!currentUser || !urls[0]) return;
|
||||||
|
|
||||||
@@ -298,6 +362,42 @@ export default function Profile() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{editing && isOwnProfile ? (
|
{editing && isOwnProfile ? (
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<Label htmlFor="display_name">Display Name</Label>
|
<Label htmlFor="display_name">Display Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -305,7 +405,11 @@ export default function Profile() {
|
|||||||
value={editForm.display_name}
|
value={editForm.display_name}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, display_name: e.target.value }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, display_name: e.target.value }))}
|
||||||
placeholder="Your display name"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -316,17 +420,33 @@ export default function Profile() {
|
|||||||
onChange={(e) => setEditForm(prev => ({ ...prev, bio: e.target.value }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, bio: e.target.value }))}
|
||||||
placeholder="Tell us about yourself..."
|
placeholder="Tell us about yourself..."
|
||||||
rows={3}
|
rows={3}
|
||||||
|
className={formErrors.bio ? 'border-destructive' : ''}
|
||||||
/>
|
/>
|
||||||
|
{formErrors.bio && (
|
||||||
|
<p className="text-sm text-destructive mt-1">{formErrors.bio}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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 className="w-4 h-4 mr-2" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setFormErrors({});
|
||||||
|
setEditForm({
|
||||||
|
username: profile?.username || '',
|
||||||
|
display_name: profile?.display_name || '',
|
||||||
|
bio: profile?.bio || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 mr-2" />
|
<X className="w-4 h-4 mr-2" />
|
||||||
@@ -493,6 +613,32 @@ export default function Profile() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user