Settings Profile

PreviousNext

Manage profile information, avatar, and social links.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/settings/settings-profile.tsx
"use client";

import { Camera, Loader2, Save, X } from "lucide-react";
import Image from "next/image";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/registry/new-york/ui/field";
import {
  InputGroup,
  InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Separator } from "@/registry/new-york/ui/separator";
import { Textarea } from "@/registry/new-york/ui/textarea";

export interface SocialLink {
  platform: string;
  url: string;
}

export interface ProfileData {
  name: string;
  email: string;
  bio?: string;
  location?: string;
  website?: string;
  avatar?: string;
  socialLinks?: SocialLink[];
}

export interface SettingsProfileProps {
  profile?: ProfileData;
  onSave?: (data: ProfileData) => Promise<void>;
  onEmailChange?: (newEmail: string, currentPassword: string) => Promise<void>;
  onAvatarUpload?: (file: File) => Promise<string>;
  onAvatarRemove?: () => Promise<void>;
  className?: string;
  showEmailVerification?: boolean;
}

const defaultSocialPlatforms = [
  {
    id: "twitter",
    label: "Twitter/X",
    placeholder: "https://x.com/preetsuthar17",
  },
  {
    id: "github",
    label: "GitHub",
    placeholder: "https://github.com/preetsuthar17",
  },
  {
    id: "linkedin",
    label: "LinkedIn",
    placeholder: "https://linkedin.com/in/preetsuthar17",
  },
  { id: "website", label: "Website", placeholder: "https://preetsuthar.me" },
];

