Web InnoventixFreeCode

Profile Settings

Original · free

account settings panel

byWeb InnoventixReact + Tailwind
profilesettingsprofiles
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/profile-settings.json
profile-settings.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type SectionId = "profile" | "notifications" | "privacy" | "sessions";

type Visibility = "public" | "team" | "private";

type Frequency = "daily" | "weekly" | "monthly";

type Status = "idle" | "saving" | "saved";

type Settings = {
  displayName: string;
  handle: string;
  bio: string;
  timezone: string;
  emailDigest: boolean;
  digestFrequency: Frequency;
  mentions: boolean;
  productNews: boolean;
  weeklyReport: boolean;
  visibility: Visibility;
  showActivity: boolean;
  indexProfile: boolean;
};

type Session = {
  id: string;
  device: string;
  browser: string;
  where: string;
  when: string;
  current: boolean;
};

const INITIAL: Settings = {
  displayName: "Nadia Ferreira",
  handle: "nadiaf",
  bio: "Design engineer at Northbeam. I maintain the component library and lose arguments about naming.",
  timezone: "Europe/Lisbon",
  emailDigest: true,
  digestFrequency: "weekly",
  mentions: true,
  productNews: false,
  weeklyReport: true,
  visibility: "team",
  showActivity: true,
  indexProfile: false,
};

const SESSIONS: Session[] = [
  {
    id: "s1",
    device: "MacBook Pro 16″",
    browser: "Chrome 141",
    where: "Lisbon, Portugal",
    when: "Active now",
    current: true,
  },
  {
    id: "s2",
    device: "iPhone 15 Pro",
    browser: "Safari",
    where: "Lisbon, Portugal",
    when: "2 hours ago",
    current: false,
  },
  {
    id: "s3",
    device: "ThinkPad X1",
    browser: "Edge 141",
    where: "Porto, Portugal",
    when: "Yesterday at 18:42",
    current: false,
  },
  {
    id: "s4",
    device: "iPad Air",
    browser: "Safari",
    where: "Madrid, Spain",
    when: "9 March at 11:07",
    current: false,
  },
];

const TIMEZONES: { value: string; label: string }[] = [
  { value: "Europe/Lisbon", label: "Lisbon — WEST (UTC+1)" },
  { value: "Europe/Berlin", label: "Berlin — CEST (UTC+2)" },
  { value: "America/New_York", label: "New York — EDT (UTC−4)" },
  { value: "Asia/Karachi", label: "Karachi — PKT (UTC+5)" },
  { value: "Asia/Tokyo", label: "Tokyo — JST (UTC+9)" },
];

const VISIBILITY_OPTIONS: { value: Visibility; title: string; detail: string }[] = [
  { value: "public", title: "Public", detail: "Anyone with the link, including logged-out visitors." },
  { value: "team", title: "Workspace", detail: "The 34 people in Northbeam. Not indexed anywhere else." },
  { value: "private", title: "Private", detail: "Only you and workspace admins can open your profile." },
];

const SECTION_FIELDS: Record<SectionId, (keyof Settings)[]> = {
  profile: ["displayName", "handle", "bio", "timezone"],
  notifications: ["emailDigest", "digestFrequency", "mentions", "productNews", "weeklyReport"],
  privacy: ["visibility", "showActivity", "indexProfile"],
  sessions: [],
};

