Compare commits

...

25 Commits

Author SHA1 Message Date
Lluis Agusti
1273f36a30 Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-07-09 16:50:53 +04:00
Lluis Agusti
2212edbd15 chore: updates 2025-07-07 12:55:30 +04:00
Lluis Agusti
1b9fa753db chore: fix build 2025-07-07 12:10:22 +04:00
Lluis Agusti
9cbed88dba chore: more cleanup 2025-07-04 20:17:05 +04:00
Lluis Agusti
707f2a7d5d Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-07-04 20:08:28 +04:00
Lluis Agusti
65482a4912 chore: improvements 2025-07-04 20:07:25 +04:00
Lluis Agusti
dc3c5435fa chore: cleanup 2025-07-04 19:47:01 +04:00
Lluis Agusti
46b7b7f248 chore: improvements 2025-07-04 19:21:06 +04:00
Lluis Agusti
6ca5b991df Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-07-04 18:54:40 +04:00
Lluis Agusti
2d26229229 chore: wip 2025-07-03 12:32:53 +04:00
Lluis Agusti
42f51379a8 chore: add colors and update UI 2025-07-02 21:09:25 +04:00
Lluis Agusti
255ee5d684 Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-07-02 17:40:40 +04:00
Lluis Agusti
a81042154b chore: use toasts 2025-07-02 17:32:26 +04:00
Lluis Agusti
18ab6fd298 chore: navbar refinements 2025-07-02 16:37:23 +04:00
Lluis Agusti
8a8d897e3b chore: wip 2025-07-02 15:31:08 +04:00
Lluis Agusti
c8cb6771a4 chore: wip 2025-07-01 20:47:53 +04:00
Lluis Agusti
00a6e326e5 chore: wip 2025-07-01 20:28:16 +04:00
Lluis Agusti
689c985dca chore: more wip 2025-07-01 20:00:24 +04:00
Lluis Agusti
c18099b72b chore: wip 2025-07-01 19:11:18 +04:00
Lluis Agusti
481c5b157d chore: update typography tokens 2025-07-01 18:46:26 +04:00
Lluis Agusti
b780e40db6 Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-07-01 18:26:01 +04:00
Lluis Agusti
33d23a34bd chore: more wip 2025-06-30 19:56:28 +04:00
Lluis Agusti
fc9165e86b Merge 'dev' into 'cursor/update-login-and-signup-pages-931a' 2025-06-30 17:54:01 +04:00
Lluis Agusti
fe375ce1bd chore: wip 2025-06-30 17:22:30 +04:00
Lluis Agusti
6ad6ed9f56 feat(frontend): improve Google SSO UX 2025-06-30 15:34:09 +04:00
63 changed files with 2418 additions and 1248 deletions

View File

@@ -75,6 +75,7 @@
"moment": "2.30.1",
"next": "15.3.5",
"next-themes": "0.4.6",
"nuqs": "2.4.3",
"party-js": "2.2.0",
"react": "18.3.1",
"react-day-picker": "9.8.0",

View File

