mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
feat: implement user settings page with profile and API key management components
This commit is contained in:
parent
97fbb70672
commit
77dc6b7c91
10 changed files with 785 additions and 531 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
540
surfsense_web/components/ui/animated-tabs.tsx
Normal file
540
surfsense_web/components/ui/animated-tabs.tsx
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue