Self-hosted dashboard and admin auth (#4837)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
15feaa8ac4
commit
db8ac61713
@@ -0,0 +1,44 @@
|
||||
import "@/styles/globals.css";
|
||||
import React from "react";
|
||||
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "../(root)/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
export const metadata = {
|
||||
title: "Mem0 - Log in",
|
||||
description: "Log in to Mem0",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(
|
||||
Fustat.variable,
|
||||
InterDisplay.variable,
|
||||
Inter.variable,
|
||||
Roboto.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="font-fustat" suppressHydrationWarning>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { isValidEmail } from "@/lib/validators";
|
||||
|
||||
const RESET_COMMAND =
|
||||
"make reset-admin-password EMAIL=<your-email> PASSWORD=<new-password>";
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, isLoading, login } = useAuth();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
router.push(searchParams.get("next") || "/dashboard/requests");
|
||||
}
|
||||
}, [user, isLoading, router, searchParams]);
|
||||
|
||||
const emailValid = isValidEmail(email);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!emailValid) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push(searchParams.get("next") || "/dashboard/requests");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Login failed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex-1 bg-surface-default-primary flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex justify-center mb-2">
|
||||
{mounted && (
|
||||
<Image
|
||||
src={
|
||||
resolvedTheme === "dark"
|
||||
? "/images/logos/logo-light.png"
|
||||
: "/images/logos/logo-dark.png"
|
||||
}
|
||||
alt="Mem0"
|
||||
width={41}
|
||||
height={41}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-onSurface-default-primary text-center mb-6 font-fustat">
|
||||
Sign in to Mem0
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4 border p-8 border-memBorder-primary rounded-xl">
|
||||
{error && (
|
||||
<p className="text-sm text-onSurface-danger-primary bg-surface-danger-primary px-3 py-2 rounded">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-email">Email</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@company.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-password">Password</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !emailValid || !password}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{submitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-onSurface-default-tertiary hover:text-onSurface-default-primary underline underline-offset-4 self-center"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset your admin password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command on the server host. It overwrites the
|
||||
existing password; anyone already signed in stays signed in
|
||||
until their session expires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={RESET_COMMAND}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={RESET_COMMAND}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden h-screen flex-1 items-center justify-center overflow-hidden bg-gradient-to-b from-[#31275A] to-[#5C49A3] px-10 lg:flex">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[url('/images/dither.svg')] bg-bottom bg-no-repeat bg-contain" />
|
||||
<div className="relative z-10 flex w-full max-w-[564px] flex-col items-center gap-20 text-center text-white">
|
||||
<div className="w-full space-y-5">
|
||||
<p className="typo-h3 text-white">
|
||||
"Mem0 allowed us to unlock true personalized tutoring for
|
||||
every student, and it took us just a weekend to integrate."
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-[7px]">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="typo-body-sm text-white">Michael Tong</p>
|
||||
<p className="typo-body-xs text-white">CTO, RevisionDojo</p>
|
||||
</div>
|
||||
<Image
|
||||
src="/images/micheal.png"
|
||||
alt="Michael Tong"
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<p className="typo-body text-white">Trusted by 100k+ Developers</p>
|
||||
<div className="flex items-center justify-center gap-8 text-white">
|
||||
<div className="h-6 shrink-0">
|
||||
<Image
|
||||
src="/images/logos/aws.svg"
|
||||
alt="AWS"
|
||||
width={41}
|
||||
height={24}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-5 shrink-0">
|
||||
<Image
|
||||
src="/images/logos/nvidia.svg"
|
||||
alt="NVIDIA"
|
||||
width={109}
|
||||
height={21}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[21px] shrink-0">
|
||||
<Image
|
||||
src="/images/vercel.png"
|
||||
alt="Vercel"
|
||||
width={66}
|
||||
height={21}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import LoginForm from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import ThemeAwareLogo from "@/components/misc/theme-aware-logo";
|
||||
import { LinearProgress } from "@/components/ui/linearProgress";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export const SIDEBAR_WIDTH = 180;
|
||||
export const COLLAPSED_SIDEBAR_WIDTH = 64;
|
||||
export const COLLAPSED_SIDEBAR_PADDING = 16;
|
||||
export const COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING =
|
||||
COLLAPSED_SIDEBAR_WIDTH - COLLAPSED_SIDEBAR_PADDING;
|
||||
|
||||
function AuthLoadingState() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<ThemeAwareLogo />
|
||||
<LinearProgress value={66} className="mt-8 h-1 w-[180px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClientLayout: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, isLoading } = useAuth();
|
||||
const isPublicPage =
|
||||
pathname.startsWith("/login") || pathname.startsWith("/setup");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPublicPage && !isLoading && !user) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, isPublicPage, router, user]);
|
||||
|
||||
if (isLoading && !isPublicPage) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
|
||||
if (!isPublicPage && !user) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
|
||||
return <TooltipProvider delayDuration={0}>{children}</TooltipProvider>;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "@/styles/globals.css";
|
||||
import { ClientLayout } from "./clientLayout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "./fonts";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "@/store/store";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Toaster = dynamic(
|
||||
() =>
|
||||
import("@/components/ui/sonner").then((mod) => ({ default: mod.Toaster })),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
Inter.className,
|
||||
InterDisplay.variable,
|
||||
Roboto.variable,
|
||||
Fustat.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
function AnalyticsMockup() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Total Operations", value: "12,847" },
|
||||
{ label: "Avg Latency", value: "142ms" },
|
||||
{ label: "Success Rate", value: "99.7%" },
|
||||
].map((stat) => (
|
||||
<Card key={stat.label} className="border-memBorder-primary">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold mt-1">{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Operations over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[200px] flex items-end gap-1">
|
||||
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 50].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-surface-default-brand rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Analytics"
|
||||
description="Track memory operations, latency, and usage patterns over time."
|
||||
previewContent={<AnalyticsMockup />}
|
||||
utmMedium="dashboard-locked-analytics"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import { api } from "@/utils/api";
|
||||
import { API_KEY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { Plus, Copy, Check, Trash2 } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { format } from "date-fns";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { ApiKey, ApiKeyCreateResponse } from "@/types/api";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||
|
||||
const {
|
||||
data: keys = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<ApiKey[]>(
|
||||
async () => {
|
||||
const res = await api.get<ApiKey[]>(API_KEY_ENDPOINTS.BASE);
|
||||
return res.data ?? [];
|
||||
},
|
||||
{ errorToast: "Failed to load API keys", initialData: [] },
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await api.post<ApiKeyCreateResponse>(API_KEY_ENDPOINTS.BASE, {
|
||||
label: newLabel,
|
||||
});
|
||||
setNewKey(res.data.key);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to create key",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!keyToRevoke) return;
|
||||
try {
|
||||
await api.delete(API_KEY_ENDPOINTS.BY_ID(keyToRevoke.id));
|
||||
toast({ title: "API key revoked", variant: "success" });
|
||||
setKeyToRevoke(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to revoke key",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setNewKey("");
|
||||
setNewLabel("");
|
||||
setCopied(false);
|
||||
}
|
||||
setCreateOpen(open);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "label" as keyof ApiKey, label: "Label", width: 150 },
|
||||
{
|
||||
key: "key_prefix" as keyof ApiKey,
|
||||
label: "Key",
|
||||
width: 120,
|
||||
render: (value: string) => (
|
||||
<code className="text-xs font-mono">{value}...</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at" as keyof ApiKey,
|
||||
label: "Created",
|
||||
width: 120,
|
||||
render: (value: string) => format(new Date(value), "MMM d, yyyy"),
|
||||
},
|
||||
{
|
||||
key: "last_used_at" as keyof ApiKey,
|
||||
label: "Last Used",
|
||||
width: 120,
|
||||
render: (value: string | null) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "Never",
|
||||
},
|
||||
{
|
||||
key: "id" as keyof ApiKey,
|
||||
label: "",
|
||||
width: 40,
|
||||
render: (_: string, row: ApiKey) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setKeyToRevoke(row)}
|
||||
className="size-7"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold font-fustat">API Keys</h1>
|
||||
<Dialog open={createOpen} onOpenChange={handleDialogClose}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-1" /> Create Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!newKey ? (
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-label">Label</Label>
|
||||
<Input
|
||||
id="api-key-label"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="e.g. Production"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newLabel}
|
||||
className="w-full"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-new">Your API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="api-key-new"
|
||||
value={newKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={newKey}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
Save this key -- you won't see it again.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleDialogClose(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{keys.length >= 3 && (
|
||||
<UpgradeBanner
|
||||
id="api-keys-3"
|
||||
message="Managing multiple apps? Cloud offers project-based isolation."
|
||||
ctaLabel="Explore Cloud"
|
||||
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-api-keys"
|
||||
variant="cloud"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={3} columns={4} />
|
||||
) : keys.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No API keys yet"
|
||||
description="Create your first API key to start using the Mem0 API."
|
||||
/>
|
||||
) : (
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={keys}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!keyToRevoke}
|
||||
onClose={() => setKeyToRevoke(null)}
|
||||
onConfirm={handleRevoke}
|
||||
title="Revoke API key"
|
||||
description="Applications using this key will immediately stop working. This cannot be undone."
|
||||
itemName={keyToRevoke?.label ?? ""}
|
||||
confirmButtonText="Revoke"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
function CategoriesMockup() {
|
||||
const categories = ["Health", "Preferences", "Work"];
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Card key={cat} className="border-memBorder-primary">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-3">{cat}</p>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 bg-surface-default-secondary rounded mb-2"
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Custom Categories"
|
||||
description="Organize memories by domain -- health, preferences, work, and more."
|
||||
previewContent={<CategoriesMockup />}
|
||||
utmMedium="dashboard-locked-categories"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
ChartLine,
|
||||
ChevronDown,
|
||||
FolderInput,
|
||||
GalleryVerticalEnd,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Tags,
|
||||
Users,
|
||||
WebhookIcon,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarGroupLabel,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MainNav({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement>) {
|
||||
const pathname = usePathname();
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
const [isCloudOpen, setIsCloudOpen] = React.useState(true);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible={isSidebarCollapsed ? "icon" : undefined}
|
||||
className={cn(className, "border-r-0 w-full mb-0 bg-transparent")}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="gap-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0">
|
||||
{!isSidebarCollapsed && (
|
||||
<SidebarGroupLabel className="mb-0">
|
||||
ACTIVITY
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
{[
|
||||
{
|
||||
title: "Requests",
|
||||
url: "/dashboard/requests",
|
||||
icon: Activity,
|
||||
active: pathname === "/dashboard/requests",
|
||||
},
|
||||
{
|
||||
title: "Memories",
|
||||
url: "/dashboard/memories",
|
||||
icon: GalleryVerticalEnd,
|
||||
active: pathname === "/dashboard/memories",
|
||||
},
|
||||
{
|
||||
title: "Entities",
|
||||
url: "/dashboard/entities",
|
||||
icon: Users,
|
||||
active: pathname === "/dashboard/entities",
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={item.active}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
|
||||
)}
|
||||
|
||||
<Collapsible
|
||||
open={isCloudOpen}
|
||||
onOpenChange={setIsCloudOpen}
|
||||
className="flex flex-col gap-0"
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarGroupLabel className="cursor-pointer mb-0">
|
||||
CLOUD FEATURES
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3 transition-transform duration-200",
|
||||
isCloudOpen ? "" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<CollapsibleContent className="flex flex-col gap-0">
|
||||
{[
|
||||
{
|
||||
title: "Categories",
|
||||
url: "/dashboard/categories",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: "Webhooks",
|
||||
url: "/dashboard/webhooks",
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/dashboard/analytics",
|
||||
icon: ChartLine,
|
||||
},
|
||||
{
|
||||
title: "Export",
|
||||
url: "/dashboard/export",
|
||||
icon: FolderInput,
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={pathname === item.url}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span>{item.title}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto text-memGold-600 border-memGold-300 typo-caption-sm px-1.5 py-0"
|
||||
>
|
||||
PRO
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
{!isSidebarCollapsed && (
|
||||
<SidebarGroupLabel className="mb-0">
|
||||
ACCOUNT
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
{[
|
||||
{
|
||||
title: "API Keys",
|
||||
url: "/dashboard/api-keys",
|
||||
icon: KeyRound,
|
||||
active: pathname === "/dashboard/api-keys",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
url: "/dashboard/configuration",
|
||||
icon: Wrench,
|
||||
active: pathname === "/dashboard/configuration",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/dashboard/settings",
|
||||
icon: Settings,
|
||||
active: pathname === "/dashboard/settings",
|
||||
},
|
||||
].map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
collapsed={isSidebarCollapsed}
|
||||
active={item.active}
|
||||
tooltip={isSidebarCollapsed ? item.title : undefined}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed
|
||||
? "justify-center mx-auto"
|
||||
: "gap-1.5",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{!isSidebarCollapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { MainNav } from "./main-nav";
|
||||
import { PanelRight, LogOut, Settings, HelpCircle } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
COLLAPSED_SIDEBAR_WIDTH,
|
||||
COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING,
|
||||
SIDEBAR_WIDTH,
|
||||
} from "../../clientLayout";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RootState } from "@/store/store";
|
||||
import { toggleSidebar } from "@/store/reducers/layoutReducer";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
export default function NavWrapper() {
|
||||
const dispatch = useDispatch();
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const instanceName = process.env.NEXT_PUBLIC_INSTANCE_NAME || "Mem0";
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
dispatch(toggleSidebar());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed top-0 left-0 h-full flex justify-between flex-col overflow-hidden transition-all duration-300 ease-in-out z-30 bg-transparent"
|
||||
style={{
|
||||
width: isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0 items-start gap-5 px-3 py-3 overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full",
|
||||
isSidebarCollapsed ? "p-0 justify-center" : "",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
isSidebarCollapsed ? "justify-center" : "gap-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center size-7 rounded-md bg-surface-default-tertiary shrink-0">
|
||||
<Building2 className="size-4 text-onSurface-default-primary" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="typo-body-xs text-onSurface-default-primary truncate text-left flex-1 min-w-0">
|
||||
{instanceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MainNav className="w-full" />
|
||||
</div>
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex flex-col shrink-0">
|
||||
<div className="mx-3 px-0 py-3 border-t border-memBorder-primary">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 w-full text-left hover:bg-surface-default-secondary-hover rounded-md p-1.5 transition-colors">
|
||||
<div className="grid size-7 place-items-center rounded-md bg-surface-default-tertiary text-onSurface-default-secondary text-xs font-semibold shrink-0">
|
||||
{user?.name?.charAt(0).toUpperCase() || "?"}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="typo-body-xs text-onSurface-default-primary truncate">
|
||||
{user?.name}
|
||||
</span>
|
||||
<span className="typo-caption-sm text-onSurface-default-tertiary truncate">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-56 font-fustat bg-surface-default-primary border-memBorder-secondary"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="typo-body-sm text-onSurface-default-primary">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="typo-body-xs text-onSurface-default-tertiary">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-memBorder-primary" />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
|
||||
>
|
||||
<Link href="/dashboard/settings">
|
||||
<Settings className="size-4 mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-memBorder-primary" />
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
|
||||
>
|
||||
<LogOut className="size-4 mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-transparent left-0 top-0 fixed flex justify-between items-center pr-4 h-12 font-fustat z-20"
|
||||
style={{
|
||||
width: `calc(100% - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px)`,
|
||||
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="cursor-pointer text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
|
||||
aria-label={
|
||||
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||
}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
|
||||
>
|
||||
<HelpCircle className="size-4 shrink-0" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Documentation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import {
|
||||
buildProviderConfig,
|
||||
getEffectiveConfig,
|
||||
} from "@/utils/self-hosted-config";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
|
||||
type BundledProviders = {
|
||||
llm: string[];
|
||||
embedder: string[];
|
||||
};
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [llmProvider, setLlmProvider] = useState("");
|
||||
const [llmModel, setLlmModel] = useState("");
|
||||
const [llmApiKey, setLlmApiKey] = useState("");
|
||||
const [embedderProvider, setEmbedderProvider] = useState("");
|
||||
const [embedderModel, setEmbedderModel] = useState("");
|
||||
|
||||
const { data: config, isLoading: isPrefilling } = useApiQuery(
|
||||
async () => {
|
||||
const res = await api.get(MEMORY_ENDPOINTS.CONFIGURE);
|
||||
return getEffectiveConfig(res.data);
|
||||
},
|
||||
{ errorToast: "Failed to load server configuration" },
|
||||
);
|
||||
|
||||
const { data: providers } = useApiQuery<BundledProviders>(
|
||||
async () => {
|
||||
const res = await api.get<BundledProviders>(
|
||||
MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
{ errorToast: "Failed to load bundled providers" },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setLlmProvider((current) => current || config.llm?.provider || "");
|
||||
setLlmModel((current) => current || config.llm?.config?.model || "");
|
||||
setEmbedderProvider(
|
||||
(current) => current || config.embedder?.provider || "",
|
||||
);
|
||||
setEmbedderModel(
|
||||
(current) => current || config.embedder?.config?.model || "",
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const llm = buildProviderConfig({
|
||||
provider: llmProvider,
|
||||
model: llmModel,
|
||||
apiKey: llmApiKey,
|
||||
});
|
||||
const embedder = buildProviderConfig({
|
||||
provider: embedderProvider,
|
||||
model: embedderModel,
|
||||
});
|
||||
|
||||
const newConfig: Record<string, unknown> = {
|
||||
version: "v1.1",
|
||||
};
|
||||
|
||||
if (llm) {
|
||||
newConfig.llm = llm;
|
||||
}
|
||||
|
||||
if (embedder) {
|
||||
newConfig.embedder = embedder;
|
||||
}
|
||||
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, newConfig);
|
||||
toast({ title: "Configuration saved", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to save configuration",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold font-fustat">Configuration</h1>
|
||||
{isPrefilling && (
|
||||
<p className="text-sm text-onSurface-default-tertiary">
|
||||
Loading effective server configuration...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">LLM Provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<Select
|
||||
value={llmProvider}
|
||||
onValueChange={setLlmProvider}
|
||||
disabled={!isAdmin || !providers}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.llm.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Model</Label>
|
||||
<Input
|
||||
placeholder="gpt-4.1-nano-2025-04-14"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={llmApiKey}
|
||||
onChange={(e) => setLlmApiKey(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Embedding Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<Select
|
||||
value={embedderProvider}
|
||||
onValueChange={setEmbedderProvider}
|
||||
disabled={!isAdmin || !providers}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.embedder.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Model</Label>
|
||||
<Input
|
||||
placeholder="text-embedding-3-small"
|
||||
value={embedderModel}
|
||||
onChange={(e) => setEmbedderModel(e.target.value)}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Need another provider? Install its Python package, rebuild the image,
|
||||
and extend the bundled list. See the{" "}
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/setup#supported-providers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
setup guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
{isAdmin && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<UpgradeBanner
|
||||
id="config-sso"
|
||||
message="Looking for SSO / SAML? Available in Enterprise."
|
||||
ctaLabel="Contact sales"
|
||||
ctaUrl="https://app.mem0.ai/enterprise?utm_source=oss&utm_medium=dashboard-configuration-sso"
|
||||
variant="enterprise"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { api } from "@/utils/api";
|
||||
import { ENTITY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { Entity } from "@/types/api";
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const [entityToDelete, setEntityToDelete] = useState<Entity | null>(null);
|
||||
|
||||
const {
|
||||
data: entities = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<Entity[]>(
|
||||
async () => {
|
||||
const res = await api.get<Entity[]>(ENTITY_ENDPOINTS.BASE);
|
||||
return res.data ?? [];
|
||||
},
|
||||
{ errorToast: "Failed to load entities", initialData: [] },
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!entityToDelete) return;
|
||||
try {
|
||||
await api.delete(
|
||||
ENTITY_ENDPOINTS.BY_ID(entityToDelete.type, entityToDelete.id),
|
||||
);
|
||||
toast({ title: "Entity deleted", variant: "success" });
|
||||
setEntityToDelete(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to delete entity",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "type" as keyof Entity,
|
||||
label: "Type",
|
||||
width: 100,
|
||||
render: (value: Entity["type"]) => (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "id" as keyof Entity,
|
||||
label: "ID",
|
||||
width: 280,
|
||||
render: (value: string) => (
|
||||
<span className="font-mono text-sm truncate">{value}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "total_memories" as keyof Entity,
|
||||
label: "Memories",
|
||||
width: 100,
|
||||
align: "right" as const,
|
||||
},
|
||||
{
|
||||
key: "updated_at" as keyof Entity,
|
||||
label: "Last Active",
|
||||
width: 140,
|
||||
render: (value: string | null) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "--",
|
||||
},
|
||||
{
|
||||
key: "id" as keyof Entity,
|
||||
label: "",
|
||||
width: 40,
|
||||
render: (_: string, row: Entity) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEntityToDelete(row)}
|
||||
className="size-7"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold font-fustat">Entities</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
) : entities.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No entities yet"
|
||||
description="Entities appear once memories are stored with a user_id, agent_id, or run_id."
|
||||
/>
|
||||
) : (
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={entities}
|
||||
columns={columns}
|
||||
getRowKey={(row) => `${row.type}:${row.id}`}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!entityToDelete}
|
||||
onClose={() => setEntityToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete entity"
|
||||
description="All memories associated with this entity will be permanently removed. This cannot be undone."
|
||||
itemName={entityToDelete?.id ?? ""}
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function ExportMockup() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<div className="flex gap-2">
|
||||
{["JSON", "CSV", "Pydantic Schema"].map((fmt) => (
|
||||
<Button
|
||||
key={fmt}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
className={fmt === "JSON" ? "border-memPurple-300" : ""}
|
||||
>
|
||||
{fmt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sample output</Label>
|
||||
<Card className="border-memBorder-primary bg-surface-default-secondary">
|
||||
<CardContent className="p-3">
|
||||
<pre className="text-xs text-onSurface-default-secondary font-mono whitespace-pre-wrap">
|
||||
{`{
|
||||
"memories": [
|
||||
{
|
||||
"id": "mem_abc123",
|
||||
"content": "User prefers dark mode",
|
||||
"user_id": "user_1",
|
||||
"created_at": "2026-04-10T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1247
|
||||
}`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button disabled>Export Memories</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Export"
|
||||
description="Export your memories in JSON, CSV, or Pydantic schema format."
|
||||
previewContent={<ExportMockup />}
|
||||
utmMedium="dashboard-locked-export"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import NavWrapper from "./components/nav-wrapper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_WIDTH, COLLAPSED_SIDEBAR_WIDTH } from "../clientLayout";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isSidebarCollapsed = useSelector(
|
||||
(state: RootState) => state.layout.isSidebarCollapsed,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavWrapper />
|
||||
<div
|
||||
className="mt-[48px] rounded-tl-lg relative h-[calc(100vh-56px)] bg-surface-default-primary border border-memBorder-primary overflow-hidden transition-all duration-300 ease-in-out font-fustat"
|
||||
style={{
|
||||
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH}px`,
|
||||
width: `calc(100vw - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH + 8 : SIDEBAR_WIDTH + 8}px)`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea type="scroll" className="h-[calc(100vh-70px)]">
|
||||
<div className="mx-auto px-6 py-6 flex-1 flex-col space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { Memory } from "@/types/api";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [selectedMemory, setSelectedMemory] = useState<Memory | null>(null);
|
||||
const [memoryToDelete, setMemoryToDelete] = useState<Memory | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
const {
|
||||
data: memories = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useApiQuery<Memory[]>(
|
||||
async () => {
|
||||
const params = userId.trim() ? { user_id: userId.trim() } : undefined;
|
||||
const res = await api.get(MEMORY_ENDPOINTS.BASE, { params });
|
||||
const raw = res.data?.results ?? res.data ?? [];
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
},
|
||||
{ errorToast: "Failed to load memories", initialData: [] },
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(memories.length / PAGE_SIZE);
|
||||
const paginatedMemories = memories.slice(
|
||||
page * PAGE_SIZE,
|
||||
(page + 1) * PAGE_SIZE,
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!memoryToDelete) return;
|
||||
try {
|
||||
await api.delete(MEMORY_ENDPOINTS.BY_ID(memoryToDelete.id));
|
||||
toast({ title: "Memory deleted", variant: "success" });
|
||||
if (selectedMemory?.id === memoryToDelete.id) setSelectedMemory(null);
|
||||
setMemoryToDelete(null);
|
||||
void refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to delete memory",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "memory" as keyof Memory,
|
||||
label: "Content",
|
||||
width: 400,
|
||||
render: (value: string) => (
|
||||
<span className="line-clamp-2 text-sm">{value}</span>
|
||||
),
|
||||
},
|
||||
{ key: "user_id" as keyof Memory, label: "User", width: 100 },
|
||||
{ key: "agent_id" as keyof Memory, label: "Agent", width: 100 },
|
||||
{
|
||||
key: "created_at" as keyof Memory,
|
||||
label: "Created",
|
||||
width: 120,
|
||||
render: (value: string) =>
|
||||
value ? format(new Date(value), "MMM d, yyyy") : "--",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold font-fustat">Memories</h1>
|
||||
|
||||
{memories.length >= 1000 && (
|
||||
<UpgradeBanner
|
||||
id="memories-1k"
|
||||
message="1,000+ memories stored. Categories can help organize them."
|
||||
ctaLabel="Explore Cloud"
|
||||
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-memories"
|
||||
variant="cloud"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Filter by User ID (optional)"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(0);
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={5} columns={4} />
|
||||
) : memories.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No memories yet"
|
||||
description="Create your first memory by sending a POST /memories request."
|
||||
>
|
||||
<pre className="text-xs text-left bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto mt-3 max-w-lg">
|
||||
{`curl -X POST ${apiUrl}/memories \\
|
||||
-H "X-API-Key: <your-key>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"messages": [{"role": "user", "content": "I like hiking"}], "user_id": "alice"}'`}
|
||||
</pre>
|
||||
<a
|
||||
href="https://docs.mem0.ai/open-source/features/rest-api#memory-operations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-onSurface-default-tertiary underline underline-offset-4 hover:text-onSurface-default-primary mt-2"
|
||||
>
|
||||
REST API reference
|
||||
</a>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={paginatedMemories}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
onRowClick={(row) => setSelectedMemory(row)}
|
||||
getRowClassName={(row) =>
|
||||
selectedMemory?.id === row.id
|
||||
? "bg-surface-default-tertiary"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
|
||||
<span>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, memories.length)} of{" "}
|
||||
{memories.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={!!selectedMemory}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedMemory(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent className="sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Memory Detail</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
View memory content and metadata
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedMemory && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Content
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.memory}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
ID
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all">
|
||||
{selectedMemory.id}
|
||||
</p>
|
||||
</div>
|
||||
{selectedMemory.user_id && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
User
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.user_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemory.agent_id && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Agent
|
||||
</Label>
|
||||
<p className="text-sm">{selectedMemory.agent_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemory.created_at && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-onSurface-default-tertiary">
|
||||
Created
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{new Date(selectedMemory.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-onSurface-danger-primary"
|
||||
onClick={() => setMemoryToDelete(selectedMemory)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
Delete memory
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!memoryToDelete}
|
||||
onClose={() => setMemoryToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete memory"
|
||||
description="This memory will be permanently removed. This cannot be undone."
|
||||
itemName={memoryToDelete?.id ?? ""}
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { TableSkeleton } from "@/components/shared/table-skeleton";
|
||||
import { EmptyState } from "@/components/self-hosted/empty-state";
|
||||
import { api } from "@/utils/api";
|
||||
import { REQUEST_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { ApiRequestLog } from "@/types/api";
|
||||
|
||||
type RequestLog = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
authType: string;
|
||||
};
|
||||
|
||||
const REQUEST_LOG_LIMIT = 200;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const getStatusClassName = (statusCode: number) => {
|
||||
if (statusCode >= 500) {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
|
||||
}
|
||||
|
||||
if (statusCode >= 400) {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
|
||||
}
|
||||
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/40 dark:text-emerald-300";
|
||||
};
|
||||
|
||||
const getMethodClassName = (method: string) => {
|
||||
switch (method.toUpperCase()) {
|
||||
case "POST":
|
||||
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/40 dark:text-sky-300";
|
||||
case "PUT":
|
||||
case "PATCH":
|
||||
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
|
||||
case "DELETE":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
|
||||
default:
|
||||
return "border-memBorder-primary bg-surface-default-secondary text-onSurface-default-secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthLabel = (authType: string) => {
|
||||
switch (authType.toLowerCase()) {
|
||||
case "bearer":
|
||||
return "JWT";
|
||||
case "api_key":
|
||||
return "API Key";
|
||||
case "admin_api_key":
|
||||
return "Admin Key";
|
||||
case "disabled":
|
||||
return "Disabled";
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLog = (entry: ApiRequestLog): RequestLog => {
|
||||
return {
|
||||
id: entry.id,
|
||||
createdAt: entry.created_at,
|
||||
method: entry.method,
|
||||
path: entry.path,
|
||||
statusCode: entry.status_code,
|
||||
latencyMs: entry.latency_ms,
|
||||
authType: entry.auth_type,
|
||||
};
|
||||
};
|
||||
|
||||
export default function RequestsPage() {
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const {
|
||||
data: logs = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useApiQuery<RequestLog[]>(
|
||||
async () => {
|
||||
const res = await api.get<ApiRequestLog[]>(REQUEST_ENDPOINTS.BASE, {
|
||||
params: { limit: REQUEST_LOG_LIMIT },
|
||||
});
|
||||
setLastUpdated(new Date().toISOString());
|
||||
return (res.data ?? []).map(normalizeLog);
|
||||
},
|
||||
{ errorToast: "Failed to load request logs", initialData: [] },
|
||||
);
|
||||
|
||||
const totalRequests = logs.length;
|
||||
const successfulRequests = logs.filter((log) => log.statusCode < 400).length;
|
||||
const successRate =
|
||||
totalRequests > 0
|
||||
? Math.round((successfulRequests / totalRequests) * 100)
|
||||
: 0;
|
||||
const averageLatency =
|
||||
totalRequests > 0
|
||||
? Math.round(
|
||||
logs.reduce((sum, log) => sum + log.latencyMs, 0) / totalRequests,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "createdAt" as keyof RequestLog,
|
||||
label: "Time",
|
||||
width: 140,
|
||||
render: (value: string) => (
|
||||
<span title={format(new Date(value), "PPpp")}>
|
||||
{formatDistanceToNow(new Date(value), { addSuffix: true })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "method" as keyof RequestLog,
|
||||
label: "Method",
|
||||
width: 96,
|
||||
render: (value: string) => (
|
||||
<Badge variant="outline" className={getMethodClassName(value)}>
|
||||
{value.toUpperCase()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "path" as keyof RequestLog,
|
||||
label: "Path",
|
||||
width: 360,
|
||||
render: (value: string) => (
|
||||
<span className="font-mono text-xs break-all text-onSurface-default-primary">
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "statusCode" as keyof RequestLog,
|
||||
label: "Status",
|
||||
width: 120,
|
||||
render: (value: number) => (
|
||||
<Badge variant="outline" className={getStatusClassName(value)}>
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latencyMs" as keyof RequestLog,
|
||||
label: "Latency",
|
||||
width: 100,
|
||||
render: (value: number) => <span>{value} ms</span>,
|
||||
},
|
||||
{
|
||||
key: "authType" as keyof RequestLog,
|
||||
label: "Auth",
|
||||
width: 120,
|
||||
render: (value: string) => getAuthLabel(value),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold font-fustat">Requests</h1>
|
||||
<p className="text-sm text-onSurface-default-secondary">
|
||||
Recent request logs from your self-hosted instance.
|
||||
</p>
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Last updated{" "}
|
||||
{formatDistanceToNow(new Date(lastUpdated), { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
void refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: "Total Requests", value: totalRequests },
|
||||
{
|
||||
label: "Success Rate",
|
||||
value: totalRequests > 0 ? `${successRate}%` : "--",
|
||||
},
|
||||
{
|
||||
label: "Avg Latency",
|
||||
value: totalRequests > 0 ? `${averageLatency} ms` : "--",
|
||||
},
|
||||
].map((card) => (
|
||||
<Card key={card.label} className="border-memBorder-primary">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-4 text-sm text-onSurface-danger-primary">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} columns={6} />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No request logs yet"
|
||||
description="Requests will appear here once your instance receives traffic."
|
||||
image="requests"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-memBorder-primary overflow-hidden">
|
||||
<DataTable
|
||||
data={logs.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
</Card>
|
||||
{logs.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
|
||||
<span>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, logs.length)} of {logs.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(page + 1) * PAGE_SIZE >= logs.length}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { api } from "@/utils/api";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name);
|
||||
setEmail(user.email);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const profileDirty =
|
||||
user !== null && (name !== user.name || email !== user.email);
|
||||
const profileValid = name.trim().length > 0 && email.trim().length > 0;
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
await api.patch(AUTH_ENDPOINTS.ME, {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
});
|
||||
await refreshUser();
|
||||
toast({ title: "Profile updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to update profile",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({
|
||||
title: "Passwords don't match",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await api.post(AUTH_ENDPOINTS.CHANGE_PASSWORD, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
toast({ title: "Password updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to update password",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold font-fustat">Settings</h1>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-name" className="text-xs">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-email" className="text-xs">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={!profileDirty || !profileValid || savingProfile}
|
||||
>
|
||||
{savingProfile ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Password</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-current-password" className="text-xs">
|
||||
Current password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-new-password" className="text-xs">
|
||||
New password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-confirm-password" className="text-xs">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<Input
|
||||
id="settings-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleChangePassword}
|
||||
disabled={
|
||||
!currentPassword ||
|
||||
newPassword.length < 8 ||
|
||||
!confirmPassword ||
|
||||
savingPassword
|
||||
}
|
||||
>
|
||||
{savingPassword ? "Saving..." : "Update password"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Appearance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-onSurface-default-secondary">
|
||||
Theme
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTheme("light")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("dark")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Moon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("system")}
|
||||
className="p-2 rounded hover:bg-surface-default-secondary-hover"
|
||||
>
|
||||
<Monitor className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { LockedPage } from "@/components/self-hosted/locked-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function WebhooksMockup() {
|
||||
return (
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Endpoint URL</Label>
|
||||
<Input placeholder="https://your-app.com/webhooks/mem0" disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Events</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
"memory.created",
|
||||
"memory.updated",
|
||||
"memory.deleted",
|
||||
"search.performed",
|
||||
].map((event) => (
|
||||
<div key={event} className="flex items-center gap-2">
|
||||
<Checkbox disabled checked={event === "memory.created"} />
|
||||
<Label className="text-sm font-normal">{event}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Signing Secret</Label>
|
||||
<Input placeholder="whsec_..." disabled type="password" />
|
||||
</div>
|
||||
<Button disabled>Create Webhook</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
return (
|
||||
<LockedPage
|
||||
title="Webhooks"
|
||||
description="Get notified when memories are created, updated, or deleted."
|
||||
previewContent={<WebhooksMockup />}
|
||||
utmMedium="dashboard-locked-webhooks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import localFont from "next/font/local";
|
||||
|
||||
export const Inter = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/InterVariable.woff2",
|
||||
weight: "100 900",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/InterVariable-Italic.woff2",
|
||||
weight: "100 900",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const InterDisplay = Inter;
|
||||
|
||||
export const Roboto = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/RobotoMono-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/RobotoMono-Bold.ttf",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-roboto-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const Fustat = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/Fustat-VariableFont_wght.ttf",
|
||||
weight: "200 800",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-fustat",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const DMMono = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Light.ttf",
|
||||
weight: "300",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-LightItalic.ttf",
|
||||
weight: "300",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Regular.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Italic.ttf",
|
||||
weight: "400",
|
||||
style: "italic",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-Medium.ttf",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "../../../public/fonts/DMMono-MediumItalic.ttf",
|
||||
weight: "500",
|
||||
style: "italic",
|
||||
},
|
||||
],
|
||||
variable: "--font-dm-mono",
|
||||
display: "swap",
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Metadata } from "next";
|
||||
import { DashboardClientLayout } from "./dashboard-client-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | Mem0",
|
||||
description: "Mem0 Dashboard",
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <DashboardClientLayout>{children}</DashboardClientLayout>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { getServerApiUrl } from "@/lib/server-api-url";
|
||||
|
||||
const COOKIE_NAME = "mem0_refresh_token";
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
};
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: "No refresh token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${getServerApiUrl()}${AUTH_ENDPOINTS.REFRESH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Refresh token is invalid — clear cookie
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
return NextResponse.json({ error: "Refresh failed" }, { status: 401 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
cookieStore.set(COOKIE_NAME, data.refresh_token, COOKIE_OPTIONS);
|
||||
|
||||
return NextResponse.json({ access_token: data.access_token });
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (!body.refresh_token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing refresh_token" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
cookieStore.set(COOKIE_NAME, body.refresh_token, COOKIE_OPTIONS);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,42 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Inter, Fustat, Roboto, DMMono, InterDisplay } from "../(root)/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
export const metadata = {
|
||||
title: "Setup | Mem0",
|
||||
description: "Set up your Mem0 instance",
|
||||
};
|
||||
|
||||
export default function SetupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(
|
||||
Fustat.variable,
|
||||
InterDisplay.variable,
|
||||
Inter.variable,
|
||||
Roboto.variable,
|
||||
DMMono.variable,
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="font-fustat" suppressHydrationWarning>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
API_KEY_ENDPOINTS,
|
||||
AUTH_ENDPOINTS,
|
||||
MEMORY_ENDPOINTS,
|
||||
} from "@/utils/api-endpoints";
|
||||
import {
|
||||
buildProviderConfig,
|
||||
getEffectiveConfig,
|
||||
} from "@/utils/self-hosted-config";
|
||||
import { isValidEmail } from "@/lib/validators";
|
||||
|
||||
type BundledProviders = {
|
||||
llm: string[];
|
||||
embedder: string[];
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Admin Account",
|
||||
"Providers",
|
||||
"API Key",
|
||||
"Use Case",
|
||||
"Quick Test",
|
||||
];
|
||||
const STEP_TITLES = [
|
||||
"Create your admin account",
|
||||
"Review provider configuration",
|
||||
"Your API key",
|
||||
"Tell us your use case",
|
||||
"Test your setup",
|
||||
];
|
||||
const SUPPORTED_PROVIDERS_URL =
|
||||
"https://docs.mem0.ai/open-source/setup#supported-providers";
|
||||
|
||||
const USE_CASE_PRESETS = [
|
||||
"Personal assistant",
|
||||
"Coding agent",
|
||||
"Customer support",
|
||||
"Research",
|
||||
"Therapy / journaling",
|
||||
];
|
||||
|
||||
const DEFAULT_TEST_MESSAGE = "I like to hike on weekends.";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const { register } = useAuth();
|
||||
const [step, setStep] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPrefillingConfig, setIsPrefillingConfig] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [llmProvider, setLlmProvider] = useState("");
|
||||
const [llmModel, setLlmModel] = useState("");
|
||||
const [llmApiKey, setLlmApiKey] = useState("");
|
||||
const [embedderProvider, setEmbedderProvider] = useState("");
|
||||
const [embedderModel, setEmbedderModel] = useState("");
|
||||
const [serverHasLlmKey, setServerHasLlmKey] = useState(false);
|
||||
const [providers, setProviders] = useState<BundledProviders | null>(null);
|
||||
const [initialLlmProvider, setInitialLlmProvider] = useState("");
|
||||
const [initialLlmModel, setInitialLlmModel] = useState("");
|
||||
const [initialEmbedderProvider, setInitialEmbedderProvider] = useState("");
|
||||
const [initialEmbedderModel, setInitialEmbedderModel] = useState("");
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [keyLabel, setKeyLabel] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [testSuccess, setTestSuccess] = useState(false);
|
||||
|
||||
const [useCase, setUseCase] = useState("");
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const [testMessage, setTestMessage] = useState(DEFAULT_TEST_MESSAGE);
|
||||
const [isGeneratingInstructions, setIsGeneratingInstructions] =
|
||||
useState(false);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 1) {
|
||||
setIsPrefillingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsPrefillingConfig(true);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const [configRes, providersRes] = await Promise.all([
|
||||
api.get(MEMORY_ENDPOINTS.CONFIGURE),
|
||||
api.get<BundledProviders>(MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS),
|
||||
]);
|
||||
const config = getEffectiveConfig(configRes.data);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const llmProv = config?.llm?.provider || "";
|
||||
const llmMod = config?.llm?.config?.model || "";
|
||||
const embProv = config?.embedder?.provider || "";
|
||||
const embMod = config?.embedder?.config?.model || "";
|
||||
|
||||
setLlmProvider(llmProv);
|
||||
setLlmModel(llmMod);
|
||||
setEmbedderProvider(embProv);
|
||||
setEmbedderModel(embMod);
|
||||
setInitialLlmProvider(llmProv);
|
||||
setInitialLlmModel(llmMod);
|
||||
setInitialEmbedderProvider(embProv);
|
||||
setInitialEmbedderModel(embMod);
|
||||
setServerHasLlmKey(!!config?.llm?.config?.api_key);
|
||||
setProviders(providersRes.data);
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
setError(getErrorMessage(err, "Could not read server configuration"));
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsPrefillingConfig(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadConfig();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [step]);
|
||||
|
||||
const handleStep1 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register(name, email, password);
|
||||
setStep(1);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Registration failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep2 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const dirty =
|
||||
!!llmApiKey ||
|
||||
llmProvider !== initialLlmProvider ||
|
||||
llmModel !== initialLlmModel ||
|
||||
embedderProvider !== initialEmbedderProvider ||
|
||||
embedderModel !== initialEmbedderModel;
|
||||
|
||||
if (!dirty) {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const llm = buildProviderConfig({
|
||||
provider: llmProvider,
|
||||
model: llmModel,
|
||||
apiKey: llmApiKey,
|
||||
});
|
||||
const embedder = buildProviderConfig({
|
||||
provider: embedderProvider,
|
||||
model: embedderModel,
|
||||
apiKey:
|
||||
llmApiKey && embedderProvider === llmProvider ? llmApiKey : undefined,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = { version: "v1.1" };
|
||||
if (llm) payload.llm = llm;
|
||||
if (embedder) payload.embedder = embedder;
|
||||
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, payload);
|
||||
if (llmApiKey) setServerHasLlmKey(true);
|
||||
setLlmApiKey("");
|
||||
setInitialLlmProvider(llmProvider);
|
||||
setInitialLlmModel(llmModel);
|
||||
setInitialEmbedderProvider(embedderProvider);
|
||||
setInitialEmbedderModel(embedderModel);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Failed to save configuration"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep3 = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post(API_KEY_ENDPOINTS.BASE, {
|
||||
label: keyLabel.trim() || "My First Key",
|
||||
});
|
||||
setApiKey(res.data.key);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Failed to create API key"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueToUseCase = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleContinueToQuickTest = () => {
|
||||
setError("");
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
const handleGoToDashboard = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/requests");
|
||||
};
|
||||
|
||||
const handleTest = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/memories`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: testMessage }],
|
||||
user_id: "setup-test",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let detail = body;
|
||||
try {
|
||||
detail = JSON.parse(body).detail ?? body;
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Test failed (${res.status}): ${detail || res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
setTestSuccess(true);
|
||||
void api
|
||||
.post(AUTH_ENDPOINTS.ONBOARDING_COMPLETE, { use_case: useCase })
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Test failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-default-primary p-4">
|
||||
<div className="w-full max-w-[560px] space-y-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-7 rounded-full flex items-center justify-center text-xs font-medium",
|
||||
i <= step
|
||||
? "bg-memPurple-500 text-white"
|
||||
: "bg-memNeutral-200 text-onSurface-default-tertiary",
|
||||
)}
|
||||
>
|
||||
{i < step ? <Check className="size-3.5" /> : i + 1}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-[2px]",
|
||||
i < step ? "bg-memPurple-500" : "bg-memNeutral-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-sm text-onSurface-default-tertiary">
|
||||
{STEPS[step]}
|
||||
</p>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold font-fustat">
|
||||
{STEP_TITLES[step]}
|
||||
</h2>
|
||||
{isPrefillingConfig && step === 1 && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Checking server configuration...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-onSurface-danger-primary">{error}</p>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<form onSubmit={handleStep1} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-name">Name</Label>
|
||||
<Input
|
||||
id="setup-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-email">Email</Label>
|
||||
<Input
|
||||
id="setup-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-password">Password</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-confirm-password">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !name || !email || !password}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Admin Account"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<form onSubmit={handleStep2} className="space-y-4">
|
||||
{!serverHasLlmKey && (
|
||||
<div className="rounded-md border border-memBorder-primary bg-surface-default-secondary p-3">
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
No LLM provider API key is configured on the server. Paste
|
||||
one below to continue — it will be saved to the server and
|
||||
used for all memory operations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-provider">LLM Provider</Label>
|
||||
<Select
|
||||
value={llmProvider}
|
||||
onValueChange={setLlmProvider}
|
||||
disabled={!providers}
|
||||
>
|
||||
<SelectTrigger id="setup-llm-provider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.llm.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-model">Model</Label>
|
||||
<Input
|
||||
id="setup-llm-model"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder="gpt-4.1-nano-2025-04-14"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-llm-api-key">LLM API Key</Label>
|
||||
<Input
|
||||
id="setup-llm-api-key"
|
||||
type="password"
|
||||
value={llmApiKey}
|
||||
onChange={(e) => setLlmApiKey(e.target.value)}
|
||||
placeholder={
|
||||
serverHasLlmKey
|
||||
? "Leave blank to keep existing key"
|
||||
: "sk-..."
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Also used for the embedder when it shares the same provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-embedder-provider">
|
||||
Embedder Provider
|
||||
</Label>
|
||||
<Select
|
||||
value={embedderProvider}
|
||||
onValueChange={setEmbedderProvider}
|
||||
disabled={!providers}
|
||||
>
|
||||
<SelectTrigger id="setup-embedder-provider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.embedder.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-embedder-model">Model</Label>
|
||||
<Input
|
||||
id="setup-embedder-model"
|
||||
value={embedderModel}
|
||||
onChange={(e) => setEmbedderModel(e.target.value)}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Need another provider? Install its Python package and rebuild
|
||||
the image. See{" "}
|
||||
<a
|
||||
href={SUPPORTED_PROVIDERS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
supported providers
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!llmProvider ||
|
||||
!embedderProvider ||
|
||||
(!serverHasLlmKey && !llmApiKey)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save & Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && !apiKey && (
|
||||
<form onSubmit={handleStep3} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-key-label">Label for this key</Label>
|
||||
<Input
|
||||
id="setup-key-label"
|
||||
value={keyLabel}
|
||||
onChange={(e) => setKeyLabel(e.target.value)}
|
||||
placeholder="My First Key"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? "Generating..." : "Generate API Key"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && apiKey && (
|
||||
<form onSubmit={handleContinueToUseCase} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-api-key">Your API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="setup-api-key"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={apiKey}
|
||||
onCopy={() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
Save this key. You will not see it again.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-use-case">Describe your use case</Label>
|
||||
<textarea
|
||||
id="setup-use-case"
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
placeholder="e.g. A personal assistant that remembers my preferences"
|
||||
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[80px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{USE_CASE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUseCase(preset)}
|
||||
className={cn(
|
||||
useCase === preset &&
|
||||
"border-memPurple-500 text-memPurple-500",
|
||||
)}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
We'll generate custom instructions that tell the memory
|
||||
extractor which facts to prioritize for your use case.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleContinueToQuickTest}
|
||||
className="flex-1"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!useCase || isGeneratingInstructions}
|
||||
className="flex-1"
|
||||
onClick={async () => {
|
||||
setError("");
|
||||
setIsGeneratingInstructions(true);
|
||||
try {
|
||||
const res = await api.post(
|
||||
MEMORY_ENDPOINTS.GENERATE_INSTRUCTIONS,
|
||||
{
|
||||
use_case: useCase,
|
||||
},
|
||||
);
|
||||
setCustomInstructions(res.data.custom_instructions);
|
||||
if (res.data.test_message) {
|
||||
setTestMessage(res.data.test_message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
getErrorMessage(
|
||||
err,
|
||||
"Failed to generate instructions",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingInstructions(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGeneratingInstructions
|
||||
? "Generating instructions..."
|
||||
: "Generate instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
{customInstructions && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="setup-instructions">
|
||||
Generated instructions
|
||||
</Label>
|
||||
<textarea
|
||||
id="setup-instructions"
|
||||
value={customInstructions}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[120px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.post(MEMORY_ENDPOINTS.CONFIGURE, {
|
||||
custom_instructions: customInstructions,
|
||||
});
|
||||
handleContinueToQuickTest();
|
||||
} catch (err) {
|
||||
setError(
|
||||
getErrorMessage(err, "Failed to save instructions"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save & Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<form
|
||||
onSubmit={testSuccess ? handleGoToDashboard : handleTest}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label>Test your setup</Label>
|
||||
{!apiUrl && (
|
||||
<p className="text-xs text-onSurface-danger-primary">
|
||||
NEXT_PUBLIC_API_URL is not set. Set it in .env and restart
|
||||
before running this test.
|
||||
</p>
|
||||
)}
|
||||
<pre className="text-xs bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto">{`curl -X POST ${apiUrl}/memories \\
|
||||
-H "X-API-Key: ${apiKey}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"messages": [{"role": "user", "content": "${testMessage}"}], "user_id": "setup-test"}'`}</pre>
|
||||
</div>
|
||||
{!testSuccess ? (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? "Testing..." : "Run Test"}
|
||||
</Button>
|
||||
{error && (
|
||||
<p className="text-xs text-onSurface-default-tertiary">
|
||||
Provider credentials or model wrong? Fix them in{" "}
|
||||
<a
|
||||
href="/dashboard/configuration"
|
||||
className="underline underline-offset-4 hover:text-onSurface-default-primary"
|
||||
>
|
||||
Configuration
|
||||
</a>{" "}
|
||||
and run the test again.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-onSurface-positive-primary">
|
||||
<Check className="size-4" /> Memory created successfully
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
|
||||
interface CopyButtonProps {
|
||||
textToCopy: string;
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({ textToCopy }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<CopyToClipboard text={textToCopy} onCopy={handleCopy}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 bg-white hover:bg-gray-100 p-2 text-black rounded-md"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
<CopyIcon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
export function Spinner({ size = "small", className = "" }) {
|
||||
const spinnerSize = size === "small" ? "w-4 h-4" : "w-6 h-6";
|
||||
return (
|
||||
<div
|
||||
className={`${spinnerSize} ${className} animate-spin rounded-full border-y-2 border-memBorder-primary`}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export default function ThemeAwareLogo({
|
||||
width = 120,
|
||||
height = 40,
|
||||
}: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div style={{ width, height }} />;
|
||||
}
|
||||
|
||||
const currentTheme = theme === "system" ? resolvedTheme : theme;
|
||||
const logoSrc =
|
||||
currentTheme === "dark" ? "/images/dark.svg" : "/images/light.svg";
|
||||
|
||||
return <Image src={logoSrc} alt="Mem0.ai" width={width} height={height} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const TruncatedText: React.FC<{ text: string; limit?: number }> = ({
|
||||
text,
|
||||
limit = 100,
|
||||
}) => {
|
||||
if (!text) return null;
|
||||
if (text.length <= limit) return <span>{text}</span>;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="cursor-pointer">{text?.slice(0, limit)}...</span>
|
||||
</HoverCardTrigger>
|
||||
{createPortal(
|
||||
<HoverCardContent
|
||||
className="text-foreground p-4 shadow-lg rounded-lg z-[9999]"
|
||||
style={{
|
||||
minWidth: "400px",
|
||||
maxWidth: "600px",
|
||||
width: "auto", // Allows it to adapt based on content
|
||||
transform: "translateY(10px)",
|
||||
whiteSpace: "normal", // Wraps text to prevent overflow
|
||||
overflowWrap: "break-word", // Breaks long words to fit within max-width
|
||||
}}
|
||||
>
|
||||
<p className="">{text}</p>
|
||||
</HoverCardContent>,
|
||||
document.body,
|
||||
)}
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: "memories" | "requests";
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
image = "memories",
|
||||
children,
|
||||
}: EmptyStateProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const src =
|
||||
resolvedTheme === "dark"
|
||||
? `/images/no-${image}-dark.svg`
|
||||
: `/images/no-${image}.svg`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
width={120}
|
||||
height={120}
|
||||
className="mb-4 opacity-80"
|
||||
/>
|
||||
<p className="text-sm font-medium text-onSurface-default-primary">
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-onSurface-default-tertiary mt-1 max-w-xs">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
interface LockedPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
previewContent: React.ReactNode;
|
||||
utmMedium: string;
|
||||
}
|
||||
|
||||
export function LockedPage({
|
||||
title,
|
||||
description,
|
||||
previewContent,
|
||||
utmMedium,
|
||||
}: LockedPageProps) {
|
||||
const utm = `utm_source=oss&utm_medium=${utmMedium}`;
|
||||
const cloudUrl = `https://app.mem0.ai?${utm}`;
|
||||
const salesUrl = `https://app.mem0.ai/enterprise?${utm}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold font-fustat flex items-center gap-2">
|
||||
{title}
|
||||
<Lock className="size-4 text-onSurface-default-tertiary" />
|
||||
</h1>
|
||||
<p className="text-sm text-onSurface-default-secondary mt-1">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="opacity-60 pointer-events-none select-none">
|
||||
{previewContent}
|
||||
</div>
|
||||
|
||||
<Card className="border-memBorder-primary">
|
||||
<CardContent className="flex flex-col sm:flex-row items-center gap-4 py-6">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
This feature is available in Mem0 Cloud and Enterprise.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="default" asChild>
|
||||
<a href={cloudUrl} target="_blank" rel="noopener noreferrer">
|
||||
Start free on Cloud
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href={salesUrl} target="_blank" rel="noopener noreferrer">
|
||||
Talk to sales
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UpgradeBannerProps {
|
||||
id: string;
|
||||
message: string;
|
||||
ctaLabel: string;
|
||||
ctaUrl: string;
|
||||
variant: "cloud" | "enterprise";
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export function UpgradeBanner({
|
||||
id,
|
||||
message,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
variant,
|
||||
dismissible = true,
|
||||
}: UpgradeBannerProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(localStorage.getItem(`nudge_${id}_dismissed`) === "true");
|
||||
setMounted(true);
|
||||
}, [id]);
|
||||
|
||||
if (!mounted || dismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(`nudge_${id}_dismissed`, "true");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 rounded-md typo-body-xs font-fustat",
|
||||
variant === "cloud"
|
||||
? "bg-memGold-100 border-l-2 border-memGold-500"
|
||||
: "bg-memRed-100 border-l-2 border-memRed-400",
|
||||
)}
|
||||
>
|
||||
<p className="flex-1 text-onSurface-default-secondary">{message}</p>
|
||||
<a
|
||||
href={ctaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"text-xs font-medium whitespace-nowrap underline",
|
||||
variant === "cloud" ? "text-memGold-700" : "text-memRed-600",
|
||||
)}
|
||||
>
|
||||
{ctaLabel}
|
||||
</a>
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-0.5 rounded hover:bg-surface-default-secondary-hover text-onSurface-default-tertiary"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
render?(value: T[keyof T], row: T): ReactNode;
|
||||
className?: string;
|
||||
width?: number | "auto";
|
||||
align?: "left" | "center" | "right";
|
||||
cellVariant?: "default" | "flush";
|
||||
headerVariant?: "default" | "check";
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
className?: string;
|
||||
getRowKey?: (row: T, rowIndex: number) => string | number;
|
||||
onRowClick?: (row: T, rowIndex: number) => void;
|
||||
getRowClassName?: (row: T, rowIndex: number) => string | undefined;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
tableHeaderRow: "h-[38px] border-b border-memBorder-primary",
|
||||
tableHeaderCell:
|
||||
"w-[230px] h-[38px] p-2 align-middle bg-surface-default-fg-secondary text-onSurface-default-secondary",
|
||||
tableHeaderCheckCell:
|
||||
"w-[40px] h-[38px] p-2 align-middle bg-surface-default-fg-secondary",
|
||||
tableHeaderCheckWrap: "flex items-center gap-2",
|
||||
tableHeaderCheckBox:
|
||||
"box-border flex h-4 w-4 items-center gap-2.5 rounded-sm border border-memBorder-primary p-1",
|
||||
tableHeaderDivider:
|
||||
"w-px self-stretch border border-memBorder-primary shrink-0",
|
||||
tableRow:
|
||||
"h-[38px] border-t border-memBorder-primary bg-surface-default-primary hover:bg-surface-default-primary-hover",
|
||||
tableCell:
|
||||
"text-sm font-medium text-onSurface-default-primary px-4 py-2 justify-start align-middle font-[Fustat] leading-[140%] tracking-normal",
|
||||
tableCellFlush: "align-middle",
|
||||
tableCellBase: "text-sm px-6",
|
||||
tableCellPadding: "",
|
||||
} as const;
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
className = "",
|
||||
getRowKey,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: DataTableProps<T>) {
|
||||
const minHeight = data.length > 0 ? Math.max(76, 38 + data.length * 38) : 100;
|
||||
// Proportional column widths so table fits container (width numbers treated as relative weights)
|
||||
const totalWeight = columns.reduce(
|
||||
(sum, col) => sum + (typeof col.width === "number" ? col.width : 100),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`min-w-0 max-w-full overflow-hidden transition-all duration-300 ease-in-out ${className}`}
|
||||
style={{ minHeight: `${minHeight}px` }}
|
||||
>
|
||||
<table className="table-fixed w-full">
|
||||
<colgroup>
|
||||
{columns.map((col, i) => {
|
||||
const weight = typeof col.width === "number" ? col.width : 100;
|
||||
const pct =
|
||||
totalWeight > 0
|
||||
? (weight / totalWeight) * 100
|
||||
: 100 / columns.length;
|
||||
return <col key={i} style={{ width: `${pct}%` }} />;
|
||||
})}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className={classes.tableHeaderRow}>
|
||||
{columns.map((column, index) => {
|
||||
const isLastColumn = index === columns.length - 1;
|
||||
|
||||
if (column.headerVariant === "check") {
|
||||
return (
|
||||
<th key={index} className={classes.tableHeaderCheckCell}>
|
||||
<div className="flex h-full items-stretch justify-between">
|
||||
<div className={classes.tableHeaderCheckWrap}>
|
||||
<span className={classes.tableHeaderCheckBox} />
|
||||
</div>
|
||||
{!isLastColumn && (
|
||||
<div className={classes.tableHeaderDivider} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = column.icon;
|
||||
const relevantClasses = column.className
|
||||
? column.className
|
||||
.split(" ")
|
||||
.filter(
|
||||
(c) =>
|
||||
c.startsWith("w-") ||
|
||||
c.startsWith("min-w-") ||
|
||||
c.startsWith("max-w-") ||
|
||||
c.startsWith("text-center") ||
|
||||
c.startsWith("text-left") ||
|
||||
c.startsWith("text-right"),
|
||||
)
|
||||
.join(" ")
|
||||
: "";
|
||||
|
||||
const baseHeaderClass = classes.tableHeaderCell;
|
||||
const alignClass =
|
||||
column.align === "center"
|
||||
? "text-center"
|
||||
: column.align === "right"
|
||||
? "text-right"
|
||||
: "";
|
||||
const mergedRelevantClasses =
|
||||
`${relevantClasses} ${alignClass}`.trim();
|
||||
const hasCustomAlignment =
|
||||
mergedRelevantClasses.includes("text-center") ||
|
||||
mergedRelevantClasses.includes("text-right");
|
||||
const headerClassName = hasCustomAlignment
|
||||
? `${baseHeaderClass.replace("text-left", "")} ${mergedRelevantClasses}`.trim()
|
||||
: mergedRelevantClasses
|
||||
? `${baseHeaderClass} ${mergedRelevantClasses}`.trim()
|
||||
: baseHeaderClass;
|
||||
const headerCellClassName = `${headerClassName} min-w-0`;
|
||||
|
||||
const flexAlignment = mergedRelevantClasses.includes(
|
||||
"text-center",
|
||||
)
|
||||
? "justify-center"
|
||||
: mergedRelevantClasses.includes("text-right")
|
||||
? "justify-end"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<th key={index} className={headerCellClassName}>
|
||||
<div className="flex h-full min-w-0 items-stretch justify-between">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden ${flexAlignment}`}
|
||||
>
|
||||
{Icon && <Icon className="size-4 shrink-0" />}
|
||||
<span className="truncate font-[Fustat] text-sm font-semibold leading-[18px] text-onSurface-default-secondary">
|
||||
{column.label}
|
||||
</span>
|
||||
</div>
|
||||
{!isLastColumn && (
|
||||
<div className={classes.tableHeaderDivider} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="transition-all duration-300 ease-in-out">
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={getRowKey ? String(getRowKey(row, rowIndex)) : rowIndex}
|
||||
className={`${classes.tableRow} ${
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
} ${getRowClassName ? (getRowClassName(row, rowIndex) ?? "") : ""} animate-fade-in`}
|
||||
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
|
||||
>
|
||||
{columns.map((column, colIndex) => {
|
||||
const value = row[column.key];
|
||||
const baseCellClass =
|
||||
column.cellVariant === "flush"
|
||||
? classes.tableCellFlush
|
||||
: classes.tableCell;
|
||||
const cellClassName = `${column.className || baseCellClass} min-w-0 overflow-hidden`;
|
||||
return (
|
||||
<td key={colIndex} className={cellClassName}>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
{column.render
|
||||
? column.render(value, row)
|
||||
: String(value)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { classes as tableClasses };
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Plus, RefreshCw, SearchCode, Trash, UserRound } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EventBadgeProps {
|
||||
event: string;
|
||||
type?: string;
|
||||
count?: number;
|
||||
label?: string;
|
||||
icon?: React.ElementType;
|
||||
showIcon?: boolean;
|
||||
variant?: "primary" | "secondary";
|
||||
}
|
||||
|
||||
type BadgeVariant = "add" | "update" | "retrieved" | "delete" | "user";
|
||||
|
||||
const getBadgeConfig = (
|
||||
type: string,
|
||||
): { variant: BadgeVariant; icon: React.ElementType } => {
|
||||
switch (type.toUpperCase()) {
|
||||
case "ADD":
|
||||
return { variant: "add", icon: Plus };
|
||||
case "UPDATE":
|
||||
return { variant: "update", icon: RefreshCw };
|
||||
case "SEARCH":
|
||||
case "GET_ALL":
|
||||
case "GET":
|
||||
return { variant: "retrieved", icon: SearchCode };
|
||||
case "DELETE":
|
||||
return { variant: "delete", icon: Trash };
|
||||
case "USER":
|
||||
case "USERS":
|
||||
return { variant: "user", icon: UserRound };
|
||||
default:
|
||||
return { variant: "add", icon: Plus };
|
||||
}
|
||||
};
|
||||
|
||||
export function EventBadge({
|
||||
event,
|
||||
type,
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
showIcon = true,
|
||||
variant = "primary",
|
||||
}: EventBadgeProps) {
|
||||
const resolvedType = (type ?? event).toUpperCase();
|
||||
const { variant: badgeVariant, icon: DefaultIcon } =
|
||||
getBadgeConfig(resolvedType);
|
||||
const Icon = icon ?? DefaultIcon;
|
||||
const content = label ?? (typeof count === "number" ? String(count) : "");
|
||||
|
||||
if (count === 0 && !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full items-center justify-center gap-1 overflow-hidden rounded-sm px-1.5 py-0.5",
|
||||
variant === "secondary"
|
||||
? "bg-surface-default-fg-secondary"
|
||||
: "bg-surface-default-tertiary",
|
||||
)}
|
||||
aria-label={`${event} ${badgeVariant} count`}
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon className="size-3.5 shrink-0 text-onSurface-default-secondary" />
|
||||
)}
|
||||
{content && (
|
||||
<span className="min-w-0 truncate font-dm-mono text-xs font-normal leading-[18px] text-onSurface-default-secondary">
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5, columns = 5 }: TableSkeletonProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Table Header */}
|
||||
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
{[...Array(columns - 1)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{[...Array(rows)].map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex items-center gap-4 px-4 py-3">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
{[...Array(columns - 2)].map((_, colIndex) => (
|
||||
<Skeleton key={colIndex} className="h-4 w-24" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-surface-default-primary p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-surface-default-primary text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 font-fustat font-semibold",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-onSurface-danger-primary hover:bg-onSurface-danger-secondary dark:bg-onSurface-danger-secondary dark:hover:bg-onSurface-danger-primary text-white",
|
||||
outline:
|
||||
"border border-memBorder-primary bg-surface-default-primary hover:bg-neutral-100 hover:text-neutral-800",
|
||||
secondary:
|
||||
"bg-surface-default-fg-secondary text-onSurface-default-primary hover:bg-surface-default-fg-secondary-hover border border-memBorder-primary",
|
||||
ghost: "hover:bg-accent/30 hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
grey: "bg-accent/30 text-accent-foreground hover:bg-accent/80",
|
||||
primary:
|
||||
"bg-onSurface-default-primary text-surface-default-primary hover:bg-onSurface-default-primary/80",
|
||||
tertiary:
|
||||
"bg-surface-default-tertiary text-onSurface-default-primary hover:bg-surface-default-tertiary-hover",
|
||||
surfaceSecondary:
|
||||
"bg-surface-default-secondary text-onSurface-default-primary hover:bg-surface-default-secondary-hover border border-memBorder-primary",
|
||||
surfacePrimary:
|
||||
"bg-surface-default-primary text-onSurface-default-primary hover:bg-surface-default-primary-hover",
|
||||
transparent:
|
||||
"bg-transparent text-onSurface-default-primary hover:bg-transparent/80 hover:text-white",
|
||||
subtle:
|
||||
"bg-transparent text-onSurface-default-primary hover:bg-surface-default-fg-secondary-hover border-0",
|
||||
brand:
|
||||
"bg-surface-default-brand text-onSurface-default-primary hover:bg-surface-default-brand-hover",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-lg px-8",
|
||||
icon: "size-9",
|
||||
xl: "h-16 rounded-md px-5 ",
|
||||
xs: "h-[30px] px-3 text-[10px]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeftIcon className="size-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRightIcon className="size-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-surface-default-primary text-onSurface-default-primary border border-memBorder-primary shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface CategoriesDisplayProps {
|
||||
categories?: string[];
|
||||
showAllCategories?: boolean;
|
||||
}
|
||||
|
||||
export function CategoriesDisplay({
|
||||
categories,
|
||||
showAllCategories,
|
||||
}: CategoriesDisplayProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
|
||||
const [isPositioned, setIsPositioned] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPopoverPosition({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
setIsPositioned(true);
|
||||
} else {
|
||||
setIsPositioned(false);
|
||||
}
|
||||
}, [isHovered]);
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors = [
|
||||
"bg-purple-500",
|
||||
"bg-blue-500",
|
||||
"bg-green-500",
|
||||
"bg-yellow-500",
|
||||
"bg-red-500",
|
||||
"bg-indigo-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-orange-500",
|
||||
"bg-cyan-500",
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < category.length; i++) {
|
||||
hash = category.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
return categories || [];
|
||||
}, [categories]);
|
||||
|
||||
if (!allCategories || allCategories.length === 0) {
|
||||
return <span></span>;
|
||||
}
|
||||
|
||||
const firstCategory = allCategories[0];
|
||||
const remainingCount = allCategories.length - 1;
|
||||
|
||||
const popoverContent = isHovered &&
|
||||
isPositioned &&
|
||||
allCategories.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`,
|
||||
zIndex: 999999,
|
||||
}}
|
||||
className={`p-3 bg-surface-default-tertiary border border-memBorder-primary rounded-lg shadow-xl flex flex-col gap-2.5 min-w-max opacity-100 visible`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Map over all categories to show everything in the popover */}
|
||||
{allCategories.map((category, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap bg-surface-default-fg-secondary border border-memBorder-primary rounded-md px-2 py-1 text-onSurface-default-secondary"
|
||||
>
|
||||
<div
|
||||
className={`size-2 rounded-full ${getCategoryColor(category)}`}
|
||||
/>
|
||||
<span className="typo-body-xs">{category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showAllCategories) {
|
||||
return (
|
||||
<div className="relative flex flex-wrap gap-2">
|
||||
{allCategories.map((category, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap bg-surface-default-fg-secondary border border-memBorder-primary rounded-md px-2 py-1 text-onSurface-default-secondary"
|
||||
>
|
||||
<div
|
||||
className={`size-2 rounded-full ${getCategoryColor(category)}`}
|
||||
/>
|
||||
<span className="typo-body-xs">{category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Always show the first category */}
|
||||
<div className="inline-flex items-center gap-1.5 whitespace-nowrap bg-surface-default-fg-secondary border border-memBorder-primary rounded-md px-2 py-1 text-onSurface-default-secondary">
|
||||
<div
|
||||
className={`size-2 rounded-full ${getCategoryColor(firstCategory)}`}
|
||||
/>
|
||||
<span className="typo-body-xs">{firstCategory}</span>
|
||||
</div>
|
||||
|
||||
{/* Show count badge if there are more categories */}
|
||||
{remainingCount > 0 && (
|
||||
<div className="flex items-center justify-center px-2 py-1.5 typo-body-xs text-onSurface-default-secondary bg-surface-default-fg-secondary border border-memBorder-primary rounded-md">
|
||||
+{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Portal-rendered popover */}
|
||||
{typeof window !== "undefined" &&
|
||||
popoverContent &&
|
||||
createPortal(popoverContent, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon as Check } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-memBorder-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current pt-[0.5px]")}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className="flex items-center border-b border-accent px-3"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<MagnifyingGlassIcon className="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DeleteConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
itemName: string;
|
||||
confirmButtonText?: string;
|
||||
}
|
||||
|
||||
const DeleteConfirmationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
itemName,
|
||||
confirmButtonText = "Delete",
|
||||
}: DeleteConfirmationModalProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
|
||||
const handleClose = () => {
|
||||
setConfirmationText("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
setConfirmationText("");
|
||||
};
|
||||
|
||||
const isDeleteEnabled = confirmationText === itemName;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mb-4">{description}</DialogDescription>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[#565553]">
|
||||
Please type <span className="font-bold">{itemName}</span> to
|
||||
confirm.
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter name to confirm"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button onClick={handleClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="destructive"
|
||||
disabled={!isDeleteEnabled}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteConfirmationModal;
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[hsla(0,0%,0%,0.6)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseIcon?: boolean; // New prop
|
||||
}
|
||||
>(({ className, children, showCloseIcon = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-memBorder-primary bg-surface-default-primary p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseIcon && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-surface-default-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border-memBorder border border-solid bg-card p-1 text-card-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border-memBorder-secondary border border-solid bg-card p-1 text-card-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="size-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InputProps } from "@/types/ui-components";
|
||||
import { inputVariants } from "@/constants/ui-components";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(inputVariants[variant], className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input, inputVariants };
|
||||
export type { InputProps };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LinearProgress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
LinearProgress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { LinearProgress };
|
||||
@@ -0,0 +1,149 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DotsHorizontalIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn(
|
||||
"mx-auto cursor-pointer flex w-full justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "default",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
"min-w-9",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
isDisabled,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { isDisabled?: boolean }) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn(
|
||||
"gap-1 pl-2.5 cursor-pointer",
|
||||
isDisabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
onClick?.(e);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
isDisabled,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { isDisabled?: boolean }) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn(
|
||||
"gap-1 pr-2.5 cursor-pointer",
|
||||
isDisabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
onClick?.(e);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-secondary bg-card p-2 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CustomProgressProps extends React.ComponentPropsWithoutRef<
|
||||
typeof ProgressPrimitive.Root
|
||||
> {
|
||||
indicatorColor: string;
|
||||
}
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
CustomProgressProps
|
||||
>(({ className, value, indicatorColor, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={`size-full flex-1 transition-all ${indicatorColor}`}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon } from "@radix-ui/react-icons";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-memBorder-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<CheckIcon className="size-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<
|
||||
typeof ScrollAreaPrimitive.Root
|
||||
> {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, viewportRef, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className="size-full rounded-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-1.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 max-w-1 rounded-full bg-accent" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { selectTriggerVariants } from "@/constants/ui-components";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
variant?: keyof typeof selectTriggerVariants;
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(selectTriggerVariants[variant], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none flex shrink-0 items-center justify-center",
|
||||
variant === "dropdown"
|
||||
? "absolute right-3 text-onSurface-default-primary"
|
||||
: "absolute right-1 flex flex-col",
|
||||
)}
|
||||
>
|
||||
{variant === "dropdown" ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] max-h-[200px] overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
"dark:border-memBorder-secondary",
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{className?.includes("scrollbars") && <SelectScrollUpButton />}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] bg-card",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
{className?.includes("scrollbars") && <SelectScrollDownButton />}
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
checkIcon?: React.ReactNode;
|
||||
checkPosition?: "left" | "right";
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, children, checkIcon, checkPosition = "left", ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
checkPosition === "left" ? "pl-8 pr-2" : "pl-2 pr-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute flex h-3.5 w-3.5 items-center justify-center",
|
||||
checkPosition === "left" ? "left-2" : "right-2",
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
{checkIcon ? checkIcon : <Check className="size-4" />}
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
),
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-surface-default-primary p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const sidebarVariants = cva(
|
||||
"relative flex flex-col gap-0 border-r border-memBorder-primary",
|
||||
{
|
||||
variants: {
|
||||
collapsible: {
|
||||
icon: "w-[90px] transition-[width] duration-300 ease-in-out",
|
||||
default: "w-72 transition-[width] duration-300 ease-in-out",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
collapsible: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SidebarProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof sidebarVariants> {}
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
|
||||
({ className, collapsible, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(sidebarVariants({ collapsible }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
));
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-1 flex-col gap-0 overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("mt-auto", className)} {...props} />
|
||||
));
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1", className)} {...props} />
|
||||
));
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 px-[6px] py-1 text-[10px] font-medium uppercase leading-[140%] text-onSurface-default-tertiary font-dm-mono",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1", className)} {...props} />
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const menuButtonVariants = cva(
|
||||
"group flex w-full items-center gap-1.5 rounded p-1.5 font-fustat text-xs font-semibold leading-[140%] transition-colors",
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: "bg-surface-default-tertiary text-onSurface-default-primary [&_svg]:text-onSurface-default-primary",
|
||||
false:
|
||||
"bg-transparent text-onSurface-default-secondary [&_svg]:text-onSurface-default-tertiary hover:bg-surface-default-primary-hover [&_svg]:group-hover:text-onSurface-default-secondary",
|
||||
},
|
||||
collapsed: {
|
||||
true: "!w-8 !h-8 !p-2 items-center justify-center",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
collapsed: true,
|
||||
active: false,
|
||||
className:
|
||||
"bg-surface-default-secondary hover:bg-surface-default-primary-hover [&_svg]:text-onSurface-default-tertiary [&_svg]:group-hover:text-onSurface-default-secondary",
|
||||
},
|
||||
{
|
||||
collapsed: true,
|
||||
active: true,
|
||||
className:
|
||||
"bg-surface-default-tertiary [&_svg]:text-onSurface-default-primary",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
active: false,
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SidebarMenuButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof menuButtonVariants> {
|
||||
asChild?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
SidebarMenuButtonProps
|
||||
>(
|
||||
(
|
||||
{ className, active, collapsed, asChild = false, tooltip, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(menuButtonVariants({ active, collapsed }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, showOnHover, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground",
|
||||
showOnHover && "invisible group-hover:visible",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-1 py-1.5 pl-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
SidebarMenuButtonProps
|
||||
>(({ className, active, collapsed, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 rounded p-1.5 font-fustat text-xs font-semibold leading-[140%] transition-colors",
|
||||
active
|
||||
? "bg-surface-default-tertiary text-onSurface-default-primary [&_svg]:text-onSurface-default-primary"
|
||||
: "bg-transparent text-onSurface-default-secondary [&_svg]:text-onSurface-default-tertiary hover:bg-surface-default-primary-hover hover:[&_svg]:text-onSurface-default-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-border opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarMenuSubButton,
|
||||
SidebarRail,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block size-4 rounded-full border border-memBorder-primary/50 bg-surface-default-primary shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
position="bottom-right"
|
||||
style={{ zIndex: 10000 }}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-surface-default-primary group-[.toaster]:text-foreground group-[.toaster]:border-memBorder-primary group-[.toaster]:shadow-lg group-[.toaster]:rounded-md group-[.toaster]:pointer-events-auto",
|
||||
title: "group-[.toast]:text-sm group-[.toast]:font-semibold",
|
||||
description:
|
||||
"group-[.toast]:text-muted-foreground group-[.toast]:text-sm",
|
||||
icon: "hidden",
|
||||
success:
|
||||
"group-[.toaster]:border-l-4 group-[.toaster]:border-l-[var(--surface-positive-primary)]",
|
||||
error:
|
||||
"group-[.toaster]:border-l-4 group-[.toaster]:border-l-[var(--surface-danger-primary)]",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-surface-default-primary shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b dark:border-memBorder-secondary transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const renderLoader = (
|
||||
condition: boolean,
|
||||
className: string = "",
|
||||
showLoader: boolean = true,
|
||||
) => {
|
||||
if (condition) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center size-full absolute opacity-80 bg-white dark:bg-black min-h-full ${className}`}
|
||||
>
|
||||
{showLoader && (
|
||||
<Loader2 className="animate-spin size-7 text-gray-900 dark:text-gray-100" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface-default-primary data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { fieldLabelClassName } from "@/constants/ui-components";
|
||||
import { TextareaProps } from "@/types/ui-components";
|
||||
|
||||
/**
|
||||
* Same style as default input (textField): rounded-lg, border-memBorder-primary,
|
||||
* bg-surface-default-primary, font-fustat, text-onSurface-default-primary. Height 129px.
|
||||
*/
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
containerClassName,
|
||||
textareaClassName,
|
||||
labelClassName,
|
||||
id: idProp,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const generatedId = React.useId();
|
||||
const id = idProp ?? generatedId;
|
||||
|
||||
const textarea = (
|
||||
<textarea
|
||||
id={id}
|
||||
className={cn(
|
||||
"flex h-[129px] w-full min-w-0 rounded-lg border border-memBorder-primary bg-surface-default-primary px-3 py-2.5 font-fustat text-sm text-onSurface-default-primary placeholder:text-onSurface-default-tertiary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none",
|
||||
textareaClassName,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", containerClassName)}>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(fieldLabelClassName, labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{textarea}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return textarea;
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SunIcon className="size-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-surface-default-primary text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-memBorder-secondary/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="size-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = ({
|
||||
children,
|
||||
delayDuration = 200,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) => (
|
||||
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props}>
|
||||
{children}
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 10, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md px-2 py-1 text-[10px] font-semibold data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"bg-primary text-primary-foreground dark:bg-surface-default-tertiary dark:text-onSurface-default-primary border border-memNeutral-700 dark:border-memBorder-secondary",
|
||||
"font-fustat [&_p]:font-fustat [&_span]:font-fustat",
|
||||
"[&_p]:text-[10px] [&_p]:font-semibold [&_span]:text-[10px] [&_span]:font-semibold",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TypographyVariant =
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "body-lg"
|
||||
| "body"
|
||||
| "body-sm"
|
||||
| "body-xs"
|
||||
| "caption"
|
||||
| "caption-sm"
|
||||
| "overline";
|
||||
|
||||
type TypographyElement = "h1" | "h2" | "h3" | "h4" | "h5" | "p" | "span";
|
||||
|
||||
/**
|
||||
* Typography system – use typo-* classes or these components.
|
||||
*
|
||||
* Tokens:
|
||||
* - h1–h5, body-lg, body, body-sm, body-xs: Fustat
|
||||
* - caption, caption-sm, overline: DM Mono
|
||||
*/
|
||||
const typographyVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
h1: "typo-h1",
|
||||
h2: "typo-h2",
|
||||
h3: "typo-h3",
|
||||
h4: "typo-h4",
|
||||
h5: "typo-h5",
|
||||
"body-lg": "typo-body-lg",
|
||||
body: "typo-body",
|
||||
"body-sm": "typo-body-sm",
|
||||
"body-xs": "typo-body-xs",
|
||||
caption: "typo-caption",
|
||||
"caption-sm": "typo-caption-sm",
|
||||
overline: "typo-overline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "body",
|
||||
},
|
||||
});
|
||||
|
||||
const variantToElement: Record<TypographyVariant, TypographyElement> = {
|
||||
h1: "h1",
|
||||
h2: "h2",
|
||||
h3: "h3",
|
||||
h4: "h4",
|
||||
h5: "h5",
|
||||
"body-lg": "p",
|
||||
body: "p",
|
||||
"body-sm": "p",
|
||||
"body-xs": "span",
|
||||
caption: "span",
|
||||
"caption-sm": "span",
|
||||
overline: "span",
|
||||
};
|
||||
|
||||
export interface TypographyProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
VariantProps<typeof typographyVariants> {
|
||||
as?: TypographyElement;
|
||||
}
|
||||
|
||||
const Typography = React.forwardRef<HTMLElement, TypographyProps>(
|
||||
({ className, variant = "body", as, ...props }, ref) => {
|
||||
const Comp = as ?? (variant ? variantToElement[variant] : "p");
|
||||
return (
|
||||
<Comp
|
||||
className={cn(typographyVariants({ variant }), className)}
|
||||
ref={ref as any}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Typography.displayName = "Typography";
|
||||
|
||||
export { Typography, typographyVariants };
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
// Shadcn recommends Sonner for toasts. This module wraps Sonner so existing
|
||||
// toast({ title, description, variant }) calls work and render via <Toaster /> from @/components/ui/sonner.
|
||||
import * as React from "react";
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
|
||||
export type ToastVariant = "default" | "destructive" | "success";
|
||||
|
||||
export type ToastOptions = {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
variant?: ToastVariant;
|
||||
};
|
||||
|
||||
function toast(options: ToastOptions) {
|
||||
const { title, description, variant = "default" } = options;
|
||||
const message =
|
||||
title != null
|
||||
? String(title)
|
||||
: description != null
|
||||
? String(description)
|
||||
: "Notification";
|
||||
const opts =
|
||||
title != null && description != null
|
||||
? { description: String(description) }
|
||||
: {};
|
||||
|
||||
if (variant === "destructive") {
|
||||
sonnerToast.error(message, opts);
|
||||
} else if (variant === "success") {
|
||||
sonnerToast.success(message, opts);
|
||||
} else {
|
||||
sonnerToast(message, opts);
|
||||
}
|
||||
|
||||
return {
|
||||
id: "",
|
||||
dismiss: () => {},
|
||||
update: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
toasts: [],
|
||||
toast,
|
||||
dismiss: () => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export { sonnerToast as toastSonner };
|
||||
export { useToast, toast };
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ui-components";
|
||||
@@ -0,0 +1,18 @@
|
||||
export const fieldLabelClassName =
|
||||
"font-fustat font-semibold text-xs leading-4 tracking-normal text-onSurface-default-tertiary";
|
||||
|
||||
export const inputVariants = {
|
||||
default:
|
||||
"flex h-9 w-full rounded-md border border-memBorder-primary bg-transparent py-2.5 px-3 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
textField:
|
||||
"flex h-[38px] w-full min-w-0 rounded-lg border border-memBorder-primary bg-surface-default-primary py-2.5 px-3 font-fustat text-sm text-onSurface-default-primary placeholder:text-onSurface-default-tertiary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium peer",
|
||||
nestedInput:
|
||||
"flex h-[38px] w-full min-w-0 rounded-lg border-0 bg-transparent py-2.5 px-0 font-fustat text-sm text-onSurface-default-primary placeholder:text-onSurface-default-tertiary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium peer",
|
||||
} as const;
|
||||
|
||||
export const selectTriggerVariants = {
|
||||
default:
|
||||
"flex h-10 w-full items-center relative justify-between rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
dropdown:
|
||||
"relative flex h-[38px] w-full min-w-0 items-center justify-between rounded-lg border border-memBorder-primary bg-surface-default-primary py-2.5 pl-3 pr-10 font-fustat text-sm text-onSurface-default-primary placeholder:text-onSurface-default-tertiary focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
} as const;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
|
||||
interface UseApiQueryOptions<T> {
|
||||
enabled?: boolean;
|
||||
errorToast?: string;
|
||||
initialData?: T;
|
||||
}
|
||||
|
||||
interface UseApiQueryResult<T> {
|
||||
data: T | undefined;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApiQuery<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
options: UseApiQueryOptions<T> = {},
|
||||
): UseApiQueryResult<T> {
|
||||
const { enabled = true, errorToast, initialData } = options;
|
||||
|
||||
const [data, setData] = useState<T | undefined>(initialData);
|
||||
const [isLoading, setIsLoading] = useState(enabled);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setData(await fetcherRef.current());
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, errorToast || "Request failed");
|
||||
setError(message);
|
||||
if (errorToast) {
|
||||
toast({
|
||||
title: errorToast,
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [errorToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) void run();
|
||||
}, [enabled, run]);
|
||||
|
||||
return { data, isLoading, error, refetch: run };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "@/lib/auth";
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function useDebounce<T>(value: T, delay = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounce;
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, setAccessToken } from "@/utils/api";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (name: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAdmin: false,
|
||||
login: async () => {},
|
||||
register: async () => {},
|
||||
logout: async () => {},
|
||||
refreshUser: async () => {},
|
||||
});
|
||||
|
||||
async function storeRefreshToken(refreshToken: string) {
|
||||
await fetch("/api/auth/refresh", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
}
|
||||
|
||||
async function clearRefreshToken() {
|
||||
await fetch("/api/auth/refresh", { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<boolean> {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
setAccessToken(data.access_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
const res = await api.get<AuthUser>(AUTH_ENDPOINTS.ME);
|
||||
setUser(res.data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const ok = await refreshSession();
|
||||
if (ok && active) await loadUser();
|
||||
} catch {
|
||||
if (active) setUser(null);
|
||||
} finally {
|
||||
if (active) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [loadUser]);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const res = await api.post(AUTH_ENDPOINTS.LOGIN, { email, password });
|
||||
setAccessToken(res.data.access_token);
|
||||
await storeRefreshToken(res.data.refresh_token);
|
||||
await loadUser();
|
||||
},
|
||||
[loadUser],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (name: string, email: string, password: string) => {
|
||||
const res = await api.post(AUTH_ENDPOINTS.REGISTER, {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
setAccessToken(res.data.access_token);
|
||||
await storeRefreshToken(res.data.refresh_token);
|
||||
await loadUser();
|
||||
},
|
||||
[loadUser],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await clearRefreshToken();
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
if (typeof window !== "undefined") window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === "admin",
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser: loadUser,
|
||||
}),
|
||||
[user, isLoading, login, register, logout, loadUser],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import axios from "axios";
|
||||
|
||||
export function getErrorMessage(
|
||||
err: unknown,
|
||||
fallback = "Something went wrong",
|
||||
): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = err.response?.data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail) && detail.length > 0) {
|
||||
const first = detail[0];
|
||||
if (typeof first === "string") return first;
|
||||
if (first?.msg) return String(first.msg);
|
||||
}
|
||||
if (err.message) return err.message;
|
||||
}
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getServerApiUrl(): string {
|
||||
return (
|
||||
process.env.API_INTERNAL_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
"http://localhost:8888"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
return new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const isValidEmail = (value: string) => EMAIL_RE.test(value.trim());
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
|
||||
import { getServerApiUrl } from "@/lib/server-api-url";
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
"/_next",
|
||||
"/api/auth",
|
||||
"/api/health",
|
||||
"/fonts",
|
||||
"/favicon",
|
||||
];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const hasRefreshToken = request.cookies.has("mem0_refresh_token");
|
||||
|
||||
if (pathname === "/" || pathname === "/login" || pathname === "/setup") {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getServerApiUrl()}${AUTH_ENDPOINTS.SETUP_STATUS}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const { needsSetup } = await res.json();
|
||||
|
||||
if (needsSetup && pathname !== "/setup") {
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
if (!needsSetup && pathname === "/setup") {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API unreachable — fall through to default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === "/login" || pathname === "/setup") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
return NextResponse.redirect(
|
||||
new URL(hasRefreshToken ? "/dashboard/requests" : "/login", request.url),
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === "/dashboard" || pathname === "/dashboard/") {
|
||||
return NextResponse.redirect(new URL("/dashboard/requests", request.url));
|
||||
}
|
||||
|
||||
if (!hasRefreshToken) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|fonts|images|icons).*)"],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
enum LayoutActionConstants {
|
||||
TOGGLE_SIDEBAR = "TOGGLE_SIDEBAR",
|
||||
}
|
||||
|
||||
interface LayoutState {
|
||||
isSidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
const initialState: LayoutState = {
|
||||
isSidebarCollapsed: false,
|
||||
};
|
||||
|
||||
export const layoutReducer = (
|
||||
state: LayoutState = initialState,
|
||||
action: { type: string },
|
||||
): LayoutState => {
|
||||
switch (action.type) {
|
||||
case LayoutActionConstants.TOGGLE_SIDEBAR:
|
||||
return { ...state, isSidebarCollapsed: !state.isSidebarCollapsed };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleSidebar = () => ({
|
||||
type: LayoutActionConstants.TOGGLE_SIDEBAR as const,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { combineReducers } from "redux";
|
||||
import { layoutReducer } from "./reducers/layoutReducer";
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
layout: layoutReducer,
|
||||
});
|
||||
|
||||
export default rootReducer;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import rootReducer from "./rootReducer";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: rootReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export default store;
|
||||
@@ -0,0 +1,48 @@
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.slide-in-right {
|
||||
animation: slideInRight 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.slide-in-left {
|
||||
animation: slideInLeft 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-in-out forwards;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0, 0%, 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 0, 0%, 98%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 72.22% 50.59%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5% 64.9%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
--neutral-0: 0, 0%, 100%, 1;
|
||||
--neutral-50: 0, 0%, 98%, 1;
|
||||
--neutral-100: 0, 0%, 96%, 1;
|
||||
--neutral-200: 0, 0%, 90%, 1;
|
||||
--neutral-300: 0, 0%, 83%, 1;
|
||||
--neutral-400: 0, 0%, 64%, 1;
|
||||
--neutral-500: 0, 0%, 45%, 1;
|
||||
--neutral-600: 0, 0%, 32%, 1;
|
||||
--neutral-700: 0, 0%, 25%, 1;
|
||||
--neutral-800: 0, 0%, 15%, 1;
|
||||
--neutral-900: 0, 0%, 9%, 1;
|
||||
--neutral-950: 0, 0%, 4%, 1;
|
||||
--onSurface-primary: 240, 11%, 9%;
|
||||
--onSurface-secondary: 240, 3%, 24%;
|
||||
--onSurface-tertiary: 240, 5%, 65%;
|
||||
--surface-tertiary: 0, 0%, 94%;
|
||||
--surface-secondary: 0, 0%, 98%;
|
||||
--surface-primary: 0, 0%, 100%;
|
||||
|
||||
--border-primary: var(--mem-neutral-200);
|
||||
--elevation-light-2: 0, 0%, 0%, 0.08;
|
||||
|
||||
/* Mem primitives */
|
||||
--mem-neutral-50: #fcfcfc;
|
||||
--mem-neutral-100: #f4f4f4;
|
||||
--mem-neutral-150: #e9e9e9;
|
||||
--mem-neutral-200: #dddddd;
|
||||
--mem-neutral-300: #cccccc;
|
||||
--mem-neutral-400: #b3b3b3;
|
||||
--mem-neutral-450: #999999;
|
||||
--mem-neutral-500: #737373;
|
||||
--mem-neutral-550: #555555;
|
||||
--mem-neutral-600: #383838;
|
||||
--mem-neutral-700: #252525;
|
||||
--mem-neutral-800: #181818;
|
||||
--mem-neutral-850: #0f0f0f;
|
||||
--mem-neutral-900: #0a0a0a;
|
||||
--mem-neutral-950: #050505;
|
||||
--mem-purple-50: #f7f2ff;
|
||||
--mem-purple-100: #efe4ff;
|
||||
--mem-purple-150: #e5d4ff;
|
||||
--mem-purple-200: #dcc3ff;
|
||||
--mem-purple-300: #cbb2ff;
|
||||
--mem-purple-400: #b89cff;
|
||||
--mem-purple-500: #a487f5;
|
||||
--mem-purple-600: #8f74e0;
|
||||
--mem-purple-700: #745cc4;
|
||||
--mem-purple-800: #5c49a3;
|
||||
--mem-purple-900: #47397f;
|
||||
--mem-purple-950: #31275a;
|
||||
--mem-gold-50: #fefaf0;
|
||||
--mem-gold-100: #fbf3df;
|
||||
--mem-gold-150: #f8ebc9;
|
||||
--mem-gold-200: #f4dfae;
|
||||
--mem-gold-300: #f2d288;
|
||||
--mem-gold-400: #f1c96c;
|
||||
--mem-gold-500: #e0b85c;
|
||||
--mem-gold-600: #be9f55;
|
||||
--mem-gold-700: #a68c51;
|
||||
--mem-gold-800: #79683e;
|
||||
--mem-gold-900: #54482c;
|
||||
--mem-gold-950: #2f2819;
|
||||
--mem-blue-50: #f3f5fd;
|
||||
--mem-blue-100: #e5eafb;
|
||||
--mem-blue-200: #d0d9f8;
|
||||
--mem-blue-300: #b1c0f0;
|
||||
--mem-blue-400: #889de1;
|
||||
--mem-blue-500: #5d79d6;
|
||||
--mem-blue-600: #3a5bc8;
|
||||
--mem-blue-700: #2b4496;
|
||||
--mem-blue-800: #152a6c;
|
||||
--mem-blue-900: #0e1d4e;
|
||||
--mem-blue-950: #0b1432;
|
||||
--mem-green-50: #f5fbf7;
|
||||
--mem-green-100: #eaf6ee;
|
||||
--mem-green-200: #d9efe0;
|
||||
--mem-green-300: #bee3ca;
|
||||
--mem-green-400: #97d2af;
|
||||
--mem-green-500: #71c291;
|
||||
--mem-green-600: #57ab73;
|
||||
--mem-green-700: #398457;
|
||||
--mem-green-800: #2b6442;
|
||||
--mem-green-900: #1c4028;
|
||||
--mem-green-950: #112718;
|
||||
--mem-red-50: #fdf3f3;
|
||||
--mem-red-100: #fbe5e5;
|
||||
--mem-red-200: #f8d0d0;
|
||||
--mem-red-300: #f0b1b1;
|
||||
--mem-red-400: #ec7d7d;
|
||||
--mem-red-500: #e64d4d;
|
||||
--mem-red-600: #d32f2f;
|
||||
--mem-red-700: #ad2525;
|
||||
--mem-red-800: #7e1b1b;
|
||||
--mem-red-900: #541212;
|
||||
--mem-red-950: #320b0b;
|
||||
|
||||
/* Mem tokens - light mode */
|
||||
--mem-border-secondary: var(--mem-neutral-300);
|
||||
--mem-border-primary: var(--mem-neutral-200);
|
||||
|
||||
/* Semantic surface tokens - light mode */
|
||||
--surface-default-primary: var(--mem-neutral-50);
|
||||
--surface-default-primary-hover: var(--mem-neutral-100);
|
||||
--surface-default-secondary: var(--mem-neutral-100);
|
||||
--surface-default-secondary-hover: var(--mem-neutral-150);
|
||||
--surface-default-fg-secondary: var(--mem-neutral-100);
|
||||
--surface-default-fg-secondary-hover: var(--mem-neutral-150);
|
||||
--surface-default-tertiary: var(--mem-neutral-150);
|
||||
--surface-default-tertiary-hover: var(--mem-neutral-200);
|
||||
--surface-default-brand: var(--mem-purple-50);
|
||||
--surface-default-brand-hover: var(--mem-purple-100);
|
||||
--surface-event-add: var(--mem-green-200);
|
||||
--surface-event-search: var(--mem-blue-100);
|
||||
--surface-event-delete: var(--mem-red-100);
|
||||
--surface-event-get: var(--mem-purple-100);
|
||||
--surface-positive-primary: var(--mem-green-100);
|
||||
--surface-positive-primary-hover: var(--mem-green-200);
|
||||
--surface-danger-primary: var(--mem-red-100);
|
||||
--surface-danger-primary-hover: var(--mem-red-200);
|
||||
--surface-info-primary: var(--mem-blue-50);
|
||||
--surface-info-primary-hover: var(--mem-blue-100);
|
||||
|
||||
/* Semantic on-surface tokens - light mode */
|
||||
--on-surface-default-primary: var(--mem-neutral-800);
|
||||
--on-surface-default-secondary: var(--mem-neutral-600);
|
||||
--on-surface-default-tertiary: var(--mem-neutral-450);
|
||||
--on-surface-default-brand: var(--mem-purple-400);
|
||||
--on-surface-default-brand-hover: var(--mem-purple-700);
|
||||
--on-surface-event-add: var(--mem-green-700);
|
||||
--on-surface-event-search: var(--mem-blue-600);
|
||||
--on-surface-event-delete: var(--mem-red-600);
|
||||
--on-surface-event-get: var(--mem-purple-700);
|
||||
--on-surface-positive-primary: var(--mem-green-700);
|
||||
--on-surface-positive-secondary: var(--mem-green-600);
|
||||
--on-surface-danger-primary: var(--mem-red-600);
|
||||
--on-surface-danger-secondary: var(--mem-red-400);
|
||||
--on-surface-info-primary: var(--mem-blue-600);
|
||||
--on-surface-info-secondary: var(--mem-blue-400);
|
||||
|
||||
/* Typography: size, line-height, letter-spacing per token */
|
||||
--typo-h1-size: 32px;
|
||||
--typo-h1-line: 36px;
|
||||
--typo-h1-tracking: -0.01em;
|
||||
--typo-h2-size: 28px;
|
||||
--typo-h2-line: 32px;
|
||||
--typo-h2-tracking: -0.01em;
|
||||
--typo-h3-size: 24px;
|
||||
--typo-h3-line: 28px;
|
||||
--typo-h3-tracking: -0.01em;
|
||||
--typo-h4-size: 20px;
|
||||
--typo-h4-line: 26px;
|
||||
--typo-h4-tracking: 0;
|
||||
--typo-h5-size: 18px;
|
||||
--typo-h5-line: 22px;
|
||||
--typo-h5-tracking: 0;
|
||||
--typo-body-lg-size: 18px;
|
||||
--typo-body-lg-line: 22px;
|
||||
--typo-body-lg-tracking: 0;
|
||||
--typo-body-size: 16px;
|
||||
--typo-body-line: 22px;
|
||||
--typo-body-tracking: 0;
|
||||
--typo-body-sm-size: 14px;
|
||||
--typo-body-sm-line: 18px;
|
||||
--typo-body-sm-tracking: 0;
|
||||
--typo-body-xs-size: 12px;
|
||||
--typo-body-xs-line: 16px;
|
||||
--typo-body-xs-tracking: 0;
|
||||
--typo-caption-size: 12px;
|
||||
--typo-caption-line: 16px;
|
||||
--typo-caption-tracking: 0.05em;
|
||||
--typo-caption-sm-size: 10px;
|
||||
--typo-caption-sm-line: 12px;
|
||||
--typo-caption-sm-tracking: 0.05em;
|
||||
--typo-overline-size: 14px;
|
||||
--typo-overline-line: 16px;
|
||||
--typo-overline-tracking: 0.05em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 11%;
|
||||
--foreground: 0 0% 88%;
|
||||
--card: 0 0% 11%;
|
||||
--card-foreground: 0 0% 88%;
|
||||
--popover: 0 0% 11%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 18.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240, 10%, 4%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--neutral-0: 0, 0%, 0%, 1;
|
||||
--neutral-50: 0, 0%, 4%, 1;
|
||||
--neutral-100: 0, 0%, 9%, 1;
|
||||
--neutral-200: 0, 0%, 15%, 1;
|
||||
--neutral-300: 0, 0%, 22%, 1;
|
||||
--neutral-400: 0, 0%, 32%, 1;
|
||||
--neutral-500: 0, 0%, 54%, 1;
|
||||
--neutral-600: 0, 0%, 64%, 1;
|
||||
--neutral-700: 0, 0%, 83%, 1;
|
||||
--neutral-800: 0, 0%, 90%, 1;
|
||||
--neutral-900: 0, 0%, 96%, 1;
|
||||
--neutral-950: 0, 0%, 98%, 1;
|
||||
--onSurface-primary: 0, 0%, 100%;
|
||||
--onSurface-secondary: 40, 4%, 85%;
|
||||
--onSurface-tertiary: 240, 4%, 46%;
|
||||
--surface-tertiary: 240, 4%, 16%;
|
||||
--surface-secondary: 240, 10%, 4%;
|
||||
--surface-primary: 240, 11%, 9%;
|
||||
--border-primary: var(--mem-neutral-800);
|
||||
--elevation-light-2: 0, 0%, 0%, 0.08;
|
||||
|
||||
/* Mem primitives (same in dark mode) */
|
||||
--mem-neutral-50: #fcfcfc;
|
||||
--mem-neutral-100: #f4f4f4;
|
||||
--mem-neutral-150: #e9e9e9;
|
||||
--mem-neutral-200: #dddddd;
|
||||
--mem-neutral-300: #cccccc;
|
||||
--mem-neutral-400: #b3b3b3;
|
||||
--mem-neutral-450: #999999;
|
||||
--mem-neutral-500: #737373;
|
||||
--mem-neutral-550: #555555;
|
||||
--mem-neutral-600: #383838;
|
||||
--mem-neutral-700: #252525;
|
||||
--mem-neutral-800: #181818;
|
||||
--mem-neutral-850: #0f0f0f;
|
||||
--mem-neutral-900: #0a0a0a;
|
||||
--mem-neutral-950: #050505;
|
||||
--mem-purple-50: #f7f2ff;
|
||||
--mem-purple-100: #efe4ff;
|
||||
--mem-purple-150: #e5d4ff;
|
||||
--mem-purple-200: #dcc3ff;
|
||||
--mem-purple-300: #cbb2ff;
|
||||
--mem-purple-400: #b89cff;
|
||||
--mem-purple-500: #a487f5;
|
||||
--mem-purple-600: #8f74e0;
|
||||
--mem-purple-700: #745cc4;
|
||||
--mem-purple-800: #5c49a3;
|
||||
--mem-purple-900: #47397f;
|
||||
--mem-purple-950: #31275a;
|
||||
--mem-gold-50: #fefaf0;
|
||||
--mem-gold-100: #fbf3df;
|
||||
--mem-gold-150: #f8ebc9;
|
||||
--mem-gold-200: #f4dfae;
|
||||
--mem-gold-300: #f2d288;
|
||||
--mem-gold-400: #f1c96c;
|
||||
--mem-gold-500: #e0b85c;
|
||||
--mem-gold-600: #be9f55;
|
||||
--mem-gold-700: #a68c51;
|
||||
--mem-gold-800: #79683e;
|
||||
--mem-gold-900: #54482c;
|
||||
--mem-gold-950: #2f2819;
|
||||
--mem-blue-50: #f3f5fd;
|
||||
--mem-blue-100: #e5eafb;
|
||||
--mem-blue-200: #d0d9f8;
|
||||
--mem-blue-300: #b1c0f0;
|
||||
--mem-blue-400: #889de1;
|
||||
--mem-blue-500: #5d79d6;
|
||||
--mem-blue-600: #3a5bc8;
|
||||
--mem-blue-700: #2b4496;
|
||||
--mem-blue-800: #152a6c;
|
||||
--mem-blue-900: #0e1d4e;
|
||||
--mem-blue-950: #0b1432;
|
||||
--mem-green-50: #f5fbf7;
|
||||
--mem-green-100: #eaf6ee;
|
||||
--mem-green-200: #d9efe0;
|
||||
--mem-green-300: #bee3ca;
|
||||
--mem-green-400: #97d2af;
|
||||
--mem-green-500: #71c291;
|
||||
--mem-green-600: #57ab73;
|
||||
--mem-green-700: #398457;
|
||||
--mem-green-800: #2b6442;
|
||||
--mem-green-900: #1c4028;
|
||||
--mem-green-950: #112718;
|
||||
--mem-red-50: #fdf3f3;
|
||||
--mem-red-100: #fbe5e5;
|
||||
--mem-red-200: #f8d0d0;
|
||||
--mem-red-300: #f0b1b1;
|
||||
--mem-red-400: #ec7d7d;
|
||||
--mem-red-500: #e64d4d;
|
||||
--mem-red-600: #d32f2f;
|
||||
--mem-red-700: #ad2525;
|
||||
--mem-red-800: #7e1b1b;
|
||||
--mem-red-900: #541212;
|
||||
--mem-red-950: #320b0b;
|
||||
|
||||
/* Mem tokens - dark mode */
|
||||
--mem-border-primary: var(--mem-neutral-700);
|
||||
--mem-border-secondary: var(--mem-neutral-600);
|
||||
|
||||
/* Semantic surface tokens - dark mode */
|
||||
--surface-default-primary: var(--mem-neutral-850);
|
||||
--surface-default-primary-hover: var(--mem-neutral-800);
|
||||
--surface-default-secondary: var(--mem-neutral-950);
|
||||
--surface-default-secondary-hover: var(--mem-neutral-900);
|
||||
--surface-default-fg-secondary: var(--mem-neutral-800);
|
||||
--surface-default-fg-secondary-hover: var(--mem-neutral-700);
|
||||
--surface-default-tertiary: var(--mem-neutral-700);
|
||||
--surface-default-tertiary-hover: var(--mem-neutral-600);
|
||||
--surface-default-brand: var(--mem-purple-950);
|
||||
--surface-default-brand-hover: var(--mem-purple-900);
|
||||
--surface-event-add: var(--mem-green-950);
|
||||
--surface-event-search: var(--mem-blue-900);
|
||||
--surface-event-delete: var(--mem-red-900);
|
||||
--surface-event-get: var(--mem-purple-950);
|
||||
--surface-positive-primary: var(--mem-green-950);
|
||||
--surface-positive-primary-hover: var(--mem-green-900);
|
||||
--surface-danger-primary: var(--mem-red-950);
|
||||
--surface-danger-primary-hover: var(--mem-red-900);
|
||||
--surface-info-primary: var(--mem-blue-950);
|
||||
--surface-info-primary-hover: var(--mem-blue-900);
|
||||
|
||||
/* Semantic on-surface tokens - dark mode */
|
||||
--on-surface-default-primary: var(--mem-neutral-50);
|
||||
--on-surface-default-secondary: var(--mem-neutral-400);
|
||||
--on-surface-default-tertiary: var(--mem-neutral-500);
|
||||
--on-surface-default-brand: var(--mem-purple-400);
|
||||
--on-surface-default-brand-hover: var(--mem-purple-700);
|
||||
--on-surface-event-add: var(--mem-green-600);
|
||||
--on-surface-event-search: var(--mem-blue-400);
|
||||
--on-surface-event-delete: var(--mem-red-400);
|
||||
--on-surface-event-get: var(--mem-purple-500);
|
||||
--on-surface-positive-primary: var(--mem-green-600);
|
||||
--on-surface-positive-secondary: var(--mem-green-700);
|
||||
--on-surface-danger-primary: var(--mem-red-400);
|
||||
--on-surface-danger-secondary: var(--mem-red-600);
|
||||
--on-surface-info-primary: var(--mem-blue-400);
|
||||
--on-surface-info-secondary: var(--mem-blue-600);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-memBorder-primary;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface-default-secondary text-foreground;
|
||||
font-feature-settings: "ss03" 1;
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography system: uses --typo-*-size, --typo-*-line, --typo-*-tracking from :root.
|
||||
font/weight via font-fustat, font-dm-mono (var(--font-*, ui-sans-serif) from Tailwind). */
|
||||
@layer components {
|
||||
.typo-h1 {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-h1-size);
|
||||
line-height: var(--typo-h1-line);
|
||||
letter-spacing: var(--typo-h1-tracking);
|
||||
}
|
||||
|
||||
.typo-h2 {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-h2-size);
|
||||
line-height: var(--typo-h2-line);
|
||||
letter-spacing: var(--typo-h2-tracking);
|
||||
}
|
||||
|
||||
.typo-h3 {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-h3-size);
|
||||
line-height: var(--typo-h3-line);
|
||||
letter-spacing: var(--typo-h3-tracking);
|
||||
}
|
||||
|
||||
.typo-h4 {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-h4-size);
|
||||
line-height: var(--typo-h4-line);
|
||||
letter-spacing: var(--typo-h4-tracking);
|
||||
}
|
||||
|
||||
.typo-h5 {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-h5-size);
|
||||
line-height: var(--typo-h5-line);
|
||||
letter-spacing: var(--typo-h5-tracking);
|
||||
}
|
||||
|
||||
.typo-body-lg {
|
||||
@apply font-fustat font-semibold;
|
||||
font-size: var(--typo-body-lg-size);
|
||||
line-height: var(--typo-body-lg-line);
|
||||
letter-spacing: var(--typo-body-lg-tracking);
|
||||
}
|
||||
|
||||
.typo-body {
|
||||
@apply font-fustat font-semibold;
|
||||
font-size: var(--typo-body-size);
|
||||
line-height: var(--typo-body-line);
|
||||
letter-spacing: var(--typo-body-tracking);
|
||||
}
|
||||
|
||||
.typo-body-sm {
|
||||
@apply font-fustat font-semibold;
|
||||
font-size: var(--typo-body-sm-size);
|
||||
line-height: var(--typo-body-sm-line);
|
||||
letter-spacing: var(--typo-body-sm-tracking);
|
||||
}
|
||||
|
||||
.typo-body-xs {
|
||||
@apply font-fustat font-semibold;
|
||||
font-size: var(--typo-body-xs-size);
|
||||
line-height: var(--typo-body-xs-line);
|
||||
letter-spacing: var(--typo-body-xs-tracking);
|
||||
}
|
||||
|
||||
.typo-body-lg-bold {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-body-lg-size);
|
||||
line-height: var(--typo-body-lg-line);
|
||||
letter-spacing: var(--typo-body-lg-tracking);
|
||||
}
|
||||
|
||||
.typo-body-bold {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-body-size);
|
||||
line-height: var(--typo-body-line);
|
||||
letter-spacing: var(--typo-body-tracking);
|
||||
}
|
||||
|
||||
.typo-body-sm-bold {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-body-sm-size);
|
||||
line-height: var(--typo-body-sm-line);
|
||||
letter-spacing: var(--typo-body-sm-tracking);
|
||||
}
|
||||
|
||||
.typo-body-xs-bold {
|
||||
@apply font-fustat font-bold;
|
||||
font-size: var(--typo-body-xs-size);
|
||||
line-height: var(--typo-body-xs-line);
|
||||
letter-spacing: var(--typo-body-xs-tracking);
|
||||
}
|
||||
|
||||
.typo-caption {
|
||||
@apply font-dm-mono font-medium;
|
||||
font-size: var(--typo-caption-size);
|
||||
line-height: var(--typo-caption-line);
|
||||
letter-spacing: var(--typo-caption-tracking);
|
||||
}
|
||||
|
||||
.typo-caption-sm {
|
||||
@apply font-dm-mono font-medium;
|
||||
font-size: var(--typo-caption-sm-size);
|
||||
line-height: var(--typo-caption-sm-line);
|
||||
letter-spacing: var(--typo-caption-sm-tracking);
|
||||
}
|
||||
|
||||
.typo-overline {
|
||||
@apply font-dm-mono font-medium;
|
||||
font-size: var(--typo-overline-size);
|
||||
line-height: var(--typo-overline-line);
|
||||
letter-spacing: var(--typo-overline-tracking);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.step {
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.step:before {
|
||||
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
|
||||
@apply ml-[-50px] mt-[-4px];
|
||||
content: counter(step);
|
||||
}
|
||||
|
||||
.chunk-container {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
.chunk-container::after {
|
||||
content: "";
|
||||
@apply absolute -inset-4 shadow-xl rounded-xl border;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
animation: blink 1.4s infinite both;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Memory {
|
||||
id: string;
|
||||
memory: string;
|
||||
user_id?: string;
|
||||
agent_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
label: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateResponse {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiRequestLog {
|
||||
id: string;
|
||||
created_at: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
latency_ms: number;
|
||||
auth_type: string;
|
||||
}
|
||||
|
||||
export type EntityType = "user" | "agent" | "run";
|
||||
|
||||
export interface Entity {
|
||||
id: string;
|
||||
type: EntityType;
|
||||
total_memories: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
declare module "imurmurhash" {
|
||||
interface MurmurHash3State {
|
||||
result(): number;
|
||||
hash(value: string): MurmurHash3State;
|
||||
reset(seed?: number): MurmurHash3State;
|
||||
}
|
||||
function MurmurHash3(text?: string, seed?: number): MurmurHash3State;
|
||||
export = MurmurHash3;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
variant?: "default" | "textField" | "nestedInput";
|
||||
}
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
textareaClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const AUTH_ENDPOINTS = {
|
||||
SETUP_STATUS: "/auth/setup-status",
|
||||
REGISTER: "/auth/register",
|
||||
LOGIN: "/auth/login",
|
||||
REFRESH: "/auth/refresh",
|
||||
ME: "/auth/me",
|
||||
CHANGE_PASSWORD: "/auth/change-password",
|
||||
ONBOARDING_COMPLETE: "/auth/onboarding-complete",
|
||||
} as const;
|
||||
|
||||
export const MEMORY_ENDPOINTS = {
|
||||
BASE: "/memories",
|
||||
BY_ID: (memoryId: string) => `/memories/${memoryId}`,
|
||||
HISTORY: (memoryId: string) => `/memories/${memoryId}/history`,
|
||||
CONFIGURE: "/configure",
|
||||
CONFIGURE_PROVIDERS: "/configure/providers",
|
||||
RESET: "/reset",
|
||||
GENERATE_INSTRUCTIONS: "/generate-instructions",
|
||||
} as const;
|
||||
|
||||
export const API_KEY_ENDPOINTS = {
|
||||
BASE: "/api-keys",
|
||||
BY_ID: (keyId: string) => `/api-keys/${keyId}`,
|
||||
} as const;
|
||||
|
||||
export const REQUEST_ENDPOINTS = {
|
||||
BASE: "/requests",
|
||||
} as const;
|
||||
|
||||
export const ENTITY_ENDPOINTS = {
|
||||
BASE: "/entities",
|
||||
BY_ID: (type: string, id: string) =>
|
||||
`/entities/${type}/${encodeURIComponent(id)}`,
|
||||
} as const;
|
||||
@@ -0,0 +1,113 @@
|
||||
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||
|
||||
let cachedToken: string | null = null;
|
||||
const LOGIN_PATH = "/login";
|
||||
|
||||
export const setAccessToken = (token: string | null) => {
|
||||
cachedToken = token;
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
const handleTokenError = () => {
|
||||
cachedToken = null;
|
||||
};
|
||||
|
||||
const redirectToLogin = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = LOGIN_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
const refreshResponse = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await refreshResponse.json();
|
||||
setAccessToken(data.access_token);
|
||||
return data.access_token as string;
|
||||
};
|
||||
|
||||
const createApi = (): AxiosInstance & {
|
||||
postStream: (url: string, data: unknown) => Promise<Response>;
|
||||
} => {
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
if (cachedToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${cachedToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<{ error?: string }>) => {
|
||||
if (error.response?.status === 401) {
|
||||
handleTokenError();
|
||||
|
||||
try {
|
||||
const nextToken = await refreshAccessToken();
|
||||
if (nextToken && error.config) {
|
||||
error.config.headers = error.config.headers ?? {};
|
||||
error.config.headers.Authorization = `Bearer ${nextToken}`;
|
||||
return api.request(error.config);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
handleTokenError();
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
if (error.response?.data?.error) {
|
||||
return Promise.reject(error.response.data.error);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const postStream = async (url: string, data: unknown): Promise<Response> => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: cachedToken ? `Bearer ${cachedToken}` : "",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handleTokenError();
|
||||
redirectToLogin();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Request failed");
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return Object.assign(api, { postStream });
|
||||
};
|
||||
|
||||
export const api = createApi();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user