const SECTIONS: { id: SectionId; label: string; hint: string; icon: ReactNode }[] = [
  {
    id: "profile",
    label: "Profile",
    hint: "Name, handle, bio",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
        <circle cx="12" cy="8" r="3.6" />
        <path d="M4.5 19.2a7.5 7.5 0 0 1 15 0" strokeLinecap="round" />
      </svg>
    ),
  },
  {
    id: "notifications",
    label: "Notifications",
    hint: "Email and digests",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
        <path d="M6 10a6 6 0 1 1 12 0c0 3.2.7 4.9 1.6 5.9.4.4.1 1.1-.5 1.1H4.9c-.6 0-.9-.7-.5-1.1C5.3 14.9 6 13.2 6 10Z" strokeLinejoin="round" />
        <path d="M10 20.2a2.3 2.3 0 0 0 4 0" strokeLinecap="round" />
      </svg>
    ),
  },
  {
    id: "privacy",
    label: "Privacy",
    hint: "Who sees what",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
        <path d="M12 3.2 19 6v5.4c0 4.3-2.9 7.6-7 9.4-4.1-1.8-7-5.1-7-9.4V6l7-2.8Z" strokeLinejoin="round" />
        <path d="m9.2 12.1 2 2 3.6-3.9" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    ),
  },
  {
    id: "sessions",
    label: "Sessions",
    hint: "Signed-in devices",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true" className="h-4 w-4">
        <rect x="3.2" y="5" width="17.6" height="11" rx="1.8" />
        <path d="M2 19.4h20" strokeLinecap="round" />
      </svg>
    ),
  },
];

const RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

function Toggle({
  checked,
  onChange,
  labelId,
  descId,
  disabled = false,
}: {
  checked: boolean;
  onChange: (next: boolean) => void;
  labelId: string;
  descId: string;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-labelledby={labelId}
      aria-describedby={descId}
      disabled={disabled}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${RING} ${
        disabled
          ? "cursor-not-allowed border-slate-200 bg-slate-100 dark:border-slate-800 dark:bg-slate-800/60"
          : checked
            ? "border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500"
            : "border-slate-300 bg-slate-200 hover:bg-slate-300 dark:border-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
      }`}
    >
      <span
        className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out ${
          checked ? "translate-x-6" : "translate-x-1"
        } ${disabled ? "opacity-60" : ""}`}
      />
    </button>
  );
}

function SwitchRow({
  title,
  detail,
  checked,
  onChange,
  disabled = false,
}: {
  title: string;
  detail: string;
  checked: boolean;
  onChange: (next: boolean) => void;
  disabled?: boolean;
}) {
  const uid = useId();
  const labelId = `${uid}-label`;
  const descId = `${uid}-desc`;
  return (
    <div className="flex items-start justify-between gap-6 border-b border-slate-200 py-4 last:border-b-0 dark:border-slate-800">
      <div className="min-w-0">
        <p
          id={labelId}
          className={`text-sm font-medium ${disabled ? "text-slate-400 dark:text-slate-600" : "text-slate-900 dark:text-slate-100"}`}
        >
          {title}
        </p>
        <p id={descId} className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
          {detail}
        </p>
      </div>
      <Toggle checked={checked} onChange={onChange} labelId={labelId} descId={descId} disabled={disabled} />
    </div>
  );
}

