Self-hosted dashboard and admin auth (#4837)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Gabriel Stein
2026-04-23 19:06:36 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 15feaa8ac4
commit db8ac61713
252 changed files with 20275 additions and 331 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env.local
.env.example
.env
.storybook
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:8888
API_INTERNAL_URL=http://localhost:8888
NEXT_PUBLIC_INSTANCE_NAME=Mem0
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
+51
View File
@@ -0,0 +1,51 @@
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Dependencies
FROM base AS deps
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile --network-timeout 600000; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \
else npm install; \
fi
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Placeholder values replaced at runtime by entrypoint.sh
ENV NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_INSTANCE_NAME=NEXT_PUBLIC_INSTANCE_NAME
RUN npm run build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh
RUN chmod +x /home/nextjs/entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/home/nextjs/entrypoint.sh"]
CMD ["node", "server.js"]
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
cd /app
# Swap NEXT_PUBLIC_* placeholders baked into .next/ at build time with the
# container's real env values. Each placeholder must be declared in the
# Dockerfile as `ENV NAME=NAME` so Next.js inlines the literal name. Currently:
# NEXT_PUBLIC_API_URL, NEXT_PUBLIC_INSTANCE_NAME
# New NEXT_PUBLIC_* vars must be added to the Dockerfile or this loop won't substitute them.
printenv | grep '^NEXT_PUBLIC_' | while IFS='=' read -r key value; do
escaped=$(printf '%s' "$value" | sed -e 's/[\\&|]/\\&/g')
find .next/ -type f -exec sed -i "s|$key|$escaped|g" {} \;
done
echo "Done replacing env variables NEXT_PUBLIC_ with real values"
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
eslint: {
ignoreDuringBuilds: false,
},
typescript: {
ignoreBuildErrors: false,
},
experimental: {
optimizePackageImports: ["@/components", "@/lib", "@/utils"],
},
compress: true,
images: {
formats: ["image/webp", "image/avif"],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
];
},
redirects: async () => {
return [
{
source: "/settings",
destination: "/dashboard/settings",
permanent: false,
},
];
},
};
export default nextConfig;
+83
View File
@@ -0,0 +1,83 @@
{
"name": "mem0-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "prettier --check .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.6.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-table": "^8.17.3",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.12",
"lodash": "^4.17.21",
"lucide-react": "^0.542.0",
"next": "15.2.6",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-copy-to-clipboard": "^5.1.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.52.0",
"react-redux": "^9.1.2",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.15.0",
"redux": "^5.0.1",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.9.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@types/lodash": "^4.17.7",
"@types/node": "22.5.4",
"@types/react": "^19.0.0",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "15.2.6",
"postcss": "^8",
"prettier": "^3.5.2",
"tailwindcss": "^3.3.0",
"typescript": "^5.6.3"
}
}
+7967
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="21" viewBox="0 0 109 21" fill="none">
<g clip-path="url(#clip0_8821_113361)">
<path d="M66.3702 3.90968V17.4541H70.1925V3.90968H66.3702ZM36.2969 3.88672V17.4427H40.1536V6.91701L43.1609 6.92848C44.1481 6.92848 44.8368 7.16953 45.3074 7.67458C45.9157 8.31737 46.1568 9.3619 46.1568 11.2558V17.4427H49.8987V9.95877C49.8987 4.60986 46.4897 3.88672 43.1609 3.88672H36.2969ZM72.5341 3.90968V17.4541H78.7324C82.0382 17.4541 83.1171 16.9032 84.2764 15.675C85.1029 14.8141 85.6309 12.9087 85.6309 10.8311C85.6309 8.92572 85.1832 7.22692 84.3912 6.17091C82.9909 4.27698 80.9477 3.90968 77.8945 3.90968H72.5341ZM76.3219 6.84814H77.9633C80.3508 6.84814 81.8889 7.91562 81.8889 10.6934C81.8889 13.4712 80.3508 14.5501 77.9633 14.5501H76.3219V6.84814ZM60.8606 3.90968L57.6696 14.6419L54.6163 3.90968H50.4841L54.8459 17.4541H60.3555L64.7632 3.90968H60.8606ZM87.4215 17.4541H91.2438V3.90968H87.4215V17.4541ZM98.1423 3.90968L92.8049 17.4427H96.5698L97.4192 15.0437H103.732L104.536 17.4312H108.634L103.25 3.90968H98.1423ZM100.622 6.37752L102.94 12.7136H98.2341L100.622 6.37752Z" fill="#FCFCFC"/>
<path d="M11.6276 6.15924V4.29974C11.8112 4.28826 11.9949 4.27679 12.1785 4.27679C17.2749 4.11609 20.6151 8.66152 20.6151 8.66152C20.6151 8.66152 17.0109 13.6661 13.1427 13.6661C12.6262 13.6661 12.1211 13.5857 11.6391 13.425V7.77769C13.6248 8.01873 14.0266 8.89109 15.2088 10.8768L17.8603 8.65004C17.8603 8.65004 15.9205 6.11332 12.6606 6.11332C12.3163 6.10185 11.9719 6.1248 11.6276 6.15924ZM11.6276 0.00683594V2.7846L12.1785 2.75016C19.2607 2.50912 23.8865 8.55822 23.8865 8.55822C23.8865 8.55822 18.5835 15.0091 13.0624 15.0091C12.5803 15.0091 12.1097 14.9631 11.6391 14.8828V16.6045C12.0293 16.6505 12.4311 16.6849 12.8213 16.6849C17.9636 16.6849 21.6826 14.0564 25.2868 10.9572C25.8837 11.4393 28.3286 12.5986 28.8336 13.1036C25.4131 15.9732 17.4356 18.2804 12.9132 18.2804C12.477 18.2804 12.0638 18.2574 11.6505 18.2115V20.6335H31.1867V0.00683594L11.6276 0.00683594ZM11.6276 13.425V14.8943C6.87554 14.0449 5.55553 9.0977 5.55553 9.0977C5.55553 9.0977 7.83972 6.57246 11.6276 6.15924V7.76621H11.6161C9.63034 7.52516 8.06929 9.38466 8.06929 9.38466C8.06929 9.38466 8.95312 12.5183 11.6276 13.425ZM3.19098 8.89109C3.19098 8.89109 6.00318 4.73592 11.6391 4.29974V2.7846C5.39483 3.28965 0 8.56969 0 8.56969C0 8.56969 3.05324 17.408 11.6276 18.2115V16.6045C5.33744 15.824 3.19098 8.89109 3.19098 8.89109Z" fill="#FCFCFC"/>
</g>
<defs>
<clipPath id="clip0_8821_113361">
<rect width="108.631" height="20.6399" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
@@ -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">
&quot;Mem0 allowed us to unlock true personalized tutoring for
every student, and it took us just a weekend to integrate.&quot;
</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&apos;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"
/>
);
}
+86
View File
@@ -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

+42
View File
@@ -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>
);
}
+760
View File
@@ -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&apos;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,
};
+177
View File
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More