@@ -155,6 +155,9 @@ importers:
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: 2.4.3
version: 2.4.3(next@15.3.3(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
party-js:
specifier: 2.2.0
version: 2.2.0
@@ -5326,6 +5329,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
@@ -5456,6 +5462,24 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuqs@2.4.3:
resolution: {integrity: sha512-BgtlYpvRwLYiJuWzxt34q2bXu/AIS66sLU1QePIMr2LWkb+XH0vKXdbLSgn9t6p7QKzwI7f38rX3Wl9llTXQ8Q==}
peerDependencies:
'@remix-run/react': '>=2'
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^6 || ^7
react-router-dom: ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
optional: true
next:
optional: true
react-router:
optional: true
react-router-dom:
optional: true
oas-kit-common@1.0.8:
resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==}
@@ -12989,6 +13013,8 @@ snapshots:
minipass@7.1.2: {}
mitt@3.0.1: {}
module-details-from-path@1.0.4: {}
moment@2.30.1: {}
@@ -13151,6 +13177,13 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.4.3(next@15.3.3(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
mitt: 3.0.1
react: 18.3.1
optionalDependencies:
next: 15.3.3(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oas-kit-common@1.0.8:
dependencies:
fast-safe-stringify: 2.1.1

View File

@@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.99 10.1871C19.99 9.36767 19.9246 8.76973 19.7831 8.14966H10.1943V11.8493H15.8207C15.7062 12.7676 15.0943 14.1618 13.7567 15.0492L13.7398 15.1632L16.7444 17.4429L16.9637 17.4648C18.8825 15.7291 19.99 13.2042 19.99 10.1871Z" fill="#4285F4"/>
<path d="M10.1943 19.9313C12.9592 19.9313 15.2429 19.0454 16.9637 17.4648L13.7567 15.0492C12.8697 15.6438 11.7348 16.0244 10.1943 16.0244C7.50242 16.0244 5.25023 14.2886 4.39644 11.9036L4.28823 11.9125L1.17021 14.2775L1.13477 14.3808C2.84508 17.8028 6.1992 19.9313 10.1943 19.9313Z" fill="#34A853"/>
<path d="M4.39644 11.9036C4.1758 11.2746 4.04876 10.6013 4.04876 9.90569C4.04876 9.21011 4.1758 8.53684 4.38177 7.90781L4.37563 7.7883L1.20776 5.3801L1.13477 5.41253C0.436264 6.80439 0.0390625 8.35202 0.0390625 9.90569C0.0390625 11.4594 0.436264 13.007 1.13477 14.3808L4.39644 11.9036Z" fill="#FBBC05"/>
<path d="M10.1943 3.78682C12.1168 3.78682 13.397 4.66154 14.1236 5.33481L17.0194 2.59768C15.2373 0.953818 12.9592 0 10.1943 0C6.1992 0 2.84508 2.12847 1.13477 5.41253L4.38177 7.90781C5.25023 5.52278 7.50242 3.78682 10.1943 3.78682Z" fill="#EB4335"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,12 +1,13 @@
"use client";
import { useSearchParams } from "next/navigation";
import { GraphID } from "@/lib/autogpt-server-api/types";
import FlowEditor from "@/components/Flow";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { useEffect } from "react";
import LoadingBox from "@/components/ui/loading";
import { GraphID } from "@/lib/autogpt-server-api/types";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect } from "react";
export default function BuilderPage() {
function BuilderContent() {
const query = useSearchParams();
const { completeStep } = useOnboarding();
@@ -15,12 +16,20 @@ export default function BuilderPage() {
}, [completeStep]);
const _graphVersion = query.get("flowVersion");
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined;
return (
<FlowEditor
className="flow-container"
flowID={query.get("flowID") as GraphID | null ?? undefined}
flowID={(query.get("flowID") as GraphID | null) ?? undefined}
flowVersion={graphVersion}
/>
);
}
export default function BuilderPage() {
return (
<Suspense fallback={<LoadingBox className="h-[80vh]" />}>
<BuilderContent />
</Suspense>
);
}

View File

@@ -1,67 +1,10 @@
import { Navbar } from "@/components/layout/Navbar/Navbar";
import { ReactNode } from "react";
import { Navbar } from "@/components/agptui/Navbar";
import { IconType } from "@/components/ui/icons";
export default function PlatformLayout({ children }: { children: ReactNode }) {
return (
<>
<Navbar
links={[
{
name: "Marketplace",
href: "/marketplace",
},
{
name: "Library",
href: "/library",
},
{
name: "Build",
href: "/build",
},
]}
menuItemGroups={[
{
items: [
{
icon: IconType.Edit,
text: "Edit profile",
href: "/profile",
},
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/profile/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
},
],
},
{
items: [
{
icon: IconType.Settings,
text: "Settings",
href: "/profile/settings",
},
],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
},
],
},
]}
/>
<Navbar />
<main>{children}</main>
</>
);

View File

@@ -1,5 +1,6 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import React, {
useCallback,
useEffect,
@@ -45,6 +46,7 @@ import { useToast } from "@/components/ui/use-toast";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const [executionId, setExecutionId] = useQueryState("executionId");
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
@@ -202,6 +204,15 @@ export default function AgentRunsPage(): React.ReactElement {
selectPreset,
]);
// Check for execution ID in URL search params and select that run
useEffect(() => {
if (executionId) {
selectRun(executionId as GraphExecutionID);
// Clean up the URL parameter after selecting the run
setExecutionId(null);
}
}, [executionId, selectRun, setExecutionId]);
// Initial load
useEffect(() => {
refreshPageData();
@@ -468,7 +479,7 @@ export default function AgentRunsPage(): React.ReactElement {
}
return (
<div className="container justify-stretch p-0 lg:flex">
<div className="container justify-stretch p-0 pt-16 lg:flex">
{/* Sidebar w/ list of runs */}
{/* TODO: render this below header in sm and md layouts */}
<AgentRunsSelectorList

View File

@@ -1,15 +1,15 @@
"use client";
import Link from "next/link";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
ArrowBottomRightIcon,
QuestionMarkCircledIcon,
} from "@radix-ui/react-icons";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { LibraryPageStateProvider } from "./components/state-provider";
import LibraryActionHeader from "./components/LibraryActionHeader/LibraryActionHeader";
import LibraryAgentList from "./components/LibraryAgentList/LibraryAgentList";
import { LibraryPageStateProvider } from "./components/state-provider";
/**
* LibraryPage Component
@@ -17,7 +17,7 @@ import LibraryAgentList from "./components/LibraryAgentList/LibraryAgentList";
*/
export default function LibraryPage() {
return (
<main className="container min-h-screen space-y-4 pb-20 sm:px-8 md:px-12">
<main className="pt-160sm:px-8 container min-h-screen space-y-4 pb-20 pt-16 md:px-12">
<LibraryPageStateProvider>
<LibraryActionHeader />
<LibraryAgentList />

View File

@@ -1,12 +1,12 @@
"use server";
import BackendAPI from "@/lib/autogpt-server-api";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { verifyTurnstileToken } from "@/lib/turnstile";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { verifyTurnstileToken } from "@/lib/turnstile";
async function shouldShowOnboarding() {
const api = new BackendAPI();
@@ -38,7 +38,6 @@ export async function login(
const { error } = await supabase.auth.signInWithPassword(values);
if (error) {
console.error("Error logging in:", error);
return error.message;
}
@@ -76,6 +75,11 @@ export async function providerLogin(provider: LoginProvider) {
});
if (error) {
// FIXME: supabase doesn't return the correct error message for this case
if (error.message.includes("P0001")) {
return "not_allowed";
}
console.error("Error logging in", error);
return error.message;
}

View File

@@ -0,0 +1,34 @@
import { AuthCard } from "@/components/auth/AuthCard";
import { Skeleton } from "@/components/ui/skeleton";
export function LoadingLogin() {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center">
<AuthCard title="">
<div className="w-full space-y-6">
<Skeleton className="mx-auto h-8 w-48" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="flex w-full items-center">
<Skeleton className="h-px flex-1" />
<Skeleton className="mx-3 h-4 w-6" />
<Skeleton className="h-px flex-1" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="flex justify-center space-x-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</AuthCard>
</div>
);
}

View File

@@ -1,26 +1,16 @@
"use client";
import {
AuthBottomText,
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
GoogleOAuthButton,
PasswordInput,
Turnstile,
} from "@/components/auth";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import LoadingBox from "@/components/ui/loading";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Link } from "@/components/atoms/Link/Link";
import { AuthCard } from "@/components/auth/AuthCard";
import AuthFeedback from "@/components/auth/AuthFeedback";
import { EmailNotAllowedModal } from "@/components/auth/EmailNotAllowedModal";
import { GoogleLoadingModal } from "@/components/auth/GoogleLoadingModal";
import { GoogleOAuthButton } from "@/components/auth/GoogleOAuthButton";
import Turnstile from "@/components/auth/Turnstile";
import { Form, FormField } from "@/components/ui/form";
import { getBehaveAs } from "@/lib/utils";
import Link from "next/link";
import { LoadingLogin } from "./components/LoadingLogin";
import { useLoginPage } from "./useLoginPage";
export default function LoginPage() {
@@ -30,17 +20,18 @@ export default function LoginPage() {
turnstile,
captchaKey,
isLoading,
isCloudEnv,
isLoggedIn,
isUserLoading,
isGoogleLoading,
showNotAllowedModal,
isSupabaseAvailable,
handleSubmit,
handleProviderLogin,
handleCloseNotAllowedModal,
} = useLoginPage();
if (isUserLoading || isLoggedIn) {
return <LoadingBox className="h-[80vh]" />;
return <LoadingLogin />;
}
if (!isSupabaseAvailable) {
@@ -52,99 +43,99 @@ export default function LoginPage() {
}
return (
<AuthCard className="mx-auto">
<AuthHeader>Login to your account</AuthHeader>
{isCloudEnv ? (
<>
<div className="mb-6">
<GoogleOAuthButton
onClick={() => handleProviderLogin("google")}
isLoading={isGoogleLoading}
disabled={isLoading}
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center">
<AuthCard title="Login to your account">
{true ? (
<>
<div className="mb-3 w-full">
<GoogleOAuthButton
onClick={() => handleProviderLogin("google")}
isLoading={isGoogleLoading}
disabled={isLoading}
/>
</div>
<div className="mb-3 flex w-full items-center">
<div className="h-px flex-1 bg-gray-300"></div>
<span className="text-md mx-3 text-gray-500">or</span>
<div className="h-px flex-1 bg-gray-300"></div>
</div>
</>
) : null}
<Form {...form}>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-1">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<Input
label="Email"
placeholder="m@example.com"
type="email"
autoComplete="username"
className="w-full"
error={form.formState.errors.email?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<Input
label="Password"
placeholder="•••••••••••••••••••••"
type="password"
autoComplete="current-password"
error={form.formState.errors.password?.message}
hint={
<Link variant="secondary" href="/reset-password">
Forgot password?
</Link>
}
{...field}
/>
)}
/>
</div>
<div className="mb-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="mx-3 text-sm text-gray-500">or</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
</>
) : null}
<Form {...form}>
<form onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="m@example.com"
{...field}
type="email" // Explicitly specify email type
autoComplete="username" // Added for password managers
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel className="flex w-full items-center justify-between">
<span>Password</span>
<Link
href="/reset-password"
className="text-sm font-normal leading-normal text-black underline"
>
Forgot your password?
</Link>
</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password" // Added for password managers
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Turnstile CAPTCHA Component */}
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="login"
shouldRender={turnstile.shouldRender}
/>
{/* Turnstile CAPTCHA Component */}
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="login"
shouldRender={turnstile.shouldRender}
<Button
variant="primary"
loading={isLoading}
type="submit"
className="mt-6 w-full"
>
Login
</Button>
</form>
<AuthFeedback
type="login"
message={feedback}
isError={!!feedback}
behaveAs={getBehaveAs()}
/>
<AuthButton isLoading={isLoading} type="submit">
Login
</AuthButton>
</form>
<AuthFeedback
type="login"
message={feedback}
isError={!!feedback}
behaveAs={getBehaveAs()}
</Form>
<AuthCard.BottomText
text="Don't have an account?"
link={{ text: "Sign up", href: "/signup" }}
/>
</Form>
<AuthBottomText
text="Don't have an account?"
linkText="Sign up"
href="/signup"
</AuthCard>
<GoogleLoadingModal isOpen={isGoogleLoading} />
<EmailNotAllowedModal
isOpen={showNotAllowedModal}
onClose={handleCloseNotAllowedModal}
/>
</AuthCard>
</div>
);
}

View File

@@ -1,22 +1,24 @@
import { useToast } from "@/components/ui/use-toast";
import { useTurnstile } from "@/hooks/useTurnstile";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { BehaveAs, getBehaveAs } from "@/lib/utils";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { login, providerLogin } from "./actions";
import z from "zod";
import { BehaveAs } from "@/lib/utils";
import { getBehaveAs } from "@/lib/utils";
import { login, providerLogin } from "./actions";
export function useLoginPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = getBehaveAs() === BehaveAs.CLOUD;
const turnstile = useTurnstile({
@@ -44,13 +46,22 @@ export function useLoginPage() {
async function handleProviderLogin(provider: LoginProvider) {
setIsGoogleLoading(true);
// Artificially wait 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const error = await providerLogin(provider);
if (error) throw error;
setFeedback(null);
} catch (error) {
resetCaptcha();
setFeedback(JSON.stringify(error));
const errorString = JSON.stringify(error);
if (errorString.includes("not_allowed")) {
setShowNotAllowedModal(true);
} else {
setFeedback(errorString);
}
} finally {
setIsGoogleLoading(false);
}
@@ -59,14 +70,22 @@ export function useLoginPage() {
async function handleLogin(data: z.infer<typeof loginFormSchema>) {
setIsLoading(true);
if (!turnstile.verified) {
setFeedback("Please complete the CAPTCHA challenge.");
toast({
title: "Please complete the CAPTCHA challenge.",
variant: "default",
});
setIsLoading(false);
resetCaptcha();
return;
}
if (data.email.includes("@agpt.co")) {
setFeedback("Please use Google SSO to login using an AutoGPT email.");
toast({
title: "Please use Google SSO to login using an AutoGPT email.",
variant: "default",
});
setIsLoading(false);
resetCaptcha();
return;
@@ -76,7 +95,11 @@ export function useLoginPage() {
await supabase?.auth.refreshSession();
setIsLoading(false);
if (error) {
setFeedback(error);
toast({
title: error,
variant: "destructive",
});
resetCaptcha();
// Always reset the turnstile on any error
turnstile.reset();
@@ -95,8 +118,10 @@ export function useLoginPage() {
isCloudEnv,
isUserLoading,
isGoogleLoading,
showNotAllowedModal,
isSupabaseAvailable: !!supabase,
handleSubmit: form.handleSubmit(handleLogin),
handleProviderLogin,
handleCloseNotAllowedModal: () => setShowNotAllowedModal(false),
};
}

View File

@@ -1,13 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import LoadingBox from "@/components/ui/loading";
import { useToast, useToastOnFail } from "@/components/ui/use-toast";
import useCredits from "@/hooks/useCredits";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSearchParams, useRouter } from "next/navigation";
import { useToast, useToastOnFail } from "@/components/ui/use-toast";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { RefundModal } from "./RefundModal";
import { CreditTransaction } from "@/lib/autogpt-server-api";
import { RefundModal } from "./RefundModal";
import {
Table,
@@ -18,7 +19,7 @@ import {
TableRow,
} from "@/components/ui/table";
export default function CreditsPage() {
function CreditsContent() {
const api = useBackendAPI();
const {
requestTopUp,
@@ -371,3 +372,11 @@ export default function CreditsPage() {
</div>
);
}
export default function CreditsPage() {
return (
<Suspense fallback={<LoadingBox className="h-[80vh]" />}>
<CreditsContent />
</Suspense>
);
}

View File

@@ -1,43 +1,27 @@
"use client";
import {
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
PasswordInput,
Turnstile,
} from "@/components/auth";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { AuthCard } from "@/components/auth/AuthCard";
import Turnstile from "@/components/auth/Turnstile";
import { Form, FormField } from "@/components/ui/form";
import LoadingBox from "@/components/ui/loading";
import { useToast } from "@/components/ui/use-toast";
import { useTurnstile } from "@/hooks/useTurnstile";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { getBehaveAs } from "@/lib/utils";
import { changePasswordFormSchema, sendEmailFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
export default function ResetPasswordPage() {
function ResetPasswordContent() {
const { supabase, user, isUserLoading } = useSupabase();
const { toast } = useToast();
const searchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [disabled, setDisabled] = useState(false);
const [sendEmailCaptchaKey, setSendEmailCaptchaKey] = useState(0);
const [changePasswordCaptchaKey, setChangePasswordCaptchaKey] = useState(0);
@@ -97,7 +81,6 @@ export default function ResetPasswordPage() {
const onSendEmail = useCallback(
async (data: z.infer<typeof sendEmailFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await sendEmailForm.trigger())) {
setIsLoading(false);
@@ -105,8 +88,11 @@ export default function ResetPasswordPage() {
}
if (!sendEmailTurnstile.verified) {
setFeedback("Please complete the CAPTCHA challenge.");
setIsError(true);
toast({
title: "CAPTCHA Required",
description: "Please complete the CAPTCHA challenge.",
variant: "destructive",
});
setIsLoading(false);
resetSendEmailCaptcha();
return;
@@ -118,24 +104,28 @@ export default function ResetPasswordPage() {
);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
toast({
title: "Error",
description: error,
variant: "destructive",
});
resetSendEmailCaptcha();
return;
}
setDisabled(true);
setFeedback(
"Password reset email sent if user exists. Please check your email.",
);
setIsError(false);
toast({
title: "Email Sent",
description:
"Password reset email sent if user exists. Please check your email.",
variant: "default",
});
},
[sendEmailForm, sendEmailTurnstile, resetSendEmailCaptcha],
[sendEmailForm, sendEmailTurnstile, resetSendEmailCaptcha, toast],
);
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await changePasswordForm.trigger())) {
setIsLoading(false);
@@ -143,8 +133,11 @@ export default function ResetPasswordPage() {
}
if (!changePasswordTurnstile.verified) {
setFeedback("Please complete the CAPTCHA challenge.");
setIsError(true);
toast({
title: "CAPTCHA Required",
description: "Please complete the CAPTCHA challenge.",
variant: "destructive",
});
setIsLoading(false);
resetChangePasswordCaptcha();
return;
@@ -156,15 +149,26 @@ export default function ResetPasswordPage() {
);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
toast({
title: "Error",
description: error,
variant: "destructive",
});
resetChangePasswordCaptcha();
return;
}
setFeedback("Password changed successfully. Redirecting to login.");
setIsError(false);
toast({
title: "Success",
description: "Password changed successfully. Redirecting to login.",
variant: "default",
});
},
[changePasswordForm, changePasswordTurnstile, resetChangePasswordCaptcha],
[
changePasswordForm,
changePasswordTurnstile,
resetChangePasswordCaptcha,
toast,
],
);
if (isUserLoading) {
@@ -180,39 +184,43 @@ export default function ResetPasswordPage() {
}
return (
<div className="flex min-h-screen items-center justify-center">
<AuthCard>
<AuthHeader>Reset Password</AuthHeader>
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
<AuthCard title="Reset Password">
{user ? (
<form onSubmit={changePasswordForm.handleSubmit(onChangePassword)}>
<form
onSubmit={changePasswordForm.handleSubmit(onChangePassword)}
className="flex w-full flex-col gap-1"
>
<Form {...changePasswordForm}>
<FormField
control={changePasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={
changePasswordForm.formState.errors.password?.message
}
{...field}
/>
)}
/>
<FormField
control={changePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 12 characters long
</FormDescription>
<FormMessage />
</FormItem>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={
changePasswordForm.formState.errors.confirmPassword
?.message
}
{...field}
/>
)}
/>
@@ -228,35 +236,34 @@ export default function ResetPasswordPage() {
shouldRender={changePasswordTurnstile.shouldRender}
/>
<AuthButton
onClick={() => onChangePassword(changePasswordForm.getValues())}
isLoading={isLoading}
<Button
variant="primary"
loading={isLoading}
type="submit"
className="mt-6 w-full"
onClick={() => onChangePassword(changePasswordForm.getValues())}
>
Update password
</AuthButton>
<AuthFeedback
type="login"
message={feedback}
isError={isError}
behaveAs={getBehaveAs()}
/>
</Button>
</Form>
</form>
) : (
<form onSubmit={sendEmailForm.handleSubmit(onSendEmail)}>
<form
onSubmit={sendEmailForm.handleSubmit(onSendEmail)}
className="flex w-full flex-col gap-1"
>
<Form {...sendEmailForm}>
<FormField
control={sendEmailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
<Input
label="Email"
placeholder="m@example.com"
type="email"
error={sendEmailForm.formState.errors.email?.message}
{...field}
/>
)}
/>
@@ -272,20 +279,16 @@ export default function ResetPasswordPage() {
shouldRender={sendEmailTurnstile.shouldRender}
/>
<AuthButton
onClick={() => onSendEmail(sendEmailForm.getValues())}
isLoading={isLoading}
<Button
variant="primary"
loading={isLoading}
disabled={disabled}
type="submit"
className="mt-6 w-full"
onClick={() => onSendEmail(sendEmailForm.getValues())}
>
Send reset email
</AuthButton>
<AuthFeedback
type="login"
message={feedback}
isError={isError}
behaveAs={getBehaveAs()}
/>
</Button>
</Form>
</form>
)}
@@ -293,3 +296,11 @@ export default function ResetPasswordPage() {
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<LoadingBox className="h-[80vh]" />}>
<ResetPasswordContent />
</Suspense>
);
}

View File

@@ -0,0 +1,29 @@
import { AuthCard } from "@/components/auth/AuthCard";
import { Skeleton } from "@/components/ui/skeleton";
export function LoadingSignup() {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center">
<AuthCard title="">
<Skeleton className="mx-auto h-8 w-48" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="w-full space-y-6">
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="flex justify-center space-x-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</AuthCard>
</div>
);
}

View File

@@ -1,28 +1,26 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Link } from "@/components/atoms/Link/Link";
import { Text } from "@/components/atoms/Text/Text";
import { AuthCard } from "@/components/auth/AuthCard";
import AuthFeedback from "@/components/auth/AuthFeedback";
import { EmailNotAllowedModal } from "@/components/auth/EmailNotAllowedModal";
import { GoogleLoadingModal } from "@/components/auth/GoogleLoadingModal";
import { GoogleOAuthButton } from "@/components/auth/GoogleOAuthButton";
import Turnstile from "@/components/auth/Turnstile";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
AuthButton,
AuthBottomText,
GoogleOAuthButton,
PasswordInput,
Turnstile,
} from "@/components/auth";
import AuthFeedback from "@/components/auth/AuthFeedback";
import { getBehaveAs } from "@/lib/utils";
import { WarningOctagonIcon } from "@phosphor-icons/react/dist/ssr";
import { LoadingSignup } from "./components/LoadingSignup";
import { useSignupPage } from "./useSignupPage";
export default function SignupPage() {
@@ -36,13 +34,15 @@ export default function SignupPage() {
isCloudEnv,
isUserLoading,
isGoogleLoading,
showNotAllowedModal,
isSupabaseAvailable,
handleSubmit,
handleProviderSignup,
handleCloseNotAllowedModal,
} = useSignupPage();
if (isUserLoading || isLoggedIn) {
return <LoadingBox className="h-[80vh]" />;
return <LoadingSignup />;
}
if (!isSupabaseAvailable) {
@@ -53,143 +53,167 @@ export default function SignupPage() {
);
}
const confirmPasswordError = form.formState.errors.confirmPassword?.message;
const termsError = form.formState.errors.agreeToTerms?.message;
return (
<AuthCard className="mx-auto mt-12">
<AuthHeader>Create a new account</AuthHeader>
<div className="mt-10 flex h-full min-h-[85vh] flex-col items-center justify-center">
<AuthCard title="Create a new account">
{isCloudEnv ? (
<>
<div className="mb-2 w-full">
<GoogleOAuthButton
onClick={() => handleProviderSignup("google")}
isLoading={isGoogleLoading}
disabled={isLoading}
/>
</div>
<div className="mb-3 flex w-full items-center">
<div className="h-px flex-1 bg-gray-300"></div>
<span className="text-md mx-3 text-gray-500">or</span>
<div className="h-px flex-1 bg-gray-300"></div>
</div>
</>
) : null}
{isCloudEnv ? (
<>
<div className="mb-6">
<GoogleOAuthButton
onClick={() => handleProviderSignup("google")}
isLoading={isGoogleLoading}
disabled={isLoading}
<Form {...form}>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-1">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<Input
label="Email"
placeholder="m@example.com"
type="email"
autoComplete="email"
error={form.formState.errors.email?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<Input
label="Password"
placeholder="********"
type="password"
autoComplete="new-password"
error={form.formState.errors.password?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<Input
label="Confirm Password"
placeholder="********"
type="password"
autoComplete="new-password"
error={confirmPasswordError}
{...field}
/>
)}
/>
</div>
<div className="mb-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="mx-3 text-sm text-gray-500">or</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
</>
) : null}
<Form {...form}>
<form onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="m@example.com"
{...field}
type="email"
autoComplete="email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} autoComplete="new-password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} autoComplete="new-password" />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 12 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<>
<FormItem className="mt-6 flex w-full flex-row items-center -space-y-1 space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
className="relative bottom-px"
/>
</FormControl>
<div>
<FormLabel className="flex flex-wrap items-center gap-1">
<Text
variant="body-medium"
className="inline-block text-slate-950"
>
I agree to the
</Text>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
variant="secondary"
>
Terms of Use
</Link>
<Text
variant="body-medium"
className="inline-block text-slate-950"
>
and
</Text>
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
variant="secondary"
>
Privacy Policy
</Link>
</FormLabel>
</div>
</FormItem>
{termsError ? (
<div className="flex items-center gap-2">
<WarningOctagonIcon className="h-4 w-4 text-red-500" />
<Text variant="small-medium" className="!text-red-500">
{termsError}
</Text>
</div>
) : null}
</>
)}
/>
{/* Turnstile CAPTCHA Component */}
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="signup"
shouldRender={turnstile.shouldRender}
/>
{/* Turnstile CAPTCHA Component */}
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="signup"
shouldRender={turnstile.shouldRender}
/>
<AuthButton isLoading={isLoading} type="submit">
Sign up
</AuthButton>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-6 flex flex-row items-start -space-y-1 space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="">
<FormLabel>
<span className="mr-1 text-sm font-normal leading-normal text-slate-950">
I agree to the
</span>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Terms of Use
</Link>
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
and
</span>
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
</Form>
<AuthFeedback
type="signup"
message={feedback}
isError={!!feedback}
behaveAs={getBehaveAs()}
<Button
variant="primary"
loading={isLoading}
type="submit"
className="mt-6 w-full"
>
Sign up
</Button>
</form>
</Form>
<AuthFeedback
type="signup"
message={feedback}
isError={!!feedback}
behaveAs={getBehaveAs()}
/>
<AuthCard.BottomText
text="Already a member?"
link={{ text: "Log in", href: "/login" }}
/>
</AuthCard>
<GoogleLoadingModal isOpen={isGoogleLoading} />
<EmailNotAllowedModal
isOpen={showNotAllowedModal}
onClose={handleCloseNotAllowedModal}
/>
<AuthBottomText
text="Already a member?"
linkText="Log in"
href="/login"
/>
</AuthCard>
</div>
);
}

View File

@@ -1,22 +1,26 @@
import { useToast } from "@/components/ui/use-toast";
import { useTurnstile } from "@/hooks/useTurnstile";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { signupFormSchema, LoginProvider } from "@/types/auth";
import { BehaveAs, getBehaveAs } from "@/lib/utils";
import { LoginProvider, signupFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { signup } from "./actions";
import { providerLogin } from "../login/actions";
import z from "zod";
import { BehaveAs, getBehaveAs } from "@/lib/utils";
import { providerLogin } from "../login/actions";
import { signup } from "./actions";
export function useSignupPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const { toast } = useToast();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = getBehaveAs() === BehaveAs.CLOUD;
const turnstile = useTurnstile({
@@ -50,7 +54,10 @@ export function useSignupPage() {
setIsGoogleLoading(false);
if (error) {
resetCaptcha();
setFeedback(error);
toast({
title: error,
variant: "destructive",
});
return;
}
setFeedback(null);
@@ -60,16 +67,21 @@ export function useSignupPage() {
setIsLoading(true);
if (!turnstile.verified) {
setFeedback("Please complete the CAPTCHA challenge.");
toast({
title: "Please complete the CAPTCHA challenge.",
variant: "default",
});
setIsLoading(false);
resetCaptcha();
return;
}
if (data.email.includes("@agpt.co")) {
setFeedback(
"Please use Google SSO to create an account using an AutoGPT email.",
);
toast({
title:
"Please use Google SSO to create an account using an AutoGPT email.",
variant: "default",
});
setIsLoading(false);
resetCaptcha();
@@ -83,8 +95,13 @@ export function useSignupPage() {
setFeedback("User with this email already exists");
turnstile.reset();
return;
} else if (error === "not_allowed") {
setShowNotAllowedModal(true);
} else {
setFeedback(error);
toast({
title: error,
variant: "destructive",
});
resetCaptcha();
turnstile.reset();
}
@@ -103,8 +120,10 @@ export function useSignupPage() {
isCloudEnv,
isUserLoading,
isGoogleLoading,
showNotAllowedModal,
isSupabaseAvailable: !!supabase,
handleSubmit: form.handleSubmit(handleSignup),
handleCloseNotAllowedModal: () => setShowNotAllowedModal(false),
handleProviderSignup,
};
}

View File

@@ -1,31 +1,35 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import { LaunchDarklyProvider } from "@/components/feature-flag/feature-flag-provider";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import OnboardingProvider from "@/components/onboarding/onboarding-provider";
import { QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
export function Providers({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NextThemesProvider {...props}>
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NextThemesProvider>
<NuqsAdapter>
<NextThemesProvider {...props}>
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NextThemesProvider>
</NuqsAdapter>
</QueryClientProvider>
);
}

View File

@@ -1,34 +1,17 @@
"use client";
import React, {
createContext,
useState,
useCallback,
useEffect,
useMemo,
useRef,
MouseEvent,
Suspense,
} from "react";
import Link from "next/link";
import {
ReactFlow,
ReactFlowProvider,
Controls,
Background,
Node,
OnConnect,
Connection,
MarkerType,
NodeChange,
EdgeChange,
useReactFlow,
applyEdgeChanges,
applyNodeChanges,
useViewport,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { BlocksControl } from "@/components/edit/control/BlocksControl";
import { Control, ControlPanel } from "@/components/edit/control/ControlPanel";
import { SaveControl } from "@/components/edit/control/SaveControl";
import OttoChatWidget from "@/components/OttoChatWidget";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import RunnerUIWrapper, {
RunnerUIWrapperRef,
} from "@/components/RunnerUIWrapper";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { IconRedo2, IconUndo2 } from "@/components/ui/icons";
import { useToast } from "@/components/ui/use-toast";
import useAgentGraph from "@/hooks/useAgentGraph";
import {
BlockUIType,
formatEdgeID,
@@ -37,27 +20,44 @@ import {
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { getTypeColor, findNewlyAddedBlockCoordinates } from "@/lib/utils";
import { history } from "./history";
import { CustomEdge } from "./CustomEdge";
import ConnectionLine from "./ConnectionLine";
import { Control, ControlPanel } from "@/components/edit/control/ControlPanel";
import { SaveControl } from "@/components/edit/control/SaveControl";
import { BlocksControl } from "@/components/edit/control/BlocksControl";
import { IconUndo2, IconRedo2 } from "@/components/ui/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { startTutorial } from "./tutorial";
import useAgentGraph from "@/hooks/useAgentGraph";
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
import {
applyEdgeChanges,
applyNodeChanges,
Background,
Connection,
Controls,
EdgeChange,
MarkerType,
Node,
NodeChange,
OnConnect,
ReactFlow,
ReactFlowProvider,
useReactFlow,
useViewport,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, {
createContext,
MouseEvent,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { v4 as uuidv4 } from "uuid";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import RunnerUIWrapper, {
RunnerUIWrapperRef,
} from "@/components/RunnerUIWrapper";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import ConnectionLine from "./ConnectionLine";
import { CustomEdge } from "./CustomEdge";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { history } from "./history";
import { startTutorial } from "./tutorial";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@@ -84,7 +84,8 @@ const FlowEditor: React.FC<{
flowID?: GraphID;
flowVersion?: number;
className?: string;
}> = ({ flowID, flowVersion, className }) => {
searchParams?: URLSearchParams;
}> = ({ flowID, flowVersion, className, searchParams }) => {
const {
addNodes,
addEdges,
@@ -144,7 +145,7 @@ const FlowEditor: React.FC<{
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const params = searchParams || new URLSearchParams();
const initialPositionRef = useRef<{
[key: string]: { x: number; y: number };
}>({});
@@ -814,9 +815,26 @@ const FlowEditor: React.FC<{
);
};
const FlowEditorWithSearchParams: React.FC<{
flowID?: GraphID;
flowVersion?: number;
className?: string;
}> = (props) => {
const searchParams = useSearchParams();
return <FlowEditor {...props} searchParams={searchParams} />;
};
const WrappedFlowEditor: typeof FlowEditor = (props) => (
<ReactFlowProvider>
<FlowEditor {...props} />
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
Loading...
</div>
}
>
<FlowEditorWithSearchParams {...props} />
</Suspense>
</ReactFlowProvider>
);

View File

@@ -1,10 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -13,8 +10,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { SubmissionStatus } from "@/lib/autogpt-server-api/types";
import { Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
export function SearchAndFilterAdminMarketplace({
function SearchAndFilterAdminMarketplaceContent({
initialSearch,
}: {
initialStatus?: SubmissionStatus;
@@ -98,3 +98,20 @@ export function SearchAndFilterAdminMarketplace({
</div>
);
}
export function SearchAndFilterAdminMarketplace({
initialSearch,
}: {
initialStatus?: SubmissionStatus;
initialSearch?: string;
}) {
return (
<Suspense
fallback={
<div className="flex items-center justify-center">Loading...</div>
}
>
<SearchAndFilterAdminMarketplaceContent initialSearch={initialSearch} />
</Suspense>
);
}

View File

@@ -1,11 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -13,8 +9,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
import { Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
export function SearchAndFilterAdminSpending({
function SearchAndFilterAdminSpendingContent({
initialSearch,
}: {
initialStatus?: CreditTransactionType;
@@ -102,3 +102,20 @@ export function SearchAndFilterAdminSpending({
</div>
);
}
export function SearchAndFilterAdminSpending({
initialSearch,
}: {
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
return (
<Suspense
fallback={
<div className="flex items-center justify-center">Loading...</div>
}
>
<SearchAndFilterAdminSpendingContent initialSearch={initialSearch} />
</Suspense>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
GraphExecutionID,
GraphExecutionMeta,
@@ -12,14 +11,15 @@ import {
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/agptui/Button";
import { Badge } from "@/components/ui/badge";
import { agentRunStatusMap } from "@/components/agents/agent-run-status-chip";
import AgentRunSummaryCard from "@/components/agents/agent-run-summary-card";
import { Button } from "../atoms/Button/Button";
interface AgentRunsSelectorListProps {
agent: LibraryAgent;
@@ -72,17 +72,11 @@ export default function AgentRunsSelectorList({
<aside className={cn("flex flex-col gap-4", className)}>
{allowDraftNewRun && (
<Button
size="card"
className={
"mb-4 hidden h-16 w-72 items-center gap-2 py-6 lg:flex xl:w-80 " +
(selectedView.type == "run" && !selectedView.id
? "agpt-card-selected text-accent"
: "")
}
className={"mb-4 hidden lg:flex"}
onClick={onSelectDraftNewRun}
leftIcon={<Plus className="h-6 w-6" />}
>
<Plus className="h-6 w-6" />
<span>New {agent.has_external_trigger ? "trigger" : "run"}</span>
New {agent.has_external_trigger ? "trigger" : "run"}
</Button>
)}
@@ -112,7 +106,7 @@ export default function AgentRunsSelectorList({
{/* New Run button - only in small layouts */}
{allowDraftNewRun && (
<Button
size="card"
size="large"
className={
"flex h-28 w-40 items-center gap-2 py-6 lg:hidden " +
(selectedView.type == "run" && !selectedView.id
@@ -120,9 +114,9 @@ export default function AgentRunsSelectorList({
: "")
}
onClick={onSelectDraftNewRun}
leftIcon={<Plus className="h-6 w-6" />}
>
<Plus className="h-6 w-6" />
<span>New {agent.has_external_trigger ? "trigger" : "run"}</span>
New {agent.has_external_trigger ? "trigger" : "run"}
</Button>
)}

View File

@@ -1,131 +0,0 @@
import * as React from "react";
import Link from "next/link";
import { ProfilePopoutMenu } from "./ProfilePopoutMenu";
import { IconType, IconLogIn, IconAutoGPTLogo } from "@/components/ui/icons";
import { MobileNavBar } from "./MobileNavBar";
import { Button } from "./Button";
import Wallet from "./Wallet";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { NavbarLink } from "./NavbarLink";
import BackendAPI from "@/lib/autogpt-server-api";
import { getServerUser } from "@/lib/supabase/server/getServerUser";
// Disable theme toggle for now
// import { ThemeToggle } from "./ThemeToggle";
interface NavLink {
name: string;
href: string;
}
interface NavbarProps {
links: NavLink[];
menuItemGroups: {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
}[];
}
async function getProfileData() {
const api = new BackendAPI();
const profile = await Promise.resolve(api.getStoreProfile());
return profile;
}
export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
const { user } = await getServerUser();
const isLoggedIn = user !== null;
let profile: ProfileDetails | null = null;
if (isLoggedIn) {
profile = await getProfileData();
}
return (
<>
<nav className="sticky top-0 z-40 mx-[16px] hidden h-16 items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex">
<div className="flex items-center gap-11">
<div className="relative h-10 w-[88.87px]">
<IconAutoGPTLogo className="h-full w-full" />
</div>
{links.map((link) => (
<NavbarLink key={link.name} name={link.name} href={link.href} />
))}
</div>
{/* Profile section */}
<div className="flex items-center gap-4">
{isLoggedIn ? (
<div className="flex items-center gap-4">
{profile && <Wallet />}
<ProfilePopoutMenu
menuItemGroups={menuItemGroups}
userName={profile?.username}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url}
/>
</div>
) : (
<Link href="/login">
<Button
size="sm"
className="flex items-center justify-end space-x-2"
>
<IconLogIn className="h-5 h-[48px] w-5" />
<span>Log In</span>
</Button>
</Link>
)}
{/* <ThemeToggle /> */}
</div>
</nav>
{/* Mobile Navbar - Adjust positioning */}
<>
{isLoggedIn ? (
<div className="fixed right-4 top-4 z-50">
<MobileNavBar
userName={profile?.username}
menuItemGroups={[
{
groupName: "Navigation",
items: links.map((link) => ({
icon:
link.name === "Marketplace"
? IconType.Marketplace
: link.name === "Library"
? IconType.Library
: link.name === "Build"
? IconType.Builder
: link.name === "Monitor"
? IconType.Library
: IconType.LayoutDashboard,
text: link.name,
href: link.href,
})),
},
...menuItemGroups,
]}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url}
/>
</div>
) : (
<Link
href="/login"
className="fixed right-4 top-4 z-50 mt-4 inline-flex h-8 items-center justify-end rounded-lg pr-4 md:hidden"
>
<Button size="sm" className="flex items-center space-x-2">
<IconLogIn className="h-5 w-5" />
<span>Log In</span>
</Button>
</Link>
)}
</>
</>
);
};

View File

@@ -1,66 +0,0 @@
"use client";
import Link from "next/link";
import {
IconShoppingCart,
IconBoxes,
IconLibrary,
IconLaptop,
} from "@/components/ui/icons";
import { usePathname } from "next/navigation";
interface NavbarLinkProps {
name: string;
href: string;
}
export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
const pathname = usePathname();
const parts = pathname.split("/");
const activeLink = "/" + (parts.length > 2 ? parts[2] : parts[1]);
return (
<Link
href={href}
data-testid={`navbar-link-${name.toLowerCase()}`}
className="font-poppins text-[20px] leading-[28px]"
>
<div
className={`h-[48px] px-5 py-4 ${
activeLink === href
? "rounded-2xl bg-neutral-800 dark:bg-neutral-200"
: ""
} flex items-center justify-start gap-3`}
>
{href === "/marketplace" && (
<IconShoppingCart
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/build" && (
<IconBoxes
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/monitor" && (
<IconLaptop
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/library" && (
<IconLibrary
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
<div
className={`hidden font-poppins text-[20px] font-medium leading-[28px] lg:block ${
activeLink === href
? "text-neutral-50 dark:text-neutral-900"
: "text-neutral-900 dark:text-neutral-50"
}`}
>
{name}
</div>
</div>
</Link>
);
};

View File

@@ -56,7 +56,7 @@ const meta: Meta<typeof Button> = {
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Button>;
// Basic variants
export const Primary: Story = {

View File

@@ -1,82 +1,78 @@
import { cn } from "@/lib/utils";
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
import { cva, type VariantProps } from "class-variance-authority";
import Link, { type LinkProps } from "next/link";
import React from "react";
import { ButtonProps, extendedButtonVariants } from "./helpers";
// Extended button variants based on our design system
const extendedButtonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 font-['Geist'] leading-snug border",
{
variants: {
variant: {
primary:
"bg-zinc-800 border-zinc-800 text-white hover:bg-zinc-900 hover:border-zinc-900 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
secondary:
"bg-zinc-100 border-zinc-100 text-black hover:bg-zinc-300 hover:border-zinc-300 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
destructive:
"bg-red-500 border-red-500 text-white hover:bg-red-600 hover:border-red-600 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
outline:
"bg-transparent border-zinc-700 text-black hover:bg-zinc-100 hover:border-zinc-700 rounded-full disabled:border-zinc-200 disabled:text-zinc-200 disabled:opacity-1",
ghost:
"bg-transparent border-transparent text-black hover:bg-zinc-50 hover:border-zinc-50 rounded-full disabled:text-zinc-200 disabled:opacity-1",
icon: "bg-white text-black border border-zinc-600 hover:bg-zinc-100 rounded-[96px] disabled:opacity-1",
},
size: {
small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem]",
large: "min-w-20 px-4 py-3 text-sm gap-2",
icon: "p-3",
},
},
defaultVariants: {
variant: "primary",
size: "large",
},
},
);
export function Button(props: ButtonProps) {
const {
className,
variant,
size,
loading = false,
leftIcon,
rightIcon,
children,
as = "button",
...restProps
} = props;
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof extendedButtonVariants> {
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
asChild?: boolean;
}
function Button({
className,
variant,
size,
loading = false,
leftIcon,
rightIcon,
children,
disabled,
...props
}: ButtonProps) {
const disabled = "disabled" in props ? props.disabled : false;
const isDisabled = disabled;
const buttonContent = (
<>
{loading && (
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
)}
{!loading && leftIcon}
{children}
{!loading && rightIcon}
</>
);
if (loading) {
return variant === "ghost" ? (
<button
const loadingClassName =
variant === "ghost"
? cn(
extendedButtonVariants({ variant, size, className }),
"pointer-events-none",
)
: cn(
extendedButtonVariants({ variant: "primary", size, className }),
"pointer-events-none border-zinc-500 bg-zinc-500 text-white",
);
return as === "NextLink" ? (
<Link
{...(restProps as LinkProps)}
className={loadingClassName}
aria-disabled="true"
>
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
{children}
</Link>
) : (
<button className={loadingClassName} disabled>
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
{children}
</button>
);
}
if (as === "NextLink") {
return (
<Link
{...(restProps as LinkProps)}
className={cn(
extendedButtonVariants({ variant, size, className }),
"pointer-events-none",
loading && "pointer-events-none",
isDisabled && "pointer-events-none opacity-50",
)}
aria-disabled={isDisabled}
>
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
{children}
</button>
) : (
<button
className={cn(
extendedButtonVariants({ variant: "primary", size, className }),
"pointer-events-none border-zinc-500 bg-zinc-500 text-white",
)}
>
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
{children}
</button>
{buttonContent}
</Link>
);
}
@@ -87,18 +83,9 @@ function Button({
loading && "pointer-events-none",
)}
disabled={isDisabled}
{...props}
{...(restProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}
>
{loading && (
<CircleNotchIcon className="h-4 w-4 animate-spin" weight="bold" />
)}
{!loading && leftIcon}
{children}
{!loading && rightIcon}
{buttonContent}
</button>
);
}
Button.displayName = "Button";
export { Button, extendedButtonVariants };

View File

@@ -0,0 +1,55 @@
import { cva, VariantProps } from "class-variance-authority";
import { LinkProps } from "next/link";
// Extended button variants based on our design system
export const extendedButtonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 font-sans leading-snug border",
{
variants: {
variant: {
primary:
"bg-zinc-800 border-zinc-800 text-white hover:bg-zinc-900 hover:border-zinc-900 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
secondary:
"bg-zinc-100 border-zinc-100 text-black hover:bg-zinc-300 hover:border-zinc-300 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
destructive:
"bg-red-500 border-red-500 text-white hover:bg-red-600 hover:border-red-600 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
outline:
"bg-transparent border-zinc-700 text-black hover:bg-zinc-100 hover:border-zinc-700 rounded-full disabled:border-zinc-200 disabled:text-zinc-200 disabled:opacity-1",
ghost:
"bg-transparent border-transparent text-black hover:bg-zinc-50 hover:border-zinc-50 rounded-full disabled:text-zinc-200 disabled:opacity-1",
icon: "bg-white text-black border border-zinc-600 hover:bg-zinc-100 rounded-[96px] disabled:opacity-1",
},
size: {
small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem]",
large: "min-w-20 px-4 py-3 text-sm gap-2",
icon: "p-3",
},
},
defaultVariants: {
variant: "primary",
size: "large",
},
},
);
type BaseButtonProps = {
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
asChild?: boolean;
} & VariantProps<typeof extendedButtonVariants>;
type ButtonAsButton = BaseButtonProps &
React.ButtonHTMLAttributes<HTMLButtonElement> & {
as?: "button";
href?: never;
};
type ButtonAsLink = BaseButtonProps &
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
LinkProps & {
as: "NextLink";
disabled?: boolean;
};
export type ButtonProps = ButtonAsButton | ButtonAsLink;

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
}
export function Card({ children, className }: Props) {
return (
<div className={cn("rounded-large bg-white p-6 shadow-md", className)}>
{children}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Input as BaseInput, type InputProps } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
import { Text } from "../Text/Text";
import { useInput } from "./useInput";
@@ -8,6 +9,7 @@ export interface TextFieldProps extends InputProps {
hideLabel?: boolean;
decimalCount?: number; // Only used for type="amount"
error?: string;
hint?: ReactNode;
}
export function Input({
@@ -16,6 +18,7 @@ export function Input({
placeholder,
hideLabel = false,
decimalCount,
hint,
error,
...props
}: TextFieldProps) {
@@ -26,13 +29,13 @@ export function Input({
className={cn(
// Override the default input styles with Figma design
"h-[2.875rem] rounded-3xl border border-zinc-200 bg-white px-4 py-2.5 shadow-none",
"font-normal leading-6 text-black",
"font-normal text-black",
"placeholder:font-normal placeholder:text-zinc-400",
// Focus and hover states
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
// Error state
error &&
"border-2 border-red-500 focus:border-red-500 focus:ring-red-500",
"border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500",
className,
)}
type={props.type}
@@ -44,13 +47,19 @@ export function Input({
);
const inputWithError = (
<div className="flex flex-col gap-1">
<div className="relative mb-6">
{input}
{error && (
<Text variant="small-medium" as="span" className="!text-red-500">
{error}
</Text>
)}
<Text
variant="small-medium"
as="span"
className={cn(
"absolute left-0 top-full mt-1 !text-red-500 transition-opacity duration-200",
error ? "opacity-100" : "opacity-0",
)}
>
{error || " "}{" "}
{/* Always render with space to maintain consistent height calculation */}
</Text>
</div>
);
@@ -58,9 +67,12 @@ export function Input({
inputWithError
) : (
<label className="flex flex-col gap-2">
<Text variant="body-medium" as="span" className="text-black">
{label}
</Text>
<div className="flex items-center justify-between">
<Text variant="body-medium" as="span" className="text-black">
{label}
</Text>
{hint}
</div>
{inputWithError}
</label>
);

View File

@@ -23,7 +23,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
) {
const linkClasses = cn(
// Base styles from Figma
"font-['Geist'] text-sm font-medium leading-[22px] text-[var(--AutoGPT-Text-text-black,#141414)]",
"font-sans text-sm font-medium leading-[22px] text-[var(--AutoGPT-Text-text-black,#141414)]",
// Variant-specific underline styles
variant === "primary" && "hover:underline",
variant === "secondary" && "underline",

View File

@@ -94,8 +94,6 @@ export function AllVariants() {
code={`<Text variant="small">Small</Text>
<Text variant="small-medium">Small Medium</Text>`}
/>
<Text variant="subtle">Subtle</Text>
<StoryCode code={`<Text variant="subtle">Subtle</Text>`} />
</div>
);
}
@@ -128,7 +126,6 @@ export function BodyText() {
<Text variant="body-medium">Body Medium</Text>
<Text variant="small">Small</Text>
<Text variant="small-medium">Small Medium</Text>
<Text variant="subtle">Subtle</Text>
</div>
);
}

View File

@@ -14,24 +14,24 @@ export type As =
export const variants = {
// Headings
h1: "font-poppins text-5xl font-semibold leading-[56px] text-zinc-800",
h2: "font-poppins text-4xl font-normal leading-[52px] text-zinc-800",
h3: "font-poppins text-3xl font-medium leading-10 text-zinc-800",
h4: "font-poppins text-base font-medium leading-normal text-zinc-800",
h1: "font-poppins text-[2.75rem] font-semibold leading-[3.5rem] text-zinc-800",
h2: "font-poppins text-[2.25rem] font-normal leading-[3.25rem] text-zinc-800",
h3: "font-poppins text-[1.75rem] font-medium leading-[2.5rem] text-zinc-800",
h4: "font-poppins text-[1rem] font-medium leading-[1.5rem] text-zinc-800",
// Body Text
lead: "font-sans text-xl font-normal leading-loose text-muted-zinc-800",
large: "font-sans text-base font-normal leading-normal text-zinc-800",
lead: "font-sans text-[1.25rem] font-normal leading-[2rem] text-zinc-800",
large: "font-sans text-[1rem] font-normal leading-[1.5rem] text-zinc-800",
"large-medium":
"font-sans text-base font-medium leading-normal text-zinc-800",
"font-sans text-[1rem] font-medium leading-[1.5rem] text-zinc-800",
"large-semibold":
"font-sans text-base font-semibold leading-normal text-zinc-800",
body: "font-sans text-sm font-normal leading-snug text-zinc-800",
"font-sans text-[1rem] font-semibold leading-[1.5rem] text-zinc-800",
body: "font-sans text-[0.875rem] font-normal leading-[1.375rem] text-zinc-800",
"body-medium": "font-sans text-sm font-medium leading-snug text-zinc-800",
small: "font-sans text-xs font-normal leading-tight text-zinc-800",
"small-medium": "font-sans text-xs font-medium leading-tight text-zinc-800",
subtle:
"font-sans text-xs font-medium uppercase leading-tight tracking-wide text-zinc-800",
small:
"font-sans text-[0.75rem] font-normal leading-[1.125rem] text-zinc-800",
"small-medium":
"font-sans text-[0.75rem] font-medium leading-[1.125rem] text-zinc-800",
} as const;
export type Variant = keyof typeof variants;
@@ -49,5 +49,4 @@ export const variantElementMap: Record<Variant, As> = {
"body-medium": "p",
small: "p",
"small-medium": "p",
subtle: "p",
};

View File

@@ -1,37 +0,0 @@
import { cn } from "@/lib/utils";
import Link from "next/link";
interface Props {
className?: string;
text: string;
linkText?: string;
href?: string;
}
export default function AuthBottomText({
className = "",
text,
linkText,
href = "",
}: Props) {
return (
<div
className={cn(
className,
"mt-8 inline-flex w-full items-center justify-center",
)}
>
<span className="text-sm font-medium leading-normal text-slate-950">
{text}
</span>
{linkText && (
<Link
href={href}
className="ml-1 text-sm font-medium leading-normal text-slate-950 underline"
>
{linkText}
</Link>
)}
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { ReactNode } from "react";
import { FaSpinner } from "react-icons/fa";
import { Button } from "../ui/button";
interface Props {
children?: ReactNode;
isLoading?: boolean;
disabled?: boolean;
type?: "button" | "submit" | "reset";
onClick?: () => void;
}
export default function AuthButton({
children,
isLoading = false,
disabled = false,
type = "button",
onClick,
}: Props) {
return (
<Button
className="mt-2 w-full px-4 py-2 text-zinc-800"
variant="outline"
type={type}
disabled={isLoading || disabled}
onClick={onClick}
>
{isLoading ? (
<FaSpinner className="animate-spin" />
) : (
<div className="text-sm font-medium">{children}</div>
)}
</Button>
);
}

View File

@@ -1,22 +1,52 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
import { Card } from "../atoms/Card/Card";
import { Link } from "../atoms/Link/Link";
import { Text } from "../atoms/Text/Text";
interface Props {
children: ReactNode;
interface BottomTextProps {
text: string;
link?: { text: string; href: string };
className?: string;
}
export default function AuthCard({ children, className }: Props) {
AuthCard.BottomText = function BottomText({
text,
link,
className,
}: BottomTextProps) {
return (
<div
className={cn(
"flex h-[80vh] w-[32rem] items-center justify-center",
className,
"mt-4 inline-flex w-full items-center justify-center gap-1",
)}
>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children}
</div>
<Text variant="body-medium" className="text-slate-950">
{text}
</Text>
{link ? (
<Link href={link.href} variant="secondary">
{link.text}
</Link>
) : null}
</div>
);
};
interface Props {
children: ReactNode;
title: string;
className?: string;
}
export function AuthCard({ children, title }: Props) {
return (
<Card className="mx-auto flex min-h-[40vh] w-full max-w-[32rem] flex-col items-center justify-center gap-8">
<Text variant="h3" as="h2" className="mb-3">
{title}
</Text>
{children}
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import { AlertCircle, CheckCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { HelpItem } from "@/components/auth/help-item";
import { Card, CardContent } from "@/components/ui/card";
import { BehaveAs } from "@/lib/utils";
import { AlertCircle, CheckCircle } from "lucide-react";
interface Props {
type: "login" | "signup";
@@ -16,12 +16,24 @@ export default function AuthFeedback({
isError = false,
behaveAs = BehaveAs.CLOUD,
}: Props) {
// If there's no message but isError is true, show a default error message
const displayMessage =
message || (isError ? "Something went wrong. Please try again." : "");
const isCloudMode = behaveAs === BehaveAs.CLOUD;
const isLocalMode = behaveAs === BehaveAs.LOCAL;
const showCloudHelp = isError && isCloudMode;
const showLocalHelp = isError && isLocalMode;
const isSignupFlow = type === "signup";
const hasAnyHelpContent = showCloudHelp || showLocalHelp;
const hasContent = displayMessage || hasAnyHelpContent;
if (!hasContent) {
return null;
}
return (
<div className="mt-4 space-y-4">
<div className="mt-4 w-full space-y-4">
{/* Message feedback */}
{displayMessage && (
<div className="text-center text-sm font-medium leading-normal">
@@ -40,9 +52,8 @@ export default function AuthFeedback({
)}
{/* Cloud-specific help */}
{isError &&
behaveAs === BehaveAs.CLOUD &&
(type === "signup" ? (
{showCloudHelp &&
(isSignupFlow ? (
<Card className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<CardContent className="p-0">
<div className="divide-y divide-slate-100">
@@ -81,32 +92,30 @@ export default function AuthFeedback({
))}
{/* Local-specific help */}
{isError && behaveAs === BehaveAs.LOCAL && (
<Card className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<CardContent className="p-0">
<div className="space-y-4 divide-y divide-slate-100">
<HelpItem
title="Having trouble getting AutoGPT running locally?"
description="Ask for help on our"
linkText="Discord"
href="https://discord.gg/autogpt"
/>
{showLocalHelp && (
<Card className="w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="w-full divide-y divide-slate-100">
<HelpItem
title="Having trouble getting AutoGPT running locally?"
description="Ask for help on our"
linkText="Discord"
href="https://discord.gg/autogpt"
/>
<HelpItem
title="Think you've found a bug?"
description="Open an issue on our"
linkText="GitHub"
href="https://github.com/Significant-Gravitas/AutoGPT"
/>
<HelpItem
title="Think you've found a bug?"
description="Open an issue on our"
linkText="GitHub"
href="https://github.com/Significant-Gravitas/AutoGPT"
/>
<HelpItem
title="Interested in the cloud-hosted version?"
description="Join our"
linkText="waitlist here"
href="https://agpt.co/waitlist"
/>
</div>
</CardContent>
<HelpItem
title="Interested in the cloud-hosted version?"
description="Join our"
linkText="waitlist here"
href="https://agpt.co/waitlist"
/>
</div>
</Card>
)}
</div>

View File

@@ -1,13 +0,0 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthHeader({ children }: Props) {
return (
<div className="mb-8 text-2xl font-semibold leading-normal text-slate-950">
{children}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from "../atoms/Button/Button";
import { Text } from "../atoms/Text/Text";
import { Dialog } from "../molecules/Dialog/Dialog";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export function EmailNotAllowedModal({ isOpen, onClose }: Props) {
return (
<Dialog
controlled={{ isOpen, set: onClose }}
styling={{ maxWidth: "35rem" }}
>
<Dialog.Content>
<div className="flex flex-col items-center gap-8 py-4">
<Text variant="h3">Access Restricted</Text>
<Text variant="large-medium" className="text-center">
We&apos;re currently in a limited access phase. Your email address
isn&apos;t on our current allowlist for early access. If you believe
this is an error or would like to request access, please contact our
support team.
</Text>
<div className="flex justify-end pt-4">
<Button variant="primary" onClick={onClose}>
I understand
</Button>
</div>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,33 @@
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
import Image from "next/image";
import { Text } from "../atoms/Text/Text";
import { Dialog } from "../molecules/Dialog/Dialog";
interface GoogleLoadingModalProps {
isOpen: boolean;
}
export function GoogleLoadingModal({ isOpen }: GoogleLoadingModalProps) {
return (
<Dialog forceOpen={isOpen} styling={{ maxWidth: "32rem" }}>
<Dialog.Content>
<div className="flex flex-col items-center gap-8 py-4">
<div className="mb-2 flex items-center justify-center gap-3">
<Image src="/google-logo.svg" alt="Google" width={20} height={20} />
<Text variant="h3">Signing in with Google</Text>
</div>
<CircleNotchIcon
className="h-10 w-10 animate-spin"
weight="regular"
/>
<Text variant="large-medium" className="text-center">
You&apos;re being redirected to Google to complete the sign-in
process.
<br /> Please don&apos;t close this tab or navigate away from this
page.
</Text>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { FaGoogle, FaSpinner } from "react-icons/fa";
import { Button } from "../ui/button";
import Image from "next/image";
import { Button } from "../atoms/Button/Button";
interface GoogleOAuthButtonProps {
onClick: () => void;
@@ -7,26 +7,22 @@ interface GoogleOAuthButtonProps {
disabled?: boolean;
}
export default function GoogleOAuthButton({
export function GoogleOAuthButton({
onClick,
isLoading = false,
disabled = false,
isLoading,
disabled,
}: GoogleOAuthButtonProps) {
return (
<Button
type="button"
className="w-full border bg-zinc-700 py-2 text-white disabled:opacity-50"
disabled={isLoading || disabled}
variant="secondary"
className="w-full gap-3"
onClick={onClick}
disabled={disabled}
loading={isLoading}
>
{isLoading ? (
<FaSpinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<FaGoogle className="mr-2 h-4 w-4" />
)}
<span className="text-sm font-medium">
{isLoading ? "Signing in..." : "Continue with Google"}
</span>
<Image src="/google-logo.svg" alt="Google" width={20} height={20} />
{isLoading ? "Connecting..." : "Continue with Google"}
</Button>
);
}

View File

@@ -1,19 +0,0 @@
import AuthBottomText from "./AuthBottomText";
import AuthButton from "./AuthButton";
import AuthCard from "./AuthCard";
import AuthFeedback from "./AuthFeedback";
import AuthHeader from "./AuthHeader";
import GoogleOAuthButton from "./GoogleOAuthButton";
import { PasswordInput } from "./PasswordInput";
import Turnstile from "./Turnstile";
export {
AuthBottomText,
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
GoogleOAuthButton,
PasswordInput,
Turnstile,
};

View File

@@ -0,0 +1,93 @@
"use client";
import { IconAutoGPTLogo, IconType } from "@/components/ui/icons";
import Wallet from "../../agptui/Wallet";
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
import { AgentNotifications } from "./components/AgentNotifications/AgentNotifications";
import { LoginButton } from "./components/LoginButton";
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
import { NavbarLink } from "./components/NavbarLink";
import { NavbarLoading } from "./components/NavbarLoading";
import { accountMenuItems, loggedInLinks, loggedOutLinks } from "./helpers";
import { useNavbar } from "./useNavbar";
export function Navbar() {
const { isLoggedIn, profile, isLoading } = useNavbar();
if (isLoading) {
return <NavbarLoading />;
}
return (
<>
<nav className="sticky top-0 z-40 hidden h-16 items-center rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 p-3 backdrop-blur-[26px] md:inline-flex">
{/* Left section */}
<div className="flex flex-1 items-center gap-6">
{isLoggedIn
? loggedInLinks.map((link) => (
<NavbarLink key={link.name} name={link.name} href={link.href} />
))
: loggedOutLinks.map((link) => (
<NavbarLink key={link.name} name={link.name} href={link.href} />
))}
</div>
{/* Centered logo */}
<div className="absolute left-1/2 top-1/2 h-10 w-[88.87px] -translate-x-1/2 -translate-y-1/2">
<IconAutoGPTLogo className="h-full w-full" />
</div>
{/* Right section */}
<div className="flex flex-1 items-center justify-end gap-4">
{isLoggedIn ? (
<div className="flex items-center gap-4">
<AgentNotifications />
{profile && <Wallet />}
<AccountMenu
userName={profile?.username}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url ?? ""}
menuItemGroups={accountMenuItems}
/>
</div>
) : (
<LoginButton />
)}
{/* <ThemeToggle /> */}
</div>
</nav>
{/* Mobile Navbar - Adjust positioning */}
<>
{isLoggedIn ? (
<div className="fixed right-4 top-4 z-50">
<MobileNavBar
userName={profile?.username}
menuItemGroups={[
{
groupName: "Navigation",
items: loggedInLinks.map((link) => ({
icon:
link.name === "Marketplace"
? IconType.Marketplace
: link.name === "Library"
? IconType.Library
: link.name === "Build"
? IconType.Builder
: link.name === "Monitor"
? IconType.Library
: IconType.LayoutDashboard,
text: link.name,
href: link.href,
})),
},
...accountMenuItems,
]}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url ?? ""}
/>
</div>
) : null}
</>
</>
);
}

View File

@@ -1,74 +1,31 @@
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
IconType,
IconEdit,
IconLayoutDashboard,
IconUploadCloud,
IconSettings,
IconLogOut,
IconRefresh,
IconMarketplace,
IconLibrary,
IconBuilder,
} from "../ui/icons";
import Link from "next/link";
import { ProfilePopoutMenuLogoutButton } from "./ProfilePopoutMenuLogoutButton";
import { PublishAgentPopout } from "./composite/PublishAgentPopout";
import * as React from "react";
import { ProfilePopoutMenuLogoutButton } from "../../../../agptui/ProfilePopoutMenuLogoutButton";
import { PublishAgentPopout } from "../../../../agptui/composite/PublishAgentPopout";
import { getAccountMenuOptionIcon, MenuItemGroup } from "../../helpers";
interface ProfilePopoutMenuProps {
interface Props {
userName?: string;
userEmail?: string;
avatarSrc?: string;
hideNavBarUsername?: boolean;
menuItemGroups: {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
}[];
menuItemGroups: MenuItemGroup[];
}
export function ProfilePopoutMenu({
export function AccountMenu({
userName,
userEmail,
avatarSrc,
menuItemGroups,
}: ProfilePopoutMenuProps) {
}: Props) {
const popupId = React.useId();
const getIcon = (icon: IconType) => {
const iconClass = "w-6 h-6";
switch (icon) {
case IconType.LayoutDashboard:
return <IconLayoutDashboard className={iconClass} />;
case IconType.UploadCloud:
return <IconUploadCloud className={iconClass} />;
case IconType.Edit:
return <IconEdit className={iconClass} />;
case IconType.Settings:
return <IconSettings className={iconClass} />;
case IconType.LogOut:
return <IconLogOut className={iconClass} />;
case IconType.Marketplace:
return <IconMarketplace className={iconClass} />;
case IconType.Library:
return <IconLibrary className={iconClass} />;
case IconType.Builder:
return <IconBuilder className={iconClass} />;
default:
return <IconRefresh className={iconClass} />;
}
};
return (
<Popover>
<PopoverTrigger asChild>
@@ -127,7 +84,7 @@ export function ProfilePopoutMenu({
className="inline-flex w-full items-center justify-start gap-2.5"
>
<div className="relative h-6 w-6">
{getIcon(item.icon)}
{getAccountMenuOptionIcon(item.icon)}
</div>
<div className="font-sans text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{item.text}
@@ -143,7 +100,7 @@ export function ProfilePopoutMenu({
trigger={
<div className="inline-flex w-full items-center justify-start gap-2.5">
<div className="relative h-6 w-6">
{getIcon(item.icon)}
{getAccountMenuOptionIcon(item.icon)}
</div>
<div className="font-sans text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{item.text}
@@ -165,7 +122,7 @@ export function ProfilePopoutMenu({
tabIndex={0}
>
<div className="relative h-6 w-6">
{getIcon(item.icon)}
{getAccountMenuOptionIcon(item.icon)}
</div>
<div className="font-sans text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{item.text}

View File

@@ -0,0 +1,57 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Bell } from "@phosphor-icons/react";
import { useState } from "react";
import { NotificationDropdown } from "./components/NotificationDropdown";
import { formatNotificationCount } from "./helpers";
import { useAgentNotifications } from "./useAgentNotifications";
export function AgentNotifications() {
const [isOpen, setIsOpen] = useState(false);
const { activeExecutions, recentCompletions, recentFailures } =
useAgentNotifications();
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
className={`group relative rounded-full p-2 transition-colors hover:bg-white ${isOpen ? "bg-white" : ""}`}
title="Agent Activity"
>
<Bell size={22} className="text-black" />
{activeExecutions.length > 0 && (
<>
{/* Running Agents Rotating Badge */}
<div className="absolute right-[1px] top-[0.5px] flex h-5 w-5 items-center justify-center rounded-full bg-purple-600 text-[10px] font-medium text-white">
{formatNotificationCount(activeExecutions.length)}
<div className="absolute -inset-0.5 animate-spin rounded-full border-[3px] border-transparent border-r-purple-200 border-t-purple-200" />
</div>
{/* Running Agent Hover Hint */}
<div className="absolute bottom-[-2.5rem] left-1/2 z-50 hidden -translate-x-1/2 transform whitespace-nowrap rounded-small bg-white px-4 py-2 shadow-md group-hover:block">
<Text variant="body-medium">
{activeExecutions.length} running agent
{activeExecutions.length > 1 ? "s" : ""}
</Text>
</div>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="center" sideOffset={8}>
<NotificationDropdown
activeExecutions={activeExecutions}
recentCompletions={recentCompletions}
recentFailures={recentFailures}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { Text } from "@/components/atoms/Text/Text";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Bell } from "@phosphor-icons/react";
import { AgentExecutionWithInfo } from "../helpers";
import { NotificationItem } from "./NotificationItem";
interface NotificationDropdownProps {
activeExecutions: AgentExecutionWithInfo[];
recentCompletions: AgentExecutionWithInfo[];
recentFailures: AgentExecutionWithInfo[];
}
export function NotificationDropdown({
activeExecutions,
recentCompletions,
recentFailures,
}: NotificationDropdownProps) {
// Combine and sort all executions - running/queued at top, then by most recent
function getSortedExecutions() {
const allExecutions = [
...activeExecutions.map((e) => ({ ...e, type: "running" as const })),
...recentCompletions.map((e) => ({ ...e, type: "completed" as const })),
...recentFailures.map((e) => ({ ...e, type: "failed" as const })),
];
return allExecutions.sort((a, b) => {
// Running/queued always at top
const aIsActive =
a.status === AgentExecutionStatus.RUNNING ||
a.status === AgentExecutionStatus.QUEUED;
const bIsActive =
b.status === AgentExecutionStatus.RUNNING ||
b.status === AgentExecutionStatus.QUEUED;
if (aIsActive && !bIsActive) return -1;
if (!aIsActive && bIsActive) return 1;
// Within same category, sort by most recent
const aTime = aIsActive ? a.started_at : a.ended_at;
const bTime = bIsActive ? b.started_at : b.ended_at;
if (!aTime || !bTime) return 0;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
}
const sortedExecutions = getSortedExecutions();
return (
<div>
{/* Header */}
<div className="sticky top-0 z-10 px-4 pb-1 pt-4">
<Text variant="body-medium" className="font-semibold text-gray-900">
Agent Activity
</Text>
</div>
{/* Content */}
<ScrollArea className="min-h-[10rem]">
{sortedExecutions.length > 0 ? (
<div className="p-2">
{sortedExecutions.map((execution) => (
<NotificationItem
key={execution.id}
execution={execution}
type={execution.type}
/>
))}
</div>
) : (
<div className="p-8 text-center text-gray-500">
<Bell size={32} className="mx-auto mb-2 opacity-50" />
<p>No recent activity</p>
</div>
)}
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { Text } from "@/components/atoms/Text/Text";
import {
CheckCircle,
CircleNotchIcon,
Clock,
WarningOctagonIcon,
} from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
import type { AgentExecutionWithInfo } from "../helpers";
import {
formatTimeAgo,
getExecutionDuration,
getStatusColorClass,
} from "../helpers";
interface NotificationItemProps {
execution: AgentExecutionWithInfo;
type: "running" | "completed" | "failed";
}
export function NotificationItem({ execution, type }: NotificationItemProps) {
const router = useRouter();
function getStatusIcon() {
switch (type) {
case "running":
return execution.status === AgentExecutionStatus.QUEUED ? (
<Clock size={16} className="text-purple-500" />
) : (
<CircleNotchIcon
size={16}
className="animate-spin text-purple-500"
weight="bold"
/>
);
case "completed":
return (
<CheckCircle size={16} weight="fill" className="text-purple-500" />
);
case "failed":
return <WarningOctagonIcon size={16} className="text-purple-500" />;
default:
return null;
}
}
function getTimeDisplay() {
if (type === "running") {
const timeAgo = formatTimeAgo(execution.started_at.toString());
return `Started ${timeAgo}, ${getExecutionDuration(execution)} running`;
}
if (execution.ended_at) {
const timeAgo = formatTimeAgo(execution.ended_at.toString());
return type === "completed"
? `Completed ${timeAgo}`
: `Failed ${timeAgo}`;
}
return "Unknown";
}
return (
<div
className="cursor-pointer border-b border-slate-50 px-2 py-3 transition-colors last:border-b-0 hover:bg-lightGrey"
onClick={() => {
const agentId = execution.library_agent_id || execution.graph_id;
router.push(`/library/agents/${agentId}?executionId=${execution.id}`);
}}
role="button"
>
{/* Icon + Agent Name */}
<div className="flex items-center space-x-3">
{getStatusIcon()}
<Text variant="body-medium" className="truncate text-gray-900">
{execution.agent_name}
</Text>
</div>
{/* Agent Message - Indented */}
<div className="ml-7">
{execution.agent_description ? (
<Text variant="body" className={`${getStatusColorClass(execution)}`}>
{execution.agent_description}
</Text>
) : null}
{/* Time - Indented */}
<Text variant="small" className="pt-2 !text-zinc-500">
{getTimeDisplay()}
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { ReactNode } from "react";
interface NotificationSectionProps {
title: string;
count: number;
colorClass: string;
children: ReactNode;
}
export function NotificationSection({
title,
count,
colorClass,
children,
}: NotificationSectionProps) {
return (
<div className="border-b border-gray-100 p-4 dark:border-gray-700">
<h4 className={`mb-2 text-sm font-medium ${colorClass}`}>
{title} ({count})
</h4>
<div className="space-y-2">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,344 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphExecutionMeta as GeneratedGraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { MyAgent } from "@/app/api/__generated__/models/myAgent";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
export function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function getStatusDisplayText(
execution: GeneratedGraphExecutionMeta,
): string {
switch (execution.status) {
case AgentExecutionStatus.QUEUED:
return "Queued";
case AgentExecutionStatus.RUNNING:
return "Running";
case AgentExecutionStatus.COMPLETED:
return "Completed";
case AgentExecutionStatus.FAILED:
return "Failed";
case AgentExecutionStatus.TERMINATED:
return "Stopped";
case AgentExecutionStatus.INCOMPLETE:
return "Incomplete";
default:
return execution.status;
}
}
export function getStatusColorClass(
execution: GeneratedGraphExecutionMeta,
): string {
switch (execution.status) {
case AgentExecutionStatus.QUEUED:
return "text-yellow-600";
case AgentExecutionStatus.RUNNING:
return "text-blue-600";
case AgentExecutionStatus.COMPLETED:
return "text-green-600";
case AgentExecutionStatus.FAILED:
case AgentExecutionStatus.TERMINATED:
return "text-red-600";
case AgentExecutionStatus.INCOMPLETE:
return "text-gray-600";
default:
return "text-gray-600";
}
}
export function truncateGraphId(graphId: string, length: number = 8): string {
return `${graphId.slice(0, length)}...`;
}
export function getExecutionDuration(
execution: GeneratedGraphExecutionMeta,
): string {
if (!execution.started_at) return "Unknown";
const start = new Date(execution.started_at);
const end = execution.ended_at ? new Date(execution.ended_at) : new Date();
const durationMs = end.getTime() - start.getTime();
const durationSec = Math.floor(durationMs / 1000);
if (durationSec < 60) return `${durationSec}s`;
const durationMin = Math.floor(durationSec / 60);
if (durationMin < 60) return `${durationMin}m ${durationSec % 60}s`;
const durationHr = Math.floor(durationMin / 60);
return `${durationHr}h ${durationMin % 60}m`;
}
export function shouldShowNotificationBadge(totalCount: number): boolean {
return totalCount > 0;
}
export function formatNotificationCount(count: number): string {
if (count > 99) return "99+";
return count.toString();
}
export interface AgentExecutionWithInfo extends GeneratedGraphExecutionMeta {
agent_name: string;
agent_description: string;
library_agent_id?: string;
}
export interface NotificationState {
activeExecutions: AgentExecutionWithInfo[];
recentCompletions: AgentExecutionWithInfo[];
recentFailures: AgentExecutionWithInfo[];
totalCount: number;
}
export function createAgentInfoMap(
agents: MyAgent[],
): Map<
string,
{ name: string; description: string; library_agent_id?: string }
> {
const agentMap = new Map<
string,
{ name: string; description: string; library_agent_id?: string }
>();
agents.forEach((agent) => {
agentMap.set(agent.agent_id, {
name: agent.agent_name,
description: agent.description,
library_agent_id: undefined, // MyAgent doesn't have library_agent_id
});
});
return agentMap;
}
export function convertLegacyExecutionToGenerated(
execution: GraphExecution,
): GeneratedGraphExecutionMeta {
return {
id: execution.id,
user_id: execution.user_id,
graph_id: execution.graph_id,
graph_version: execution.graph_version,
preset_id: execution.preset_id,
status: execution.status as AgentExecutionStatus,
started_at: execution.started_at.toISOString(),
ended_at: execution.ended_at.toISOString(),
stats: execution.stats || {
cost: 0,
duration: 0,
duration_cpu_only: 0,
node_exec_time: 0,
node_exec_time_cpu_only: 0,
node_exec_count: 0,
},
};
}
export function enrichExecutionWithAgentInfo(
execution: GeneratedGraphExecutionMeta,
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): AgentExecutionWithInfo {
const agentInfo = agentInfoMap.get(execution.graph_id);
return {
...execution,
agent_name: agentInfo?.name || `Graph ${execution.graph_id.slice(0, 8)}...`,
agent_description: agentInfo?.description ?? "",
library_agent_id: agentInfo?.library_agent_id,
};
}
export function isActiveExecution(
execution: GeneratedGraphExecutionMeta,
): boolean {
const status = execution.status;
return (
status === AgentExecutionStatus.RUNNING ||
status === AgentExecutionStatus.QUEUED
);
}
export function isRecentCompletion(
execution: GeneratedGraphExecutionMeta,
thirtyMinutesAgo: Date,
): boolean {
const status = execution.status;
return (
status === AgentExecutionStatus.COMPLETED &&
!!execution.ended_at &&
new Date(execution.ended_at) > thirtyMinutesAgo
);
}
export function isRecentFailure(
execution: GeneratedGraphExecutionMeta,
thirtyMinutesAgo: Date,
): boolean {
const status = execution.status;
return (
(status === AgentExecutionStatus.FAILED ||
status === AgentExecutionStatus.TERMINATED) &&
!!execution.ended_at &&
new Date(execution.ended_at) > thirtyMinutesAgo
);
}
export function isRecentNotification(
execution: AgentExecutionWithInfo,
thirtyMinutesAgo: Date,
): boolean {
return execution.ended_at
? new Date(execution.ended_at) > thirtyMinutesAgo
: false;
}
export function categorizeExecutions(
executions: GeneratedGraphExecutionMeta[],
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const enrichedExecutions = executions.map((execution) =>
enrichExecutionWithAgentInfo(execution, agentInfoMap),
);
const activeExecutions = enrichedExecutions
.filter(isActiveExecution)
.slice(0, 10);
const recentCompletions = enrichedExecutions
.filter((execution) => isRecentCompletion(execution, twentyFourHoursAgo))
.slice(0, 10);
const recentFailures = enrichedExecutions
.filter((execution) => isRecentFailure(execution, twentyFourHoursAgo))
.slice(0, 10);
return {
activeExecutions,
recentCompletions,
recentFailures,
totalCount:
activeExecutions.length +
recentCompletions.length +
recentFailures.length,
};
}
export function removeExecutionFromAllCategories(
state: NotificationState,
executionId: string,
): NotificationState {
return {
activeExecutions: state.activeExecutions.filter(
(e) => e.id !== executionId,
),
recentCompletions: state.recentCompletions.filter(
(e) => e.id !== executionId,
),
recentFailures: state.recentFailures.filter((e) => e.id !== executionId),
totalCount: state.totalCount, // Will be recalculated later
};
}
export function addExecutionToCategory(
state: NotificationState,
execution: AgentExecutionWithInfo,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const newState = { ...state };
if (isActiveExecution(execution)) {
newState.activeExecutions = [execution, ...newState.activeExecutions].slice(
0,
10,
);
} else if (isRecentCompletion(execution, twentyFourHoursAgo)) {
newState.recentCompletions = [
execution,
...newState.recentCompletions,
].slice(0, 10);
} else if (isRecentFailure(execution, twentyFourHoursAgo)) {
newState.recentFailures = [execution, ...newState.recentFailures].slice(
0,
10,
);
}
return newState;
}
export function cleanupOldNotifications(
state: NotificationState,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return {
...state,
recentCompletions: state.recentCompletions.filter((e) =>
isRecentNotification(e, twentyFourHoursAgo),
),
recentFailures: state.recentFailures.filter((e) =>
isRecentNotification(e, twentyFourHoursAgo),
),
};
}
export function calculateTotalCount(
state: NotificationState,
): NotificationState {
return {
...state,
totalCount:
state.activeExecutions.length +
state.recentCompletions.length +
state.recentFailures.length,
};
}
export function handleExecutionUpdate(
currentState: NotificationState,
execution: GraphExecution,
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): NotificationState {
// Convert and enrich the execution
const convertedExecution = convertLegacyExecutionToGenerated(execution);
const enrichedExecution = enrichExecutionWithAgentInfo(
convertedExecution,
agentInfoMap,
);
// Remove from all categories first
let newState = removeExecutionFromAllCategories(currentState, execution.id);
// Add to appropriate category
newState = addExecutionToCategory(newState, enrichedExecution);
// Clean up old notifications
newState = cleanupOldNotifications(newState);
// Recalculate total count
newState = calculateTotalCount(newState);
return newState;
}

View File

@@ -0,0 +1,164 @@
import { useGetV1GetAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV2GetMyAgents } from "@/app/api/__generated__/endpoints/store/store";
import { useGetV2ListLibraryAgents } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import BackendAPI from "@/lib/autogpt-server-api/client";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useState } from "react";
import {
NotificationState,
categorizeExecutions,
createAgentInfoMap,
handleExecutionUpdate,
} from "./helpers";
type AgentInfoMap = Map<
string,
{ name: string; description: string; library_agent_id?: string }
>;
export function useAgentNotifications() {
const [api] = useState(() => new BackendAPI());
const [notifications, setNotifications] = useState<NotificationState>({
activeExecutions: [],
recentCompletions: [],
recentFailures: [],
totalCount: 0,
});
const [isConnected, setIsConnected] = useState(false);
const [agentInfoMap, setAgentInfoMap] = useState<AgentInfoMap>(new Map());
// Get library agents using generated hook
const {
data: myAgentsResponse,
isLoading: isAgentsLoading,
error: agentsError,
} = useGetV2GetMyAgents({
query: {
enabled: true,
},
});
// Get library agents data to map graph_id to library_agent_id
const {
data: libraryAgentsResponse,
isLoading: isLibraryAgentsLoading,
error: libraryAgentsError,
} = useGetV2ListLibraryAgents(
{},
{
query: {
enabled: true,
},
},
);
// Get all executions using generated hook
const {
data: executionsResponse,
isLoading: isExecutionsLoading,
error: executionsError,
} = useGetV1GetAllExecutions({
query: {
enabled: true,
},
});
// Update agent info map when both agent data sources change
useEffect(() => {
if (
myAgentsResponse?.data?.agents &&
libraryAgentsResponse?.data &&
"agents" in libraryAgentsResponse.data
) {
const agentMap = createAgentInfoMap(myAgentsResponse.data.agents);
// Add library agent ID mapping
libraryAgentsResponse.data.agents.forEach(
(libraryAgent: LibraryAgent) => {
const existingInfo = agentMap.get(libraryAgent.graph_id);
if (existingInfo) {
agentMap.set(libraryAgent.graph_id, {
...existingInfo,
library_agent_id: libraryAgent.id,
});
}
},
);
setAgentInfoMap(agentMap);
}
}, [myAgentsResponse, libraryAgentsResponse]);
// Handle real-time execution updates
const handleExecutionEvent = useCallback(
(execution: GraphExecution) => {
setNotifications((currentState) =>
handleExecutionUpdate(currentState, execution, agentInfoMap),
);
},
[agentInfoMap],
);
// Process initial execution state when data loads
useEffect(() => {
if (
executionsResponse?.data &&
!isExecutionsLoading &&
agentInfoMap.size > 0
) {
const newNotifications = categorizeExecutions(
executionsResponse.data,
agentInfoMap,
);
setNotifications(newNotifications);
}
}, [executionsResponse, isExecutionsLoading, agentInfoMap]);
// Initialize WebSocket connection for real-time updates
useEffect(() => {
const connectHandler = api.onWebSocketConnect(() => {
setIsConnected(true);
// Subscribe to graph executions for all user agents
if (myAgentsResponse?.data?.agents) {
myAgentsResponse.data.agents.forEach((agent) => {
api
.subscribeToGraphExecutions(agent.agent_id as any)
.catch((error) => {
console.error(
`[AgentNotifications] Failed to subscribe to graph ${agent.agent_id}:`,
error,
);
});
});
}
});
const disconnectHandler = api.onWebSocketDisconnect(() => {
setIsConnected(false);
});
const messageHandler = api.onWebSocketMessage(
"graph_execution_event",
handleExecutionEvent,
);
api.connectWebSocket();
return () => {
connectHandler();
disconnectHandler();
messageHandler();
api.disconnectWebSocket();
};
}, [api, handleExecutionEvent, myAgentsResponse]);
return {
...notifications,
isConnected,
isLoading: isAgentsLoading || isExecutionsLoading || isLibraryAgentsLoading,
error: agentsError || executionsError || libraryAgentsError,
};
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { SignInIcon } from "@phosphor-icons/react/dist/ssr";
import { usePathname } from "next/navigation";
export function LoginButton() {
const pathname = usePathname();
const isLoginPage = pathname.includes("/login");
if (isLoginPage) return null;
return (
<Button
as="NextLink"
href="/login"
size="small"
className="flex items-center justify-end space-x-2"
leftIcon={<SignInIcon className="h-5 w-5" />}
variant="secondary"
>
Log In
</Button>
);
}

View File

@@ -1,45 +1,26 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@/components/ui/popover";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import {
IconType,
IconMenu,
IconChevronUp,
IconEdit,
IconLayoutDashboard,
IconUploadCloud,
IconSettings,
IconLogOut,
IconMarketplace,
IconLibrary,
IconBuilder,
} from "../ui/icons";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { usePathname } from "next/navigation";
import * as React from "react";
import { IconChevronUp, IconMenu } from "../../../../ui/icons";
import { MenuItemGroup } from "../../helpers";
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
interface MobileNavBarProps {
userName?: string;
userEmail?: string;
avatarSrc?: string;
menuItemGroups: {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
}[];
menuItemGroups: MenuItemGroup[];
}
const Overlay = React.forwardRef<HTMLDivElement, { children: React.ReactNode }>(
@@ -49,76 +30,15 @@ const Overlay = React.forwardRef<HTMLDivElement, { children: React.ReactNode }>(
</div>
),
);
Overlay.displayName = "Overlay";
const PopoutMenuItem: React.FC<{
icon: IconType;
isActive: boolean;
text: React.ReactNode;
href?: string;
onClick?: () => void;
}> = ({ icon, isActive, text, href, onClick }) => {
const getIcon = (iconType: IconType) => {
const iconClass = "w-6 h-6 relative";
switch (iconType) {
case IconType.Marketplace:
return <IconMarketplace className={iconClass} />;
case IconType.Library:
return <IconLibrary className={iconClass} />;
case IconType.Builder:
return <IconBuilder className={iconClass} />;
case IconType.Edit:
return <IconEdit className={iconClass} />;
case IconType.LayoutDashboard:
return <IconLayoutDashboard className={iconClass} />;
case IconType.UploadCloud:
return <IconUploadCloud className={iconClass} />;
case IconType.Settings:
return <IconSettings className={iconClass} />;
case IconType.LogOut:
return <IconLogOut className={iconClass} />;
default:
return null;
}
};
const content = (
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0] dark:hover:bg-[#3a3a3a]">
{getIcon(icon)}
<div className="relative">
<div
className={`font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
>
{text}
</div>
{isActive && (
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727] dark:bg-[#ffffff]"></div>
)}
</div>
</div>
);
if (onClick)
return (
<div className="w-full" onClick={onClick}>
{content}
</div>
);
if (href)
return (
<Link href={href} className="w-full">
{content}
</Link>
);
return content;
};
export const MobileNavBar: React.FC<MobileNavBarProps> = ({
export function MobileNavBar({
userName,
userEmail,
avatarSrc,
menuItemGroups,
}) => {
}: MobileNavBarProps) {
const [isOpen, setIsOpen] = React.useState(false);
const pathname = usePathname();
const parts = pathname.split("/");
@@ -173,7 +93,7 @@ export const MobileNavBar: React.FC<MobileNavBarProps> = ({
{menuItemGroups.map((group, groupIndex) => (
<React.Fragment key={groupIndex}>
{group.items.map((item, itemIndex) => (
<PopoutMenuItem
<MobileNavbarMenuItem
key={itemIndex}
icon={item.icon}
isActive={item.href === activeLink}
@@ -194,4 +114,4 @@ export const MobileNavBar: React.FC<MobileNavBarProps> = ({
</AnimatePresence>
</Popover>
);
};
}

View File

@@ -0,0 +1,55 @@
import { IconType } from "@/components/ui/icons";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { getAccountMenuOptionIcon } from "../../../helpers";
interface Props {
icon: IconType;
isActive: boolean;
text: string;
href?: string;
onClick?: () => void;
}
export function MobileNavbarMenuItem({
icon,
isActive,
text,
href,
onClick,
}: Props) {
const content = (
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0] dark:hover:bg-[#3a3a3a]">
{getAccountMenuOptionIcon(icon)}
<div className="relative">
<div
className={cn(
"font-sans text-base font-normal leading-7",
isActive
? "font-semibold text-[#272727] dark:text-[#ffffff]"
: "text-[#474747] dark:text-[#cfcfcf]",
)}
>
{text}
</div>
{isActive && (
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727] dark:bg-[#ffffff]"></div>
)}
</div>
</div>
);
if (onClick)
return (
<div className="w-full" onClick={onClick}>
{content}
</div>
);
if (href)
return (
<Link href={href} className="w-full">
{content}
</Link>
);
return content;
}

View File

@@ -0,0 +1,68 @@
"use client";
import { IconLaptop } from "@/components/ui/icons";
import { cn } from "@/lib/utils";
import {
CubeIcon,
HouseIcon,
StorefrontIcon,
} from "@phosphor-icons/react/dist/ssr";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Text } from "../../../atoms/Text/Text";
interface Props {
name: string;
href: string;
}
export function NavbarLink({ name, href }: Props) {
const pathname = usePathname();
const isActive = pathname.includes(href);
return (
<Link
href={href}
data-testid={`navbar-link-${name.toLowerCase()}`}
className="font-poppins text-[20px] leading-[28px]"
>
<div
className={cn(
"flex items-center justify-start gap-1 p-2",
isActive &&
"rounded-small bg-neutral-800 p-2 transition-all duration-300 dark:bg-neutral-200",
)}
>
{href === "/marketplace" && (
<StorefrontIcon
className={cn("h-6 w-6", isActive && "text-white dark:text-black")}
/>
)}
{href === "/build" && (
<CubeIcon
className={cn("h-6 w-6", isActive && "text-white dark:text-black")}
/>
)}
{href === "/monitor" && (
<IconLaptop
className={cn("h-6 w-6", isActive && "text-white dark:text-black")}
/>
)}
{href === "/library" && (
<HouseIcon
className={cn("h-6 w-6", isActive && "text-white dark:text-black")}
/>
)}
<Text
variant="body-medium"
className={cn(
"hidden lg:block",
isActive ? "!text-white" : "!text-black",
)}
>
{name}
</Text>
</div>
</Link>
);
}

View File

@@ -0,0 +1,21 @@
import { IconAutoGPTLogo } from "@/components/ui/icons";
import { Skeleton } from "@/components/ui/skeleton";
export function NavbarLoading() {
return (
<nav className="sticky top-0 z-40 hidden h-16 items-center rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 p-3 backdrop-blur-[26px] md:inline-flex">
<div className="flex flex-1 items-center gap-6">
<Skeleton className="h-4 w-20 bg-white/20" />
<Skeleton className="h-4 w-16 bg-white/20" />
<Skeleton className="h-4 w-12 bg-white/20" />
</div>
<div className="absolute left-1/2 top-1/2 h-10 w-[88.87px] -translate-x-1/2 -translate-y-1/2">
<IconAutoGPTLogo className="h-full w-full" />
</div>
<div className="flex flex-1 items-center justify-end gap-4">
<Skeleton className="h-8 w-8 rounded-full bg-white/20" />
<Skeleton className="h-8 w-8 rounded-full bg-white/20" />
</div>
</nav>
);
}

View File

@@ -0,0 +1,115 @@
import {
IconBuilder,
IconEdit,
IconLayoutDashboard,
IconLibrary,
IconLogOut,
IconMarketplace,
IconRefresh,
IconSettings,
IconType,
IconUploadCloud,
} from "@/components/ui/icons";
type Link = {
name: string;
href: string;
};
export const loggedInLinks: Link[] = [
{
name: "Marketplace",
href: "/marketplace",
},
{
name: "Library",
href: "/library",
},
{
name: "Build",
href: "/build",
},
];
export const loggedOutLinks: Link[] = [
{
name: "Marketplace",
href: "/marketplace",
},
];
export type MenuItemGroup = {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
};
export const accountMenuItems: MenuItemGroup[] = [
{
items: [
{
icon: IconType.Edit,
text: "Edit profile",
href: "/profile",
},
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/profile/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
},
],
},
{
items: [
{
icon: IconType.Settings,
text: "Settings",
href: "/profile/settings",
},
],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
},
],
},
];
export function getAccountMenuOptionIcon(icon: IconType) {
const iconClass = "w-6 h-6";
switch (icon) {
case IconType.LayoutDashboard:
return <IconLayoutDashboard className={iconClass} />;
case IconType.UploadCloud:
return <IconUploadCloud className={iconClass} />;
case IconType.Edit:
return <IconEdit className={iconClass} />;
case IconType.Settings:
return <IconSettings className={iconClass} />;
case IconType.LogOut:
return <IconLogOut className={iconClass} />;
case IconType.Marketplace:
return <IconMarketplace className={iconClass} />;
case IconType.Library:
return <IconLibrary className={iconClass} />;
case IconType.Builder:
return <IconBuilder className={iconClass} />;
default:
return <IconRefresh className={iconClass} />;
}
}

View File

@@ -0,0 +1,26 @@
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
export function useNavbar() {
const { isLoggedIn, isUserLoading } = useSupabase();
const {
data: profileResponse,
isLoading: isProfileLoading,
error: profileError,
} = useGetV2GetUserProfile({
query: {
enabled: isLoggedIn === true,
},
});
const profile = profileResponse?.data || null;
const isLoading = isUserLoading || (isLoggedIn && isProfileLoading);
return {
isLoggedIn,
profile,
isLoading,
profileError,
};
}

View File

@@ -71,6 +71,30 @@ export const colors = {
800: "#0c5a29",
900: "#09441f",
},
purple: {
50: "#f1ebfe",
100: "#d5c0fc",
200: "#c0a1fa",
300: "#a476f8",
400: "#925cf7",
500: "#7733f5",
600: "#6c2edf",
700: "#5424ae",
800: "#411c87",
900: "#321567",
},
pink: {
50: "#fdedf5",
100: "#f9c6df",
200: "#f6abd0",
300: "#f284bb",
400: "#f06dad",
500: "#ec4899",
600: "#d7428b",
700: "#a8336d",
800: "#822854",
900: "#631e40",
},
// Special semantic colors
white: "#fefefe",

View File

@@ -14,54 +14,54 @@ const meta: Meta = {
export default meta;
// Border radius scale data based on Figma design tokens
// Custom naming convention: xs, s, m, l, xl, 2xl, full
// Custom naming convention: xsmall, small, medium, large, xlarge, 2xlarge, full
const borderRadiusScale = [
{
name: "xs",
name: "xsmall",
value: "0.25rem",
rem: "0.25rem",
px: "4px",
class: "rounded-xs",
class: "rounded-xsmall",
description: "Extra small - for subtle rounding",
},
{
name: "s",
name: "small",
value: "0.5rem",
rem: "0.5rem",
px: "8px",
class: "rounded-s",
class: "rounded-small",
description: "Small - for cards and containers",
},
{
name: "m",
name: "medium",
value: "0.75rem",
rem: "0.75rem",
px: "12px",
class: "rounded-m",
class: "rounded-medium",
description: "Medium - for buttons and inputs",
},
{
name: "l",
name: "large",
value: "1rem",
rem: "1rem",
px: "16px",
class: "rounded-l",
class: "rounded-large",
description: "Large - for panels and modals",
},
{
name: "xl",
name: "xlarge",
value: "1.25rem",
rem: "1.25rem",
px: "20px",
class: "rounded-xl",
class: "rounded-xlarge",
description: "Extra large - for hero sections",
},
{
name: "2xl",
name: "2xlarge",
value: "1.5rem",
rem: "1.5rem",
px: "24px",
class: "rounded-2xl",
class: "rounded-2xlarge",
description: "2X large - for major containers",
},
{
@@ -84,10 +84,11 @@ export function AllVariants() {
Border Radius
</Text>
<Text variant="large" className="text-zinc-600">
Our border radius system uses a simplified naming convention (xs, s,
m, l, xl, 2xl, full) based on our Figma design tokens. This creates
visual hierarchy and maintains design consistency across all
components.
Our border radius system uses a descriptive naming convention
(xsmall, small, medium, large, xlarge, 2xlarge, full) based on our
Figma design tokens. This creates visual hierarchy and maintains
design consistency across all components while avoiding conflicts
with Tailwind&apos;s built-in classes.
</Text>
</div>
@@ -137,9 +138,10 @@ export function AllVariants() {
</div>
<Text variant="body" className="mb-4 text-zinc-600">
We use a custom border radius system based on our Figma design
tokens, with simplified naming (xs, s, m, l, xl, 2xl, full) that
provides consistent radius values optimized for our design
system.
tokens, with descriptive naming (xsmall, small, medium, large,
xlarge, 2xlarge, full) that provides consistent radius values
optimized for our design system while avoiding conflicts with
Tailwind&apos;s built-in classes.
</Text>
</div>
</div>
@@ -188,7 +190,8 @@ export function AllVariants() {
<Text variant="body" className="mb-6 text-zinc-600">
All border radius values from our Figma design tokens. Each value
can be applied to all corners or specific corners/sides using our
simplified naming convention.
descriptive naming convention (xsmall, small, medium, large, xlarge,
2xlarge, full).
</Text>
</div>
@@ -232,30 +235,30 @@ export function AllVariants() {
<StoryCode
code={`// Border radius examples - Design System Tokens
<div className="rounded-xs">Extra small rounding (4px)</div>
<div className="rounded-s">Small rounding (8px)</div>
<div className="rounded-m">Medium rounding (12px)</div>
<div className="rounded-l">Large rounding (16px)</div>
<div className="rounded-xl">Extra large rounding (20px)</div>
<div className="rounded-2xl">2X large rounding (24px)</div>
<div className="rounded-xsmall">Extra small rounding (4px)</div>
<div className="rounded-small">Small rounding (8px)</div>
<div className="rounded-medium">Medium rounding (12px)</div>
<div className="rounded-large">Large rounding (16px)</div>
<div className="rounded-xlarge">Extra large rounding (20px)</div>
<div className="rounded-2xlarge">2X large rounding (24px)</div>
<div className="rounded-full">Pill buttons (circular)</div>
// Directional rounding (works with all sizes)
<div className="rounded-t-m">Top corners only</div>
<div className="rounded-r-m">Right corners only</div>
<div className="rounded-b-m">Bottom corners only</div>
<div className="rounded-l-m">Left corners only</div>
<div className="rounded-t-medium">Top corners only</div>
<div className="rounded-r-medium">Right corners only</div>
<div className="rounded-b-medium">Bottom corners only</div>
<div className="rounded-l-medium">Left corners only</div>
// Individual corners
<div className="rounded-tl-m">Top-left corner</div>
<div className="rounded-tr-m">Top-right corner</div>
<div className="rounded-bl-m">Bottom-left corner</div>
<div className="rounded-br-m">Bottom-right corner</div>
<div className="rounded-tl-medium">Top-left corner</div>
<div className="rounded-tr-medium">Top-right corner</div>
<div className="rounded-bl-medium">Bottom-left corner</div>
<div className="rounded-br-medium">Bottom-right corner</div>
// Usage recommendations
<button className="rounded-full">Pill Button</button>
<div className="rounded-m">Card Container</div>
<input className="rounded-s">Input Field</input>`}
<div className="rounded-medium">Card Container</div>
<input className="rounded-small">Input Field</input>`}
/>
</div>
</div>

View File

@@ -36,6 +36,8 @@ const colorCategories = Object.entries(colors)
orange: "Warnings, notifications, and secondary call-to-actions",
yellow: "Highlights, cautions, and attention-grabbing elements",
green: "Success states, confirmations, and positive actions",
purple: "Brand accents, premium features, and creative elements",
pink: "Highlights, special promotions, and playful interactions",
};
return {
@@ -312,6 +314,8 @@ export function AllVariants() {
<div className="bg-green-50 border-green-200 text-green-800">Success</div>
<div className="bg-red-50 border-red-200 text-red-800">Error</div>
<div className="bg-yellow-50 border-yellow-200 text-yellow-800">Warning</div>
<div className="bg-purple-50 border-purple-200 text-purple-800">Premium</div>
<div className="bg-pink-50 border-pink-200 text-pink-800">Special</div>
// ❌ INCORRECT - Don't use these
<div className="bg-blue-500 text-purple-600">❌ Not approved</div>

View File

@@ -169,8 +169,6 @@ export function AllVariants() {
code={`<Text variant="small">Small</Text>
<Text variant="small-medium">Small Medium</Text>`}
/>
<Text variant="subtle">Subtle</Text>
<StoryCode code={`<Text variant="subtle">Subtle</Text>`} />
</div>
</div>
);

View File

@@ -1,9 +1,10 @@
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
export function PaginationControls({
function PaginationControlsContent({
currentPage,
totalPages,
pathParam = "page",
@@ -48,3 +49,27 @@ export function PaginationControls({
</div>
);
}
export function PaginationControls({
currentPage,
totalPages,
pathParam = "page",
}: {
currentPage: number;
totalPages: number;
pathParam?: string;
}) {
return (
<Suspense
fallback={
<div className="flex items-center justify-center">Loading...</div>
}
>
<PaginationControlsContent
currentPage={currentPage}
totalPages={totalPages}
pathParam={pathParam}
/>
</Suspense>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
@@ -23,7 +23,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 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 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
"rounded-medium z-50 w-72 border border-neutral-200 bg-white p-4 text-neutral-950 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 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
@@ -34,8 +34,8 @@ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverTrigger,
};

View File

@@ -11,12 +11,12 @@ const ScrollArea = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
className="h-full w-full rounded-[inherit]"
style={{ overflow: "scroll" }}
style={{ overflowX: "hidden", overflowY: "scroll" }}
>
{children}
</ScrollAreaPrimitive.Viewport>

View File

@@ -73,56 +73,56 @@ const config = {
},
spacing: {
// Tailwind spacing + custom sizes
0: "0rem",
0.5: "0.125rem",
1: "0.25rem",
1.5: "0.375rem",
2: "0.5rem",
2.5: "0.625rem",
3: "0.75rem",
3.5: "0.875rem",
4: "1rem",
5: "1.25rem",
6: "1.5rem",
7: "1.75rem",
7.5: "1.875rem",
8: "2rem",
8.5: "2.125rem",
9: "2.25rem",
10: "2.5rem",
11: "2.75rem",
12: "3rem",
14: "3.5rem",
16: "4rem",
18: "4.5rem",
20: "5rem",
24: "6rem",
28: "7rem",
32: "8rem",
36: "9rem",
40: "10rem",
44: "11rem",
48: "12rem",
52: "13rem",
56: "14rem",
60: "15rem",
64: "16rem",
68: "17rem",
70: "17.5rem",
71: "17.75rem",
72: "18rem",
76: "19rem",
80: "20rem",
96: "24rem",
0: "0rem", // 0px
0.5: "0.125rem", // 2px
1: "0.25rem", // 4px
1.5: "0.375rem", // 6px
2: "0.5rem", // 8px
2.5: "0.625rem", // 10px
3: "0.75rem", // 12px
3.5: "0.875rem", // 14px
4: "1rem", // 16px
5: "1.25rem", // 20px
6: "1.5rem", // 24px
7: "1.75rem", // 28px
7.5: "1.875rem", // 30px
8: "2rem", // 32px
8.5: "2.125rem", // 34px
9: "2.25rem", // 36px
10: "2.5rem", // 40px
11: "2.75rem", // 44px
12: "3rem", // 48px
14: "3.5rem", // 56px
16: "4rem", // 64px
18: "4.5rem", // 72px
20: "5rem", // 80px
24: "6rem", // 96px
28: "7rem", // 112px
32: "8rem", // 128px
36: "9rem", // 144px
40: "10rem", // 160px
44: "11rem", // 176px
48: "12rem", // 192px
52: "13rem", // 208px
56: "14rem", // 224px
60: "15rem", // 240px
64: "16rem", // 256px
68: "17rem", // 272px
70: "17.5rem", // 280px
71: "17.75rem", // 284px
72: "18rem", // 288px
76: "19rem", // 304px
80: "20rem", // 320px
96: "24rem", // 384px
},
borderRadius: {
// Design system border radius tokens from Figma
xs: "0.25rem", // 4px
s: "0.5rem", // 8px
m: "0.75rem", // 12px
l: "1rem", // 16px
xl: "1.25rem", // 20px
"2xl": "1.5rem", // 24px
xsmall: "0.25rem", // 4px
small: "0.5rem", // 8px
medium: "0.75rem", // 12px
large: "1rem", // 16px
xlarge: "1.25rem", // 20px
"2xlarge": "1.5rem", // 24px
full: "9999px", // For pill buttons
// Legacy values - kept for backward compatibility