export default function ProfileSettings() {
  const reduced = useReducedMotion();
  const uid = useId();

  const [active, setActive] = useState<SectionId>("profile");
  const [settings, setSettings] = useState<Settings>(INITIAL);
  const [saved, setSaved] = useState<Settings>(INITIAL);
  const [status, setStatus] = useState<Status>("idle");
  const [sessions, setSessions] = useState<Session[]>(SESSIONS);
  const [announce, setAnnounce] = useState("");

  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const timers = useRef<number[]>([]);

  useEffect(() => {
    const list = timers.current;
    return () => {
      list.forEach((t) => window.clearTimeout(t));
    };
  }, []);

  const changedKeys = useMemo(
    () => (Object.keys(settings) as (keyof Settings)[]).filter((k) => settings[k] !== saved[k]),
    [settings, saved],
  );
  const dirty = changedKeys.length > 0;

  const dirtySections = useMemo(() => {
    const out: Record<SectionId, boolean> = { profile: false, notifications: false, privacy: false, sessions: false };
    (Object.keys(SECTION_FIELDS) as SectionId[]).forEach((id) => {
      out[id] = SECTION_FIELDS[id].some((f) => changedKeys.includes(f));
    });
    return out;
  }, [changedKeys]);

  const update = useCallback(<K extends keyof Settings>(key: K, value: Settings[K]) => {
    setSettings((prev) => ({ ...prev, [key]: value }));
  }, []);

  const handleSave = useCallback(() => {
    if (!dirty || status === "saving") return;
    setStatus("saving");
    const t1 = window.setTimeout(() => {
      setSaved(settings);
      setStatus("saved");
      setAnnounce(`${changedKeys.length} ${changedKeys.length === 1 ? "change" : "changes"} saved.`);
      const t2 = window.setTimeout(() => setStatus("idle"), 2400);
      timers.current.push(t2);
    }, 700);
    timers.current.push(t1);
  }, [dirty, status, settings, changedKeys.length]);

  const handleDiscard = useCallback(() => {
    setSettings(saved);
    setAnnounce("Changes discarded.");
  }, [saved]);

  const onTabKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
      const keys = ["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft", "Home", "End"];
      if (!keys.includes(event.key)) return;
      event.preventDefault();
      let next = index;
      if (event.key === "ArrowDown" || event.key === "ArrowRight") next = (index + 1) % SECTIONS.length;
      if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = (index - 1 + SECTIONS.length) % SECTIONS.length;
      if (event.key === "Home") next = 0;
      if (event.key === "End") next = SECTIONS.length - 1;
      setActive(SECTIONS[next].id);
      tabRefs.current[next]?.focus();
    },
    [],
  );

  const revoke = useCallback((session: Session) => {
    setSessions((prev) => prev.filter((s) => s.id !== session.id));
    setAnnounce(`Signed out of ${session.device}.`);
  }, []);

  const otherSessions = sessions.filter((s) => !s.current).length;

  const fade = {
    initial: { opacity: 0, y: reduced ? 0 : 8 },
    animate: { opacity: 1, y: 0 },
    exit: { opacity: 0, y: reduced ? 0 : -6 },
    transition: { duration: reduced ? 0 : 0.22, ease: "easeOut" as const },
  };

  const bioLeft = 180 - settings.bio.length;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes psx-pop {
          0% { transform: scale(.7); opacity: 0; }
          60% { transform: scale(1.08); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes psx-spin { to { transform: rotate(360deg); } }
        @keyframes psx-breathe {
          0%, 100% { opacity: .4; transform: scale(.82); }
          50% { opacity: 1; transform: scale(1); }
        }
        .psx-pop { animation: psx-pop .34s cubic-bezier(.2,.8,.3,1) both; }
        .psx-spin { animation: psx-spin .8s linear infinite; transform-origin: 50% 50%; }
        .psx-breathe { animation: psx-breathe 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .psx-pop, .psx-spin, .psx-breathe { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-8">
          <p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
            Northbeam account
          </p>
          <h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Settings
          </h2>
          <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Changes stay local until you save them. Signing a device out happens immediately and cannot be undone from
            this panel.
          </p>
        </header>

        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm shadow-slate-900/[0.03] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none">
          <div className="grid md:grid-cols-[228px_1fr]">
            <nav
              aria-label="Settings sections"
              className="flex gap-1 overflow-x-auto border-b border-slate-200 p-3 md:flex-col md:overflow-visible md:border-b-0 md:border-r dark:border-slate-800"
            >
              <div role="tablist" aria-orientation="vertical" className="flex gap-1 md:w-full md:flex-col">
                {SECTIONS.map((section, i) => {
                  const selected = active === section.id;
                  return (
                    <button
                      key={section.id}
                      ref={(el) => {
                        tabRefs.current[i] = el;
                      }}
                      role="tab"
                      id={`${uid}-tab-${section.id}`}
                      aria-selected={selected}
                      aria-controls={`${uid}-panel-${section.id}`}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => setActive(section.id)}
                      onKeyDown={(e) => onTabKeyDown(e, i)}
                      className={`group relative flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors md:w-full ${RING} ${
                        selected
                          ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                          : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
                      }`}
                    >
                      <span className={selected ? "text-indigo-300 dark:text-indigo-600" : "text-slate-400 dark:text-slate-500"}>
                        {section.icon}
                      </span>
                      <span className="min-w-0">
                        <span className="block text-sm font-medium">{section.label}</span>
                        <span
                          className={`hidden text-xs md:block ${
                            selected ? "text-slate-400 dark:text-slate-500" : "text-slate-400 dark:text-slate-500"
                          }`}
                        >
                          {section.hint}
                        </span>
                      </span>
                      {dirtySections[section.id] ? (
                        <span
                          className="psx-breathe ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
                          aria-label="Unsaved changes in this section"
                        />
                      ) : null}
                    </button>
                  );
                })}
              </div>
            </nav>

            <div className="min-w-0 p-5 sm:p-7">
              <AnimatePresence mode="wait" initial={false}>
                {active === "profile" ? (
                  <motion.div key="profile" role="tabpanel" id={`${uid}-panel-profile`} aria-labelledby={`${uid}-tab-profile`} {...fade}>
                    <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Public profile</h3>
                    <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                      This is what teammates see next to your comments and pull requests.
                    </p>

                    <div className="mt-6 flex items-center gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
                      <div
                        aria-hidden="true"
                        className="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-base font-semibold text-white"
                      >
                        NF
                      </div>
                      <div className="min-w-0">
                        <p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
                          nadia@northbeam.dev
                        </p>
                        <p className="mt-0.5 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
                          <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
                            <path
                              fillRule="evenodd"
                              d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.79a.75.75 0 0 0-1.22-.87l-3.24 4.53-1.62-1.62a.75.75 0 0 0-1.06 1.06l2.24 2.24a.75.75 0 0 0 1.14-.09l3.76-5.25Z"
                              clipRule="evenodd"
                            />
                          </svg>
                          Verified 14 January 2024
                        </p>
                      </div>
                    </div>

                    <div className="mt-6 grid gap-5 sm:grid-cols-2">
                      <div>
                        <label htmlFor={`${uid}-name`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
                          Display name
                        </label>
                        <input
                          id={`${uid}-name`}
                          type="text"
                          value={settings.displayName}
                          onChange={(e: ChangeEvent<HTMLInputElement>) => update("displayName", e.target.value)}
                          className={`mt-2 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
                        />
                      </div>
                      <div>
                        <label htmlFor={`${uid}-handle`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
                          Handle
                        </label>
                        <div className="mt-2 flex rounded-xl border border-slate-300 bg-white focus-within:ring-2 focus-within:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:ring-indigo-400">
                          <span className="grid place-items-center pl-3 text-sm text-slate-400 dark:text-slate-500">@</span>
                          <input
                            id={`${uid}-handle`}
                            type="text"
                            value={settings.handle}
                            aria-describedby={`${uid}-handle-help`}
                            onChange={(e: ChangeEvent<HTMLInputElement>) =>
                              update("handle", e.target.value.replace(/\s/g, "").toLowerCase())
                            }
                            className="w-full rounded-r-xl bg-transparent px-2 py-2 text-sm text-slate-900 focus:outline-none dark:text-slate-100"
                          />
                        </div>
                        <p id={`${uid}-handle-help`} className="mt-1.5 text-xs text-slate-500 dark:text-slate-400">
                          northbeam.dev/@{settings.handle || "…"}
                        </p>
                      </div>
                    </div>

                    <div className="mt-5">
                      <div className="flex items-baseline justify-between">
                        <label htmlFor={`${uid}-bio`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
                          Bio
                        </label>
                        <span
                          id={`${uid}-bio-count`}
                          className={`text-xs tabular-nums ${
                            bioLeft < 20 ? "text-rose-600 dark:text-rose-400" : "text-slate-400 dark:text-slate-500"
                          }`}
                        >
                          {bioLeft} left
                        </span>
                      </div>
                      <textarea
                        id={`${uid}-bio`}
                        rows={3}
                        maxLength={180}
                        value={settings.bio}
                        aria-describedby={`${uid}-bio-count`}
                        onChange={(e: ChangeEvent<HTMLTextAreaElement>) => update("bio", e.target.value)}
                        className={`mt-2 w-full resize-y rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
                      />
                    </div>

                    <div className="mt-5 max-w-sm">
                      <label htmlFor={`${uid}-tz`} className="block text-sm font-medium text-slate-800 dark:text-slate-200">
                        Timezone
                      </label>
                      <div className="relative mt-2">
                        <select
                          id={`${uid}-tz`}
                          value={settings.timezone}
                          onChange={(e: ChangeEvent<HTMLSelectElement>) => update("timezone", e.target.value)}
                          className={`w-full appearance-none rounded-xl border border-slate-300 bg-white py-2 pl-3 pr-9 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
                        >
                          {TIMEZONES.map((tz) => (
                            <option key={tz.value} value={tz.value}>
                              {tz.label}
                            </option>
                          ))}
                        </select>
                        <svg
                          viewBox="0 0 20 20"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.6"
                          aria-hidden="true"
                          className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                        >
                          <path d="m5.5 8 4.5 4.5L14.5 8" strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                      </div>
                    </div>
                  </motion.div>
                ) : null}

                {active === "notifications" ? (
                  <motion.div
                    key="notifications"
                    role="tabpanel"
                    id={`${uid}-panel-notifications`}
                    aria-labelledby={`${uid}-tab-notifications`}
                    {...fade}
                  >
                    <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Notifications</h3>
                    <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                      We send to nadia@northbeam.dev. Security alerts are always sent and can&apos;t be turned off.
                    </p>

                    <div className="mt-5">
                      <SwitchRow
                        title="Activity digest"
                        detail="One email summarising everything you missed, instead of a message per event."
                        checked={settings.emailDigest}
                        onChange={(v) => update("emailDigest", v)}
                      />

                      <div
                        className={`flex flex-wrap items-center gap-3 border-b border-slate-200 py-4 transition-opacity dark:border-slate-800 ${
                          settings.emailDigest ? "opacity-100" : "opacity-50"
                        }`}
                      >
                        <label
                          htmlFor={`${uid}-freq`}
                          className="text-sm font-medium text-slate-800 dark:text-slate-200"
                        >
                          Digest frequency
                        </label>
                        <div className="relative">
                          <select
                            id={`${uid}-freq`}
                            value={settings.digestFrequency}
                            disabled={!settings.emailDigest}
                            onChange={(e: ChangeEvent<HTMLSelectElement>) =>
                              update("digestFrequency", e.target.value as Frequency)
                            }
                            className={`appearance-none rounded-lg border border-slate-300 bg-white py-1.5 pl-3 pr-8 text-sm text-slate-900 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${RING}`}
                          >
                            <option value="daily">Every morning</option>
                            <option value="weekly">Mondays at 09:00</option>
                            <option value="monthly">First of the month</option>
                          </select>
                          <svg
                            viewBox="0 0 20 20"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth="1.6"
                            aria-hidden="true"
                            className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                          >
                            <path d="m5.5 8 4.5 4.5L14.5 8" strokeLinecap="round" strokeLinejoin="round" />
                          </svg>
                        </div>
                      </div>

                      <SwitchRow
                        title="Mentions and replies"
                        detail="Someone @-mentions you or replies in a thread you started."
                        checked={settings.mentions}
                        onChange={(v) => update("mentions", v)}
                      />
                      <SwitchRow
                        title="Weekly report"
                        detail="Review load, merged pull requests, and open threads older than five days."
                        checked={settings.weeklyReport}
                        onChange={(v) => update("weeklyReport", v)}
                      />
                      <SwitchRow
                        title="Product news"
                        detail="Roughly one email a month when we ship something worth reading about."
                        checked={settings.productNews}
                        onChange={(v) => update("productNews", v)}
                      />
                    </div>
                  </motion.div>
                ) : null}

                {active === "privacy" ? (
                  <motion.div
                    key="privacy"
                    role="tabpanel"
                    id={`${uid}-panel-privacy`}
                    aria-labelledby={`${uid}-tab-privacy`}
                    {...fade}
                  >
                    <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Privacy</h3>
                    <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                      Controls who can open your profile page and what shows up on it.
                    </p>

                    <fieldset className="mt-5">
                      <legend className="text-sm font-medium text-slate-800 dark:text-slate-200">
                        Profile visibility
                      </legend>
                      <div className="mt-3 grid gap-2">
                        {VISIBILITY_OPTIONS.map((opt) => (
                          <label key={opt.value} className="relative block cursor-pointer">
                            <input
                              type="radio"
                              name={`${uid}-visibility`}
                              value={opt.value}
                              checked={settings.visibility === opt.value}
                              onChange={() => update("visibility", opt.value)}
                              className="peer sr-only"
                            />
                            <span className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50/60 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/10 dark:peer-focus-visible:ring-offset-slate-900 dark:hover:bg-slate-800/50">
                              <span
                                aria-hidden="true"
                                className={`mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border transition-colors ${
                                  settings.visibility === opt.value
                                    ? "border-indigo-600 dark:border-indigo-400"
                                    : "border-slate-300 dark:border-slate-600"
                                }`}
                              >
                                <span
                                  className={`h-2 w-2 rounded-full transition-transform ${
                                    settings.visibility === opt.value
                                      ? "scale-100 bg-indigo-600 dark:bg-indigo-400"
                                      : "scale-0 bg-transparent"
                                  }`}
                                />
                              </span>
                              <span className="min-w-0">
                                <span className="block text-sm font-medium text-slate-900 dark:text-slate-100">
                                  {opt.title}
                                </span>
                                <span className="mt-0.5 block text-sm text-slate-500 dark:text-slate-400">
                                  {opt.detail}
                                </span>
                              </span>
                            </span>
                          </label>
                        ))}
                      </div>
                    </fieldset>

                    <div className="mt-5">
                      <SwitchRow
                        title="Show activity graph"
                        detail="The contribution grid on your profile. Private repositories stay unnamed either way."
                        checked={settings.showActivity}
                        onChange={(v) => update("showActivity", v)}
                      />
                      <SwitchRow
                        title="Allow search engines"
                        detail="Lets Google index your profile. Only has an effect while visibility is public."
                        checked={settings.indexProfile}
                        onChange={(v) => update("indexProfile", v)}
                        disabled={settings.visibility !== "public"}
                      />
                    </div>
                  </motion.div>
                ) : null}

                {active === "sessions" ? (
                  <motion.div
                    key="sessions"
                    role="tabpanel"
                    id={`${uid}-panel-sessions`}
                    aria-labelledby={`${uid}-tab-sessions`}
                    {...fade}
                  >
                    <div className="flex flex-wrap items-start justify-between gap-3">
                      <div>
                        <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">Signed-in devices</h3>
                        <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                          {otherSessions === 0
                            ? "This is the only device signed in."
                            : `${otherSessions} other ${otherSessions === 1 ? "device is" : "devices are"} signed in.`}
                        </p>
                      </div>
                      <button
                        type="button"
                        disabled={otherSessions === 0}
                        onClick={() => {
                          setSessions((prev) => prev.filter((s) => s.current));
                          setAnnounce("Signed out of all other devices.");
                        }}
                        className={`rounded-xl border border-rose-200 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-rose-500/30 dark:text-rose-400 dark:hover:bg-rose-500/10 ${RING}`}
                      >
                        Sign out everywhere else
                      </button>
                    </div>

                    <ul className="mt-5 grid gap-2">
                      {sessions.map((s) => (
                        <li
                          key={s.id}
                          className="flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950"
                        >
                          <span
                            aria-hidden="true"
                            className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${
                              s.current
                                ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
                                : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                            }`}
                          >
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" className="h-4 w-4">
                              <rect x="3.2" y="5" width="17.6" height="11" rx="1.8" />
                              <path d="M2 19.4h20" strokeLinecap="round" />
                            </svg>
                          </span>
                          <div className="min-w-0 flex-1">
                            <p className="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
                              {s.device}
                              {s.current ? (
                                <span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400">
                                  This device
                                </span>
                              ) : null}
                            </p>
                            <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
                              {s.browser} · {s.where} · {s.when}
                            </p>
                          </div>
                          {s.current ? (
                            <span className="text-xs text-slate-400 dark:text-slate-500">—</span>
                          ) : (
                            <button
                              type="button"
                              onClick={() => revoke(s)}
                              className={`rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-rose-50 hover:text-rose-700 dark:text-slate-400 dark:hover:bg-rose-500/10 dark:hover:text-rose-400 ${RING}`}
                            >
                              Sign out
                              <span className="sr-only"> of {s.device} in {s.where}</span>
                            </button>
                          )}
                        </li>
                      ))}
                    </ul>
                  </motion.div>
                ) : null}
              </AnimatePresence>
            </div>
          </div>

          <AnimatePresence initial={false}>
            {dirty || status === "saved" ? (
              <motion.div
                key="savebar"
                initial={{ opacity: 0, height: 0, y: reduced ? 0 : 8 }}
                animate={{ opacity: 1, height: "auto", y: 0 }}
                exit={{ opacity: 0, height: 0, y: reduced ? 0 : 8 }}
                transition={{ duration: reduced ? 0 : 0.24, ease: "easeOut" }}
                className="overflow-hidden border-t border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800/40"
              >
                <div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4 sm:px-7">
                  <p className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
                    {status === "saved" ? (
                      <>
                        <svg
                          viewBox="0 0 20 20"
                          fill="currentColor"
                          aria-hidden="true"
                          className="psx-pop h-4 w-4 text-emerald-600 dark:text-emerald-400"
                        >
                          <path
                            fillRule="evenodd"
                            d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.79a.75.75 0 0 0-1.22-.87l-3.24 4.53-1.62-1.62a.75.75 0 0 0-1.06 1.06l2.24 2.24a.75.75 0 0 0 1.14-.09l3.76-5.25Z"
                            clipRule="evenodd"
                          />
                        </svg>
                        All changes saved
                      </>
                    ) : (
                      <>
                        <span className="psx-breathe h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
                        {changedKeys.length} unsaved {changedKeys.length === 1 ? "change" : "changes"}
                      </>
                    )}
                  </p>
                  <div className="flex items-center gap-2">
                    <button
                      type="button"
                      onClick={handleDiscard}
                      disabled={!dirty || status === "saving"}
                      className={`rounded-xl px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-300 dark:hover:bg-slate-700 ${RING}`}
                    >
                      Discard
                    </button>
                    <button
                      type="button"
                      onClick={handleSave}
                      disabled={!dirty || status === "saving"}
                      className={`inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-slate-900 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:disabled:hover:bg-indigo-500 ${RING}`}
                    >
                      {status === "saving" ? (
                        <>
                          <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="psx-spin h-4 w-4">
                            <circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" />
                            <path
                              d="M17.5 10a7.5 7.5 0 0 0-7.5-7.5"
                              stroke="currentColor"
                              strokeWidth="2.5"
                              strokeLinecap="round"
                            />
                          </svg>
                          Saving
                        </>
                      ) : (
                        "Save changes"
                      )}
                    </button>
                  </div>
                </div>
              </motion.div>
            ) : null}
          </AnimatePresence>
        </div>

        <p aria-live="polite" className="sr-only">
          {announce}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →