feat: implement user settings page with profile and API key management components

This commit is contained in:
Anish Sarkar 2026-03-08 19:36:12 +05:30
parent 97fbb70672
commit 77dc6b7c91
10 changed files with 785 additions and 531 deletions

View file

@ -0,0 +1,71 @@
"use client";
import { Check, Copy, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
export function ApiKeyContent() {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [hasError, setHasError] = useState(false);
useEffect(() => {
setHasError(false);
}, [url]);
if (url && !hasError) {
return (
<img
src={url}
alt="Avatar"
className="h-16 w-16 rounded-xl object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent() {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("profile_display_name_hint")}
</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { UserKey, User } from "lucide-react";
import { useTranslations } from "next-intl";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/animated-tabs";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs defaultValue="profile" className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
{t("profile_nav_label")}
</TabsTrigger>
<TabsTrigger value="api-key">
<UserKey className="mr-2 h-4 w-4" />
{t("api_key_nav_label")}
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-6">
<ProfileContent />
</TabsContent>
<TabsContent value="api-key" className="mt-6">
<ApiKeyContent />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -1,122 +0,0 @@
"use client";
import { Check, Copy, Key, Menu, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
interface ApiKeyContentProps {
onMenuClick: () => void;
}
export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,182 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
interface ProfileContentProps {
onMenuClick: () => void;
}
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [hasError, setHasError] = useState(false);
useEffect(() => {
setHasError(false);
}, [url]);
if (url && !hasError) {
return (
<img
src={url}
alt="Avatar"
className="h-16 w-16 rounded-xl object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent({ onMenuClick }: ProfileContentProps) {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="profile-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<User className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("profile_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("profile_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("profile_display_name_hint")}
</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -1,160 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
interface UserSettingsSidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}
export function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: UserSettingsSidebarProps) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
{/* Version display */}
<div className="mt-auto border-t px-6 py-3">
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
</div>
</aside>
</>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { Key, User } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const [activeSection, setActiveSection] = useState("profile");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [
{
id: "profile",
label: t("profile_nav_label"),
description: t("profile_nav_description"),
icon: User,
},
{
id: "api-key",
label: t("api_key_nav_label"),
description: t("api_key_nav_description"),
icon: Key,
},
];
const handleBackToApp = useCallback(() => {
router.back();
}, [router]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<UserSettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
navItems={navItems}
/>
{activeSection === "profile" && (
<ProfileContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
{activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
</div>
</div>
</motion.div>
);
}

View file

@ -304,8 +304,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, []);
const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings");
}, [router]);
router.push(`/dashboard/${searchSpaceId}/user-settings`);
}, [router, searchSpaceId]);
const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => {

View file

@ -303,7 +303,7 @@ export function DocumentUploadTab({
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
<Button
variant="outline"
variant="secondary"
size="sm"
className="text-xs sm:text-sm"
onClick={(e) => {

View file

@ -0,0 +1,540 @@
"use client"
import React, {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react"
import { cn } from "@/lib/utils"
/*
Context (replaces cloneElement)
*/
interface TabsContextValue {
activeValue: string
onValueChange: (value: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabsContext() {
const ctx = useContext(TabsContext)
if (!ctx) {
throw new Error(
"AnimatedTabs compound components must be rendered inside <Tabs>"
)
}
return ctx
}
/*
Constants (hoisted out of render)
*/
const SIZE_CLASSES = {
sm: "h-[32px] text-sm",
md: "h-[40px] text-base",
lg: "h-[48px] text-lg",
} as const
const VARIANT_CLASSES = {
default: "",
pills: "rounded-full",
underlined: "",
} as const
const ACTIVE_INDICATOR_CLASSES = {
default: "h-[4px] bg-primary dark:bg-primary",
pills: "hidden",
underlined: "h-[4px] bg-primary dark:bg-primary",
} as const
const HOVER_INDICATOR_CLASSES = {
default: "bg-muted dark:bg-muted rounded-[6px]",
pills: "bg-muted dark:bg-muted rounded-full",
underlined: "bg-muted dark:bg-muted rounded-[6px]",
} as const
/*
XScrollable (internal)
*/
const XScrollable = forwardRef<
HTMLDivElement,
{
className?: string
children?: ReactNode
showScrollbar?: boolean
contentClassName?: string
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement | null>(null)
const dragging = useRef(false)
const startX = useRef(0)
const startScrollLeft = useRef(0)
const onMouseDown = (e: React.MouseEvent) => {
if (!scrollRef.current) return
dragging.current = true
startX.current = e.clientX
startScrollLeft.current = scrollRef.current.scrollLeft
}
const endDrag = () => {
dragging.current = false
}
const onMouseMove = (e: React.MouseEvent) => {
if (!dragging.current || !scrollRef.current) return
e.preventDefault()
const dx = e.clientX - startX.current
scrollRef.current.scrollLeft = startScrollLeft.current - dx
}
const onWheel = (e: React.WheelEvent) => {
if (!scrollRef.current) return
const delta =
Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX
if (delta !== 0) {
e.preventDefault()
scrollRef.current.scrollLeft += delta
}
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
<div
ref={ref}
className={cn("relative", className)}
{...props}
onMouseLeave={endDrag}
onMouseUp={endDrag}
onMouseMove={onMouseMove}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
<div
ref={scrollRef}
className={cn(
"overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
!showScrollbar && "scrollbar-none",
contentClassName
)}
onWheel={onWheel}
onMouseDown={onMouseDown}
>
{children}
</div>
</div>
)
})
XScrollable.displayName = "XScrollable"
/*
Tabs (root)
*/
const Tabs = forwardRef<
HTMLDivElement,
{
defaultValue?: string
value?: string
onValueChange?: (value: string) => void
className?: string
children?: ReactNode
}
>(
(
{ defaultValue, value, onValueChange, className, children, ...props },
ref
) => {
const [activeValue, setActiveValue] = useState(value || defaultValue || "")
useEffect(() => {
if (value !== undefined) {
setActiveValue(value)
}
}, [value])
const handleValueChange = useCallback(
(newValue: string) => {
if (value === undefined) {
setActiveValue(newValue)
}
onValueChange?.(newValue)
},
[onValueChange, value]
)
return (
<TabsContext.Provider
value={{ activeValue, onValueChange: handleValueChange }}
>
<div ref={ref} className={cn("tabs-container", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
)
}
)
Tabs.displayName = "Tabs"
/*
TabsList
*/
type TabsListVariant = "default" | "pills" | "underlined"
type TabsListSize = "sm" | "md" | "lg"
const TabsList = forwardRef<
HTMLDivElement,
{
className?: string
children?: ReactNode
showHoverEffect?: boolean
showActiveIndicator?: boolean
activeIndicatorPosition?: "top" | "bottom"
activeIndicatorOffset?: number
size?: TabsListSize
variant?: TabsListVariant
stretch?: boolean
ariaLabel?: string
showBottomBorder?: boolean
bottomBorderClassName?: string
activeIndicatorClassName?: string
hoverIndicatorClassName?: string
}
>(
(
{
className,
children,
showHoverEffect = true,
showActiveIndicator = true,
activeIndicatorPosition = "bottom",
activeIndicatorOffset = 0,
size = "sm",
variant = "default",
stretch = false,
ariaLabel = "Tabs",
showBottomBorder = false,
bottomBorderClassName,
activeIndicatorClassName,
hoverIndicatorClassName,
...props
},
ref
) => {
const { activeValue, onValueChange } = useTabsContext()
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [hoverStyle, setHoverStyle] = useState({})
const [activeStyle, setActiveStyle] = useState({
left: "0px",
width: "0px",
})
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const activeIndex = React.Children.toArray(children).findIndex(
(child) =>
React.isValidElement(child) &&
(child as React.ReactElement<{ value: string }>).props.value ===
activeValue
)
useEffect(() => {
if (hoveredIndex !== null && showHoverEffect) {
const hoveredElement = tabRefs.current[hoveredIndex]
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
})
}
}
}, [hoveredIndex, showHoverEffect])
const updateActiveIndicator = useCallback(() => {
if (showActiveIndicator && activeIndex >= 0) {
const activeElement = tabRefs.current[activeIndex]
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
})
}
}
}, [showActiveIndicator, activeIndex])
useEffect(() => {
updateActiveIndicator()
}, [updateActiveIndicator])
useEffect(() => {
requestAnimationFrame(updateActiveIndicator)
}, [updateActiveIndicator])
const scrollTabToCenter = useCallback((index: number) => {
const tabElement = tabRefs.current[index]
const scrollContainer = scrollContainerRef.current
if (tabElement && scrollContainer) {
const containerWidth = scrollContainer.offsetWidth
const tabWidth = tabElement.offsetWidth
const tabLeft = tabElement.offsetLeft
const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2
scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" })
}
}, [])
const setTabRef = useCallback(
(el: HTMLDivElement | null, index: number) => {
tabRefs.current[index] = el
},
[]
)
const handleScrollableRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const scrollableDiv = node.querySelector(
'div[class*="overflow-x-auto"]'
)
if (scrollableDiv) {
scrollContainerRef.current = scrollableDiv as HTMLDivElement
}
}
}, [])
useEffect(() => {
if (activeIndex >= 0) {
const timer = setTimeout(() => {
scrollTabToCenter(activeIndex)
}, 100)
return () => clearTimeout(timer)
}
}, [activeIndex, scrollTabToCenter])
return (
<div
ref={handleScrollableRef}
className={cn("relative", className)}
role="tablist"
aria-label={ariaLabel}
{...props}
>
<XScrollable showScrollbar={false}>
<div className={cn("relative", showBottomBorder && "pb-px")}>
{showBottomBorder && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-border dark:bg-border",
bottomBorderClassName
)}
/>
)}
{showHoverEffect && (
<div
className={cn(
"absolute transition-all duration-300 ease-out flex items-center z-0",
SIZE_CLASSES[size],
HOVER_INDICATOR_CLASSES[variant],
hoverIndicatorClassName
)}
style={{
...hoverStyle,
opacity: hoveredIndex !== null ? 1 : 0,
transition: "all 300ms ease-out",
}}
aria-hidden="true"
/>
)}
<div
ref={ref}
className={cn(
"relative flex items-center",
stretch ? "w-full" : "",
variant === "default" ? "space-x-[6px]" : "space-x-[2px]"
)}
>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child
const childProps = (
child as React.ReactElement<{
value: string
disabled?: boolean
label?: string
className?: string
activeClassName?: string
inactiveClassName?: string
disabledClassName?: string
}>
).props
const { value, disabled } = childProps
const isActive = value === activeValue
return (
<div
key={value}
ref={(el) => setTabRef(el, index)}
className={cn(
"px-3 py-2 sm:mb-1.5 mb-2 cursor-pointer transition-colors duration-300",
SIZE_CLASSES[size],
variant === "pills" && isActive
? "bg-[#0e0f1114] dark:bg-[#ffffff1a] rounded-full"
: "",
disabled ? "opacity-50 cursor-not-allowed" : "",
stretch ? "flex-1 text-center" : "",
isActive
? childProps.activeClassName ||
"text-foreground dark:text-foreground"
: childProps.inactiveClassName ||
"text-muted-foreground dark:text-muted-foreground",
disabled && childProps.disabledClassName,
VARIANT_CLASSES[variant],
childProps.className
)}
onMouseEnter={() => !disabled && setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => {
if (!disabled) {
onValueChange(value)
scrollTabToCenter(index)
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
if (!disabled) {
onValueChange(value)
scrollTabToCenter(index)
}
}
}}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
aria-controls={`tabpanel-${value}`}
id={`tab-${value}`}
tabIndex={isActive ? 0 : -1}
>
<div className="whitespace-nowrap flex items-center justify-center h-full">
{child}
</div>
</div>
)
})}
</div>
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
<div
className={cn(
"absolute transition-all duration-300 ease-out z-10",
ACTIVE_INDICATOR_CLASSES[variant],
activeIndicatorPosition === "top"
? "top-[-1px]"
: "bottom-[-1px]",
activeIndicatorClassName
)}
style={{
...activeStyle,
transition: "all 300ms ease-out",
[activeIndicatorPosition]: `${activeIndicatorOffset}px`,
}}
aria-hidden="true"
/>
)}
</div>
</XScrollable>
</div>
)
}
)
TabsList.displayName = "TabsList"
/*
TabsTrigger
*/
const TabsTrigger = forwardRef<
HTMLDivElement,
{
value: string
disabled?: boolean
label?: string
className?: string
activeClassName?: string
inactiveClassName?: string
disabledClassName?: string
children?: ReactNode
}
>(
(
{
value,
disabled = false,
label,
className,
activeClassName,
inactiveClassName,
disabledClassName,
children,
...props
},
ref
) => {
return (
<div ref={ref} className={cn("flex items-center", className)} {...props}>
{label || children}
</div>
)
}
)
TabsTrigger.displayName = "TabsTrigger"
/*
TabsContent
*/
const TabsContent = forwardRef<
HTMLDivElement,
{
value: string
className?: string
children: ReactNode
}
>(
(
{ value, className, children, ...props },
ref
) => {
const { activeValue } = useTabsContext()
if (value !== activeValue) return null
return (
<div
ref={ref}
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
className={className}
{...props}
>
{children}
</div>
)
}
)
TabsContent.displayName = "TabsContent"
export { Tabs, TabsList, TabsTrigger, TabsContent }