export default function SettingsProfile({
  profile,
  onSave,
  onEmailChange,
  onAvatarUpload,
  onAvatarRemove,
  className,
  showEmailVerification = true,
}: SettingsProfileProps) {
  const [isSaving, setIsSaving] = useState(false);
  const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
  const [isChangingEmail, setIsChangingEmail] = useState(false);
  const [showEmailChangeForm, setShowEmailChangeForm] = useState(false);
  const [avatarPreview, setAvatarPreview] = useState<string | null>(
    profile?.avatar || null
  );
  const [errors, setErrors] = useState<Record<string, string>>({});

  const [formData, setFormData] = useState<ProfileData>({
    name: profile?.name || "",
    email: profile?.email || "",
    bio: profile?.bio || "",
    location: profile?.location || "",
    website: profile?.website || "",
    socialLinks: profile?.socialLinks || [],
  });

  const [emailChangeData, setEmailChangeData] = useState({
    newEmail: "",
    currentPassword: "",
  });

  const fileInputRef = useRef<HTMLInputElement>(null);
  const avatarFileRef = useRef<File | null>(null);

  const handleAvatarSelect = useCallback(
    async (file: File) => {
      if (!file.type.startsWith("image/")) {
        setErrors({ avatar: "Please select an image file" });
        return;
      }

      if (file.size > 5 * 1024 * 1024) {
        setErrors({ avatar: "Image size must be less than 5MB" });
        return;
      }

      avatarFileRef.current = file;
      const reader = new FileReader();
      reader.onloadend = () => {
        setAvatarPreview(reader.result as string);
      };
      reader.readAsDataURL(file);

      if (onAvatarUpload) {
        setIsUploadingAvatar(true);
        try {
          const avatarUrl = await onAvatarUpload(file);
          setAvatarPreview(avatarUrl);
          setErrors({});
        } catch (error) {
          setErrors({
            avatar:
              error instanceof Error
                ? error.message
                : "Failed to upload avatar",
          });
        } finally {
          setIsUploadingAvatar(false);
        }
      }
    },
    [onAvatarUpload]
  );

  const handleAvatarClick = () => {
    fileInputRef.current?.click();
  };

  const handleAvatarRemove = async () => {
    if (onAvatarRemove) {
      setIsUploadingAvatar(true);
      try {
        await onAvatarRemove();
        setAvatarPreview(null);
        avatarFileRef.current = null;
        setErrors({});
      } catch (error) {
        setErrors({
          avatar:
            error instanceof Error ? error.message : "Failed to remove avatar",
        });
      } finally {
        setIsUploadingAvatar(false);
      }
    } else {
      setAvatarPreview(null);
      avatarFileRef.current = null;
    }
  };

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();

      const file = e.dataTransfer.files[0];
      if (file) {
        handleAvatarSelect(file);
      }
    },
    [handleAvatarSelect]
  );

  const handleFileInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (file) {
        handleAvatarSelect(file);
      }
    },
    [handleAvatarSelect]
  );

  const handleSave = async () => {
    setErrors({});

    if (!formData.name.trim()) {
      setErrors({ name: "Name is required" });
      return;
    }

    if (!formData.email.trim()) {
      setErrors({ email: "Email is required" });
      return;
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(formData.email)) {
      setErrors({ email: "Please enter a valid email address" });
      return;
    }

    if (formData.website && formData.website.trim()) {
      try {
        new URL(formData.website);
      } catch {
        setErrors({ website: "Please enter a valid URL" });
        return;
      }
    }

    setIsSaving(true);
    try {
      await onSave?.(formData);
    } catch (error) {
      setErrors({
        _general:
          error instanceof Error ? error.message : "Failed to save profile",
      });
    } finally {
      setIsSaving(false);
    }
  };

  const handleEmailChange = async () => {
    setErrors({});

    if (!emailChangeData.newEmail.trim()) {
      setErrors({ newEmail: "New email is required" });
      return;
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(emailChangeData.newEmail)) {
      setErrors({ newEmail: "Please enter a valid email address" });
      return;
    }

    if (!emailChangeData.currentPassword.trim()) {
      setErrors({ currentPassword: "Current password is required" });
      return;
    }

    setIsChangingEmail(true);
    try {
      await onEmailChange?.(
        emailChangeData.newEmail,
        emailChangeData.currentPassword
      );
      setFormData((prev) => ({ ...prev, email: emailChangeData.newEmail }));
      setEmailChangeData({ newEmail: "", currentPassword: "" });
      setShowEmailChangeForm(false);
      setErrors({});
    } catch (error) {
      setErrors({
        emailChange:
          error instanceof Error ? error.message : "Failed to change email",
      });
    } finally {
      setIsChangingEmail(false);
    }
  };

  const updateSocialLink = (platform: string, url: string) => {
    setFormData((prev) => {
      const socialLinks = prev.socialLinks || [];
      const existingIndex = socialLinks.findIndex(
        (link) => link.platform === platform
      );
      const updatedLinks = [...socialLinks];

      if (url.trim()) {
        if (existingIndex >= 0) {
          updatedLinks[existingIndex] = { platform, url };
        } else {
          updatedLinks.push({ platform, url });
        }
      } else if (existingIndex >= 0) {
        updatedLinks.splice(existingIndex, 1);
      }

      return { ...prev, socialLinks: updatedLinks };
    });
  };

  const getSocialLink = (platform: string): string =>
    formData.socialLinks?.find((link) => link.platform === platform)?.url || "";

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            <CardTitle className="wrap-break-word">Profile Settings</CardTitle>
            <CardDescription className="wrap-break-word">
              Manage your profile information and avatar
            </CardDescription>
          </div>
          <div className="flex shrink-0 gap-2">
            <Button
              className="w-full sm:w-auto"
              disabled={isSaving}
              onClick={handleSave}
              type="button"
            >
              {isSaving ? (
                <>
                  <Loader2 className="size-4 animate-spin" />
                  <span className="whitespace-nowrap">Saving…</span>
                </>
              ) : (
                <>
                  <Save className="size-4" />
                  <span className="whitespace-nowrap">Save Changes</span>
                </>
              )}
            </Button>
          </div>
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {errors._general && (
            <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
              <p className="text-destructive text-sm">{errors._general}</p>
            </div>
          )}

          {/* Avatar Upload */}
          <div className="flex flex-col gap-4">
            <FieldLabel>Profile Picture</FieldLabel>
            <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
              <div
                className={cn(
                  "relative flex size-24 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed transition-colors",
                  isUploadingAvatar
                    ? "border-primary bg-primary/5"
                    : "border-muted bg-muted/30 hover:border-primary/50"
                )}
                onClick={handleAvatarClick}
                onDragOver={handleDragOver}
                onDrop={handleDrop}
              >
                {avatarPreview ? (
                  <>
                    <Image
                      alt="Profile avatar"
                      className="object-cover"
                      fill
                      sizes="96px"
                      src={avatarPreview}
                      unoptimized
                    />
                    {isUploadingAvatar && (
                      <div className="absolute inset-0 flex items-center justify-center bg-background/80">
                        <Loader2 className="size-6 animate-spin text-primary" />
                      </div>
                    )}
                  </>
                ) : (
                  <Camera className="size-8 text-muted-foreground" />
                )}
              </div>
              <div className="flex flex-1 flex-col gap-2">
                <div className="flex flex-col gap-2 sm:flex-row">
                  <Button
                    onClick={handleAvatarClick}
                    type="button"
                    variant="outline"
                  >
                    <Camera className="size-4" />
                    {avatarPreview ? "Change Photo" : "Upload Photo"}
                  </Button>
                  {avatarPreview && (
                    <Button
                      onClick={handleAvatarRemove}
                      type="button"
                      variant="outline"
                    >
                      <X className="size-4" />
                      Remove
                    </Button>
                  )}
                </div>
                <p className="text-muted-foreground text-xs">
                  Drag and drop an image here, or click to browse. Max size: 5MB
                </p>
                {errors.avatar && (
                  <p className="text-destructive text-xs">{errors.avatar}</p>
                )}
              </div>
              <input
                accept="image/*"
                className="hidden"
                onChange={handleFileInputChange}
                ref={fileInputRef}
                type="file"
              />
            </div>
          </div>

          <Separator />

          {/* Basic Information */}
          <div className="flex flex-col gap-4">
            <Field>
              <FieldLabel htmlFor="name">
                Name <span className="text-destructive">*</span>
              </FieldLabel>
              <FieldContent>
                <InputGroup>
                  <InputGroupInput
                    id="name"
                    onChange={(e) =>
                      setFormData((prev) => ({ ...prev, name: e.target.value }))
                    }
                    placeholder="Your full name"
                    value={formData.name}
                  />
                </InputGroup>
                {errors.name && <FieldError>{errors.name}</FieldError>}
              </FieldContent>
            </Field>

            <Field>
              <FieldLabel htmlFor="email">Email</FieldLabel>
              <FieldContent>
                <div className="flex flex-col gap-2">
                  <InputGroup>
                    <InputGroupInput
                      disabled={showEmailChangeForm}
                      id="email"
                      onChange={(e) =>
                        setFormData((prev) => ({
                          ...prev,
                          email: e.target.value,
                        }))
                      }
                      placeholder="your.email@example.com"
                      type="email"
                      value={formData.email}
                    />
                  </InputGroup>
                  {showEmailVerification && (
                    <Button
                      className="w-full sm:w-auto"
                      onClick={() =>
                        setShowEmailChangeForm(!showEmailChangeForm)
                      }
                      type="button"
                      variant="outline"
                    >
                      {showEmailChangeForm ? "Cancel" : "Change Email"}
                    </Button>
                  )}
                </div>
                {errors.email && <FieldError>{errors.email}</FieldError>}
              </FieldContent>
            </Field>

            {showEmailChangeForm && (
              <div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
                <Field>
                  <FieldLabel htmlFor="new-email">
                    New Email <span className="text-destructive">*</span>
                  </FieldLabel>
                  <FieldContent>
                    <InputGroup>
                      <InputGroupInput
                        id="new-email"
                        onChange={(e) =>
                          setEmailChangeData((prev) => ({
                            ...prev,
                            newEmail: e.target.value,
                          }))
                        }
                        placeholder="new.email@example.com"
                        type="email"
                        value={emailChangeData.newEmail}
                      />
                    </InputGroup>
                    {errors.newEmail && (
                      <FieldError>{errors.newEmail}</FieldError>
                    )}
                  </FieldContent>
                </Field>

                <Field>
                  <FieldLabel htmlFor="current-password">
                    Current Password <span className="text-destructive">*</span>
                  </FieldLabel>
                  <FieldContent>
                    <InputGroup>
                      <InputGroupInput
                        id="current-password"
                        onChange={(e) =>
                          setEmailChangeData((prev) => ({
                            ...prev,
                            currentPassword: e.target.value,
                          }))
                        }
                        placeholder="Enter your current password"
                        type="password"
                        value={emailChangeData.currentPassword}
                      />
                    </InputGroup>
                    {errors.currentPassword && (
                      <FieldError>{errors.currentPassword}</FieldError>
                    )}
                    {errors.emailChange && (
                      <FieldError>{errors.emailChange}</FieldError>
                    )}
                  </FieldContent>
                </Field>

                <Button
                  className="w-full sm:w-auto"
                  disabled={isChangingEmail}
                  onClick={handleEmailChange}
                  type="button"
                >
                  {isChangingEmail ? (
                    <>
                      <Loader2 className="size-4 animate-spin" />
                      Changing…
                    </>
                  ) : (
                    "Update Email"
                  )}
                </Button>
              </div>
            )}

            <Field>
              <FieldLabel htmlFor="bio">Bio</FieldLabel>
              <FieldContent>
                <Textarea
                  id="bio"
                  onChange={(e) =>
                    setFormData((prev) => ({ ...prev, bio: e.target.value }))
                  }
                  placeholder="Tell us about yourself..."
                  rows={4}
                  value={formData.bio || ""}
                />
                <FieldDescription>
                  A brief description about yourself (max 500 characters)
                </FieldDescription>
              </FieldContent>
            </Field>

            <Field>
              <FieldLabel htmlFor="location">Location</FieldLabel>
              <FieldContent>
                <InputGroup>
                  <InputGroupInput
                    id="location"
                    onChange={(e) =>
                      setFormData((prev) => ({
                        ...prev,
                        location: e.target.value,
                      }))
                    }
                    placeholder="City, Country"
                    value={formData.location || ""}
                  />
                </InputGroup>
              </FieldContent>
            </Field>

            <Field>
              <FieldLabel htmlFor="website">Website</FieldLabel>
              <FieldContent>
                <InputGroup>
                  <InputGroupInput
                    id="website"
                    onChange={(e) =>
                      setFormData((prev) => ({
                        ...prev,
                        website: e.target.value,
                      }))
                    }
                    placeholder="https://example.com"
                    type="url"
                    value={formData.website || ""}
                  />
                </InputGroup>
                {errors.website && <FieldError>{errors.website}</FieldError>}
              </FieldContent>
            </Field>
          </div>

          <Separator />

          {/* Social Links */}
          <div className="flex flex-col gap-4">
            <FieldLabel>Social Links</FieldLabel>
            <div className="flex flex-col gap-3">
              {defaultSocialPlatforms.map((platform) => (
                <Field key={platform.id}>
                  <FieldLabel htmlFor={`social-${platform.id}`}>
                    {platform.label}
                  </FieldLabel>
                  <FieldContent>
                    <InputGroup>
                      <InputGroupInput
                        id={`social-${platform.id}`}
                        onChange={(e) =>
                          updateSocialLink(platform.id, e.target.value)
                        }
                        placeholder={platform.placeholder}
                        type="url"
                        value={getSocialLink(platform.id)}
                      />
                    </InputGroup>
                  </FieldContent>
                </Field>
              ))}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @hextaui/settings-profile

Usage

import { SettingsProfile } from "@/components/ui/settings-profile"
<SettingsProfile />