mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-22 13:38:10 -05:00
Compare commits
2 Commits
fix/chat-l
...
testing-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa75c8da4 | ||
|
|
919cc877ad |
38
.github/workflows/platform-frontend-ci.yml
vendored
38
.github/workflows/platform-frontend-ci.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
exitOnceUploaded: true
|
||||
|
||||
e2e_test:
|
||||
test:
|
||||
runs-on: big-boi
|
||||
needs: setup
|
||||
strategy:
|
||||
@@ -258,39 +258,3 @@ jobs:
|
||||
- name: Print Final Docker Compose logs
|
||||
if: always()
|
||||
run: docker compose -f ../docker-compose.yml logs
|
||||
|
||||
integration_test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Restore dependencies cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ needs.setup.outputs.cache-key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate API client
|
||||
run: pnpm generate:api
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: pnpm test:unit
|
||||
|
||||
@@ -1,37 +1,12 @@
|
||||
-- CreateExtension
|
||||
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
|
||||
-- Ensures vector extension is in the current schema (from DATABASE_URL ?schema= param)
|
||||
-- If it exists in a different schema (e.g., public), we drop and recreate it in the current schema
|
||||
-- Creates extension in current schema (determined by search_path from DATABASE_URL ?schema= param)
|
||||
-- This ensures vector type is in the same schema as tables, making ::vector work without explicit qualification
|
||||
DO $$
|
||||
DECLARE
|
||||
current_schema_name text;
|
||||
vector_schema text;
|
||||
BEGIN
|
||||
-- Get the current schema from search_path
|
||||
SELECT current_schema() INTO current_schema_name;
|
||||
|
||||
-- Check if vector extension exists and which schema it's in
|
||||
SELECT n.nspname INTO vector_schema
|
||||
FROM pg_extension e
|
||||
JOIN pg_namespace n ON e.extnamespace = n.oid
|
||||
WHERE e.extname = 'vector';
|
||||
|
||||
-- Handle removal if in wrong schema
|
||||
IF vector_schema IS NOT NULL AND vector_schema != current_schema_name THEN
|
||||
BEGIN
|
||||
-- Vector exists in a different schema, drop it first
|
||||
RAISE WARNING 'pgvector found in schema "%" but need it in "%". Dropping and reinstalling...',
|
||||
vector_schema, current_schema_name;
|
||||
EXECUTE 'DROP EXTENSION IF EXISTS vector CASCADE';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE EXCEPTION 'Failed to drop pgvector from schema "%": %. You may need to drop it manually.',
|
||||
vector_schema, SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
|
||||
-- Create extension in current schema (let it fail naturally if not available)
|
||||
EXECUTE format('CREATE EXTENSION IF NOT EXISTS vector SCHEMA %I', current_schema_name);
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'vector extension not available or already exists, skipping';
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Acknowledge Supabase-managed extensions to prevent drift warnings
|
||||
-- These extensions are pre-installed by Supabase in specific schemas
|
||||
-- This migration ensures they exist where available (Supabase) or skips gracefully (CI)
|
||||
|
||||
-- Create schemas (safe in both CI and Supabase)
|
||||
CREATE SCHEMA IF NOT EXISTS "extensions";
|
||||
|
||||
-- Extensions that exist in both CI and Supabase
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pgcrypto extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'uuid-ossp extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
-- Supabase-specific extensions (skip gracefully in CI)
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pg_stat_statements extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pg_net extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pgjwt extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE SCHEMA IF NOT EXISTS "graphql";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pg_graphql extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE SCHEMA IF NOT EXISTS "pgsodium";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pgsodium extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE SCHEMA IF NOT EXISTS "vault";
|
||||
CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'supabase_vault extension not available, skipping';
|
||||
END $$;
|
||||
|
||||
|
||||
-- Return to platform
|
||||
CREATE SCHEMA IF NOT EXISTS "platform";
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
|
||||
interface NewChatContextValue {
|
||||
onNewChatClick: () => void;
|
||||
setOnNewChatClick: (handler?: () => void) => void;
|
||||
performNewChat?: () => void;
|
||||
setPerformNewChat: (handler?: () => void) => void;
|
||||
}
|
||||
|
||||
const NewChatContext = createContext<NewChatContextValue | null>(null);
|
||||
|
||||
export function NewChatProvider({ children }: { children: ReactNode }) {
|
||||
const onNewChatRef = useRef<(() => void) | undefined>();
|
||||
const performNewChatRef = useRef<(() => void) | undefined>();
|
||||
const contextValueRef = useRef<NewChatContextValue>({
|
||||
onNewChatClick() {
|
||||
onNewChatRef.current?.();
|
||||
},
|
||||
setOnNewChatClick(handler?: () => void) {
|
||||
onNewChatRef.current = handler;
|
||||
},
|
||||
performNewChat() {
|
||||
performNewChatRef.current?.();
|
||||
},
|
||||
setPerformNewChat(handler?: () => void) {
|
||||
performNewChatRef.current = handler;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<NewChatContext.Provider value={contextValueRef.current}>
|
||||
{children}
|
||||
</NewChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNewChat() {
|
||||
return useContext(NewChatContext);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useNewChat } from "../../NewChatContext";
|
||||
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
||||
import { LoadingState } from "./components/LoadingState/LoadingState";
|
||||
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||
@@ -35,25 +33,10 @@ export function CopilotShell({ children }: Props) {
|
||||
isReadyToShowContent,
|
||||
} = useCopilotShell();
|
||||
|
||||
const newChatContext = useNewChat();
|
||||
const handleNewChatClickWrapper =
|
||||
newChatContext?.onNewChatClick || handleNewChat;
|
||||
|
||||
useEffect(
|
||||
function registerNewChatHandler() {
|
||||
if (!newChatContext) return;
|
||||
newChatContext.setPerformNewChat(handleNewChat);
|
||||
return function cleanup() {
|
||||
newChatContext.setPerformNewChat(undefined);
|
||||
};
|
||||
},
|
||||
[newChatContext, handleNewChat],
|
||||
);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ChatLoader />
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +55,7 @@ export function CopilotShell({ children }: Props) {
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChatClickWrapper}
|
||||
onNewChat={handleNewChat}
|
||||
hasActiveSession={Boolean(hasActiveSession)}
|
||||
/>
|
||||
)}
|
||||
@@ -94,7 +77,7 @@ export function CopilotShell({ children }: Props) {
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChatClickWrapper}
|
||||
onNewChat={handleNewChat}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
hasActiveSession={Boolean(hasActiveSession)}
|
||||
|
||||
@@ -115,13 +115,13 @@ export function useCopilotShell() {
|
||||
const isReadyToShowContent = isOnHomepage
|
||||
? true
|
||||
: checkReadyToShowContent(
|
||||
areAllSessionsLoaded,
|
||||
paramSessionId,
|
||||
accumulatedSessions,
|
||||
isCurrentSessionLoading,
|
||||
currentSessionData,
|
||||
hasAutoSelectedSession,
|
||||
);
|
||||
areAllSessionsLoaded,
|
||||
paramSessionId,
|
||||
accumulatedSessions,
|
||||
isCurrentSessionLoading,
|
||||
currentSessionData,
|
||||
hasAutoSelectedSession,
|
||||
);
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
// Navigate using replaceState to avoid full page reload
|
||||
@@ -148,15 +148,13 @@ export function useCopilotShell() {
|
||||
setHasAutoSelectedSession(false);
|
||||
}
|
||||
|
||||
const isLoading = isSessionsLoading && accumulatedSessions.length === 0;
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDrawerOpen,
|
||||
isLoggedIn,
|
||||
hasActiveSession:
|
||||
Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
|
||||
isLoading,
|
||||
isLoading: isSessionsLoading || !areAllSessionsLoaded,
|
||||
sessions: visibleSessions,
|
||||
currentSessionId: sidebarSelectedSessionId,
|
||||
handleSelectSession,
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
|
||||
export type PageState =
|
||||
| { type: "welcome" }
|
||||
| { type: "newChat" }
|
||||
| { type: "creating"; prompt: string }
|
||||
| { type: "chat"; sessionId: string; initialPrompt?: string };
|
||||
|
||||
export function getInitialPromptFromState(
|
||||
pageState: PageState,
|
||||
storedInitialPrompt: string | undefined,
|
||||
) {
|
||||
if (storedInitialPrompt) return storedInitialPrompt;
|
||||
if (pageState.type === "creating") return pageState.prompt;
|
||||
if (pageState.type === "chat") return pageState.initialPrompt;
|
||||
}
|
||||
|
||||
export function shouldResetToWelcome(pageState: PageState) {
|
||||
return (
|
||||
pageState.type !== "newChat" &&
|
||||
pageState.type !== "creating" &&
|
||||
pageState.type !== "welcome"
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreetingName(user?: User | null): string {
|
||||
if (!user) return "there";
|
||||
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NewChatProvider } from "./NewChatContext";
|
||||
import { CopilotShell } from "./components/CopilotShell/CopilotShell";
|
||||
|
||||
export default function CopilotLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NewChatProvider>
|
||||
<CopilotShell>{children}</CopilotShell>
|
||||
</NewChatProvider>
|
||||
);
|
||||
return <CopilotShell>{children}</CopilotShell>;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useCopilotPage } from "./useCopilotPage";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import {
|
||||
Flag,
|
||||
type FlagValues,
|
||||
useGetFlag,
|
||||
} from "@/services/feature-flags/use-get-flag";
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getGreetingName, getQuickActions } from "./helpers";
|
||||
|
||||
type PageState =
|
||||
| { type: "welcome" }
|
||||
| { type: "creating"; prompt: string }
|
||||
| { type: "chat"; sessionId: string; initialPrompt?: string };
|
||||
|
||||
export default function CopilotPage() {
|
||||
const { state, handlers } = useCopilotPage();
|
||||
const {
|
||||
greetingName,
|
||||
quickActions,
|
||||
isLoading,
|
||||
pageState,
|
||||
isNewChatModalOpen,
|
||||
isReady,
|
||||
} = state;
|
||||
const {
|
||||
handleQuickAction,
|
||||
startChatWithPrompt,
|
||||
handleSessionNotFound,
|
||||
handleStreamingChange,
|
||||
handleCancelNewChat,
|
||||
proceedWithNewChat,
|
||||
handleNewChatModalOpen,
|
||||
} = handlers;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||
|
||||
if (!isReady) {
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||
const isFlagReady =
|
||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>({ type: "welcome" });
|
||||
const initialPromptRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const urlSessionId = searchParams.get("sessionId");
|
||||
|
||||
// Sync with URL sessionId (preserve initialPrompt from ref)
|
||||
useEffect(
|
||||
function syncSessionFromUrl() {
|
||||
if (urlSessionId) {
|
||||
// If we're already in chat state with this sessionId, don't overwrite
|
||||
if (pageState.type === "chat" && pageState.sessionId === urlSessionId) {
|
||||
return;
|
||||
}
|
||||
// Get initialPrompt from ref or current state
|
||||
const storedInitialPrompt = initialPromptRef.current.get(urlSessionId);
|
||||
const currentInitialPrompt =
|
||||
storedInitialPrompt ||
|
||||
(pageState.type === "creating"
|
||||
? pageState.prompt
|
||||
: pageState.type === "chat"
|
||||
? pageState.initialPrompt
|
||||
: undefined);
|
||||
if (currentInitialPrompt) {
|
||||
initialPromptRef.current.set(urlSessionId, currentInitialPrompt);
|
||||
}
|
||||
setPageState({
|
||||
type: "chat",
|
||||
sessionId: urlSessionId,
|
||||
initialPrompt: currentInitialPrompt,
|
||||
});
|
||||
} else if (pageState.type === "chat") {
|
||||
setPageState({ type: "welcome" });
|
||||
}
|
||||
},
|
||||
[urlSessionId],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function ensureAccess() {
|
||||
if (!isFlagReady) return;
|
||||
if (isChatEnabled === false) {
|
||||
router.replace(homepageRoute);
|
||||
}
|
||||
},
|
||||
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||
);
|
||||
|
||||
const greetingName = useMemo(
|
||||
function getName() {
|
||||
return getGreetingName(user);
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const quickActions = useMemo(function getActions() {
|
||||
return getQuickActions();
|
||||
}, []);
|
||||
|
||||
async function startChatWithPrompt(prompt: string) {
|
||||
if (!prompt?.trim()) return;
|
||||
if (pageState.type === "creating") return;
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
setPageState({ type: "creating", prompt: trimmedPrompt });
|
||||
|
||||
try {
|
||||
// Create session
|
||||
const sessionResponse = await postV2CreateSession({
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
|
||||
const sessionId = sessionResponse.data.id;
|
||||
|
||||
// Store initialPrompt in ref so it persists across re-renders
|
||||
initialPromptRef.current.set(sessionId, trimmedPrompt);
|
||||
|
||||
// Update URL and show Chat with initial prompt
|
||||
// Chat will handle sending the message and streaming
|
||||
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
|
||||
setPageState({ type: "chat", sessionId, initialPrompt: trimmedPrompt });
|
||||
} catch (error) {
|
||||
console.error("[CopilotPage] Failed to start chat:", error);
|
||||
setPageState({ type: "welcome" });
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
startChatWithPrompt(action);
|
||||
}
|
||||
|
||||
function handleSessionNotFound() {
|
||||
router.replace("/copilot");
|
||||
}
|
||||
|
||||
if (!isFlagReady || isChatEnabled === false || !isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,55 +150,7 @@ export default function CopilotPage() {
|
||||
urlSessionId={pageState.sessionId}
|
||||
initialPrompt={pageState.initialPrompt}
|
||||
onSessionNotFound={handleSessionNotFound}
|
||||
onStreamingChange={handleStreamingChange}
|
||||
/>
|
||||
<Dialog
|
||||
title="Interrupt current chat?"
|
||||
styling={{ maxWidth: 300, width: "100%" }}
|
||||
controlled={{
|
||||
isOpen: isNewChatModalOpen,
|
||||
set: handleNewChatModalOpen,
|
||||
}}
|
||||
onClose={handleCancelNewChat}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text variant="body">
|
||||
The current chat response will be interrupted. Are you sure you
|
||||
want to start a new chat?
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelNewChat}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={proceedWithNewChat}
|
||||
>
|
||||
Start new chat
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageState.type === "newChat") {
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,18 +158,18 @@ export default function CopilotPage() {
|
||||
// Show loading state while creating session and sending first message
|
||||
if (pageState.type === "creating") {
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9] px-6 py-10">
|
||||
<LoadingSpinner size="large" />
|
||||
<Text variant="body" className="mt-4 text-zinc-500">
|
||||
Starting your chat...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show Welcome screen
|
||||
const isLoading = isUserLoading;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
||||
<div className="w-full text-center">
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import {
|
||||
Flag,
|
||||
type FlagValues,
|
||||
useGetFlag,
|
||||
} from "@/services/feature-flags/use-get-flag";
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { useNewChat } from "./NewChatContext";
|
||||
import {
|
||||
getGreetingName,
|
||||
getQuickActions,
|
||||
type PageState,
|
||||
} from "./helpers";
|
||||
import { useCopilotURLState } from "./useCopilotURLState";
|
||||
|
||||
type CopilotState = {
|
||||
pageState: PageState;
|
||||
isStreaming: boolean;
|
||||
isNewChatModalOpen: boolean;
|
||||
initialPrompts: Record<string, string>;
|
||||
previousSessionId: string | null;
|
||||
};
|
||||
|
||||
type CopilotAction =
|
||||
| { type: "setPageState"; pageState: PageState }
|
||||
| { type: "setStreaming"; isStreaming: boolean }
|
||||
| { type: "setNewChatModalOpen"; isOpen: boolean }
|
||||
| { type: "setInitialPrompt"; sessionId: string; prompt: string }
|
||||
| { type: "setPreviousSessionId"; sessionId: string | null };
|
||||
|
||||
function isSamePageState(next: PageState, current: PageState) {
|
||||
if (next.type !== current.type) return false;
|
||||
if (next.type === "creating" && current.type === "creating") {
|
||||
return next.prompt === current.prompt;
|
||||
}
|
||||
if (next.type === "chat" && current.type === "chat") {
|
||||
return (
|
||||
next.sessionId === current.sessionId &&
|
||||
next.initialPrompt === current.initialPrompt
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function copilotReducer(state: CopilotState, action: CopilotAction): CopilotState {
|
||||
if (action.type === "setPageState") {
|
||||
if (isSamePageState(action.pageState, state.pageState)) return state;
|
||||
return { ...state, pageState: action.pageState };
|
||||
}
|
||||
if (action.type === "setStreaming") {
|
||||
if (action.isStreaming === state.isStreaming) return state;
|
||||
return { ...state, isStreaming: action.isStreaming };
|
||||
}
|
||||
if (action.type === "setNewChatModalOpen") {
|
||||
if (action.isOpen === state.isNewChatModalOpen) return state;
|
||||
return { ...state, isNewChatModalOpen: action.isOpen };
|
||||
}
|
||||
if (action.type === "setInitialPrompt") {
|
||||
if (state.initialPrompts[action.sessionId] === action.prompt) return state;
|
||||
return {
|
||||
...state,
|
||||
initialPrompts: {
|
||||
...state.initialPrompts,
|
||||
[action.sessionId]: action.prompt,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === "setPreviousSessionId") {
|
||||
if (state.previousSessionId === action.sessionId) return state;
|
||||
return { ...state, previousSessionId: action.sessionId };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useCopilotPage() {
|
||||
const router = useRouter();
|
||||
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||
const isFlagReady =
|
||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||
|
||||
const [state, dispatch] = useReducer(copilotReducer, {
|
||||
pageState: { type: "welcome" },
|
||||
isStreaming: false,
|
||||
isNewChatModalOpen: false,
|
||||
initialPrompts: {},
|
||||
previousSessionId: null,
|
||||
});
|
||||
|
||||
const newChatContext = useNewChat();
|
||||
const greetingName = getGreetingName(user);
|
||||
const quickActions = getQuickActions();
|
||||
|
||||
function setPageState(pageState: PageState) {
|
||||
dispatch({ type: "setPageState", pageState });
|
||||
}
|
||||
|
||||
function setInitialPrompt(sessionId: string, prompt: string) {
|
||||
dispatch({ type: "setInitialPrompt", sessionId, prompt });
|
||||
}
|
||||
|
||||
function setPreviousSessionId(sessionId: string | null) {
|
||||
dispatch({ type: "setPreviousSessionId", sessionId });
|
||||
}
|
||||
|
||||
const { setUrlSessionId } = useCopilotURLState({
|
||||
pageState: state.pageState,
|
||||
initialPrompts: state.initialPrompts,
|
||||
previousSessionId: state.previousSessionId,
|
||||
setPageState,
|
||||
setInitialPrompt,
|
||||
setPreviousSessionId,
|
||||
});
|
||||
|
||||
useEffect(
|
||||
function registerNewChatHandler() {
|
||||
if (!newChatContext) return;
|
||||
newChatContext.setOnNewChatClick(handleNewChatClick);
|
||||
return function cleanup() {
|
||||
newChatContext.setOnNewChatClick(undefined);
|
||||
};
|
||||
},
|
||||
[newChatContext, handleNewChatClick],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function transitionNewChatToWelcome() {
|
||||
if (state.pageState.type === "newChat") {
|
||||
function setWelcomeState() {
|
||||
dispatch({ type: "setPageState", pageState: { type: "welcome" } });
|
||||
}
|
||||
|
||||
const timer = setTimeout(setWelcomeState, 300);
|
||||
|
||||
return function cleanup() {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
},
|
||||
[state.pageState.type],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function ensureAccess() {
|
||||
if (!isFlagReady) return;
|
||||
if (isChatEnabled === false) {
|
||||
router.replace(homepageRoute);
|
||||
}
|
||||
},
|
||||
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||
);
|
||||
|
||||
async function startChatWithPrompt(prompt: string) {
|
||||
if (!prompt?.trim()) return;
|
||||
if (state.pageState.type === "creating") return;
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
dispatch({
|
||||
type: "setPageState",
|
||||
pageState: { type: "creating", prompt: trimmedPrompt },
|
||||
});
|
||||
|
||||
try {
|
||||
const sessionResponse = await postV2CreateSession({
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
|
||||
const sessionId = sessionResponse.data.id;
|
||||
|
||||
dispatch({
|
||||
type: "setInitialPrompt",
|
||||
sessionId,
|
||||
prompt: trimmedPrompt,
|
||||
});
|
||||
|
||||
await setUrlSessionId(sessionId, { shallow: false });
|
||||
dispatch({
|
||||
type: "setPageState",
|
||||
pageState: { type: "chat", sessionId, initialPrompt: trimmedPrompt },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CopilotPage] Failed to start chat:", error);
|
||||
dispatch({ type: "setPageState", pageState: { type: "welcome" } });
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
startChatWithPrompt(action);
|
||||
}
|
||||
|
||||
function handleSessionNotFound() {
|
||||
router.replace("/copilot");
|
||||
}
|
||||
|
||||
function handleStreamingChange(isStreamingValue: boolean) {
|
||||
dispatch({ type: "setStreaming", isStreaming: isStreamingValue });
|
||||
}
|
||||
|
||||
function proceedWithNewChat() {
|
||||
dispatch({ type: "setNewChatModalOpen", isOpen: false });
|
||||
if (newChatContext?.performNewChat) {
|
||||
newChatContext.performNewChat();
|
||||
return;
|
||||
}
|
||||
setUrlSessionId(null, { shallow: false });
|
||||
router.replace("/copilot");
|
||||
}
|
||||
|
||||
function handleCancelNewChat() {
|
||||
dispatch({ type: "setNewChatModalOpen", isOpen: false });
|
||||
}
|
||||
|
||||
function handleNewChatModalOpen(isOpen: boolean) {
|
||||
dispatch({ type: "setNewChatModalOpen", isOpen });
|
||||
}
|
||||
|
||||
function handleNewChatClick() {
|
||||
if (state.isStreaming) {
|
||||
dispatch({ type: "setNewChatModalOpen", isOpen: true });
|
||||
} else {
|
||||
proceedWithNewChat();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
greetingName,
|
||||
quickActions,
|
||||
isLoading: isUserLoading,
|
||||
pageState: state.pageState,
|
||||
isNewChatModalOpen: state.isNewChatModalOpen,
|
||||
isReady: isFlagReady && isChatEnabled !== false && isLoggedIn,
|
||||
},
|
||||
handlers: {
|
||||
handleQuickAction,
|
||||
startChatWithPrompt,
|
||||
handleSessionNotFound,
|
||||
handleStreamingChange,
|
||||
handleCancelNewChat,
|
||||
proceedWithNewChat,
|
||||
handleNewChatModalOpen,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useLayoutEffect } from "react";
|
||||
import {
|
||||
getInitialPromptFromState,
|
||||
type PageState,
|
||||
shouldResetToWelcome,
|
||||
} from "./helpers";
|
||||
|
||||
interface UseCopilotUrlStateArgs {
|
||||
pageState: PageState;
|
||||
initialPrompts: Record<string, string>;
|
||||
previousSessionId: string | null;
|
||||
setPageState: (pageState: PageState) => void;
|
||||
setInitialPrompt: (sessionId: string, prompt: string) => void;
|
||||
setPreviousSessionId: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function useCopilotURLState({
|
||||
pageState,
|
||||
initialPrompts,
|
||||
previousSessionId,
|
||||
setPageState,
|
||||
setInitialPrompt,
|
||||
setPreviousSessionId,
|
||||
}: UseCopilotUrlStateArgs) {
|
||||
const [urlSessionId, setUrlSessionId] = useQueryState(
|
||||
"sessionId",
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
function syncSessionFromUrl() {
|
||||
if (urlSessionId) {
|
||||
if (pageState.type === "chat" && pageState.sessionId === urlSessionId) {
|
||||
setPreviousSessionId(urlSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedInitialPrompt = initialPrompts[urlSessionId];
|
||||
const currentInitialPrompt = getInitialPromptFromState(
|
||||
pageState,
|
||||
storedInitialPrompt,
|
||||
);
|
||||
|
||||
if (currentInitialPrompt) {
|
||||
setInitialPrompt(urlSessionId, currentInitialPrompt);
|
||||
}
|
||||
|
||||
setPageState({
|
||||
type: "chat",
|
||||
sessionId: urlSessionId,
|
||||
initialPrompt: currentInitialPrompt,
|
||||
});
|
||||
setPreviousSessionId(urlSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasInChat = previousSessionId !== null && pageState.type === "chat";
|
||||
setPreviousSessionId(null);
|
||||
if (wasInChat) {
|
||||
setPageState({ type: "newChat" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldResetToWelcome(pageState)) {
|
||||
setPageState({ type: "welcome" });
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(syncSessionFromUrl, [
|
||||
urlSessionId,
|
||||
pageState.type,
|
||||
previousSessionId,
|
||||
initialPrompts,
|
||||
]);
|
||||
|
||||
return {
|
||||
urlSessionId,
|
||||
setUrlSessionId,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/__legacy__/ui/carousel";
|
||||
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
|
||||
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
|
||||
import { useAgentsSection } from "./useAgentsSection";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
import { StoreCard } from "../StoreCard/StoreCard";
|
||||
@@ -41,12 +43,14 @@ export const AgentsSection = ({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-full max-w-[1360px]">
|
||||
<h2
|
||||
style={{ marginBottom: margin }}
|
||||
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
|
||||
>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
<FadeIn direction="left" duration={0.5}>
|
||||
<h2
|
||||
style={{ marginBottom: margin }}
|
||||
className="font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
</FadeIn>
|
||||
{!displayedAgents || displayedAgents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
No agents found
|
||||
@@ -54,32 +58,38 @@ export const AgentsSection = ({
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Carousel View */}
|
||||
<Carousel
|
||||
className="md:hidden"
|
||||
opts={{
|
||||
loop: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{displayedAgents.map((agent, index) => (
|
||||
<CarouselItem key={index} className="min-w-64 max-w-71">
|
||||
<StoreCard
|
||||
agentName={agent.agent_name}
|
||||
agentImage={agent.agent_image}
|
||||
description={agent.description}
|
||||
runs={agent.runs}
|
||||
rating={agent.rating}
|
||||
avatarSrc={agent.creator_avatar}
|
||||
creatorName={agent.creator}
|
||||
hideAvatar={hideAvatars}
|
||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<FadeIn direction="up" className="md:hidden">
|
||||
<Carousel
|
||||
opts={{
|
||||
loop: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{displayedAgents.map((agent, index) => (
|
||||
<CarouselItem key={index} className="min-w-64 max-w-71">
|
||||
<StoreCard
|
||||
agentName={agent.agent_name}
|
||||
agentImage={agent.agent_image}
|
||||
description={agent.description}
|
||||
runs={agent.runs}
|
||||
rating={agent.rating}
|
||||
avatarSrc={agent.creator_avatar}
|
||||
creatorName={agent.creator}
|
||||
hideAvatar={hideAvatars}
|
||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</FadeIn>
|
||||
|
||||
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{/* Desktop Grid View with Staggered Animation */}
|
||||
<StaggeredList
|
||||
direction="up"
|
||||
staggerDelay={0.08}
|
||||
className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
|
||||
>
|
||||
{displayedAgents.map((agent, index) => (
|
||||
<StoreCard
|
||||
key={index}
|
||||
@@ -94,7 +104,7 @@ export const AgentsSection = ({
|
||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StaggeredList>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function BecomeACreator({
|
||||
|
||||
<PublishAgentModal
|
||||
trigger={
|
||||
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
|
||||
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:focus-visible:ring-neutral-50 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
|
||||
<span className="whitespace-nowrap font-poppins text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
|
||||
{buttonText}
|
||||
</span>
|
||||
|
||||
@@ -20,9 +20,18 @@ export const CreatorCard = ({
|
||||
}: CreatorCardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
|
||||
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-[filter] duration-200 hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
data-testid="creator-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View ${creatorName}'s profile - ${agentsUploaded} agents`}
|
||||
>
|
||||
<div className="relative h-[64px] w-[64px]">
|
||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
|
||||
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
|
||||
import { CreatorCard } from "../CreatorCard/CreatorCard";
|
||||
import { useFeaturedCreators } from "./useFeaturedCreators";
|
||||
import { Creator } from "@/app/api/__generated__/models/creator";
|
||||
@@ -19,11 +21,17 @@ export const FeaturedCreators = ({
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<div className="w-full max-w-[1360px]">
|
||||
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{title}
|
||||
</h2>
|
||||
<FadeIn direction="left" duration={0.5}>
|
||||
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{title}
|
||||
</h2>
|
||||
</FadeIn>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StaggeredList
|
||||
direction="up"
|
||||
staggerDelay={0.1}
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
{displayedCreators.map((creator, index) => (
|
||||
<CreatorCard
|
||||
key={index}
|
||||
@@ -35,7 +43,7 @@ export const FeaturedCreators = ({
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StaggeredList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CarouselNext,
|
||||
CarouselIndicator,
|
||||
} from "@/components/__legacy__/ui/carousel";
|
||||
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
|
||||
import Link from "next/link";
|
||||
import { useFeaturedSection } from "./useFeaturedSection";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
@@ -25,40 +26,44 @@ export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
Featured agents
|
||||
</h2>
|
||||
<FadeIn direction="left" duration={0.5}>
|
||||
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
Featured agents
|
||||
</h2>
|
||||
</FadeIn>
|
||||
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "center",
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{featuredAgents.map((agent, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className="h-[480px] md:basis-1/2 lg:basis-1/3"
|
||||
>
|
||||
<Link
|
||||
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
|
||||
className="block h-full"
|
||||
<FadeIn direction="up" duration={0.6} delay={0.1}>
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "center",
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{featuredAgents.map((agent, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className="h-[480px] md:basis-1/2 lg:basis-1/3"
|
||||
>
|
||||
<FeaturedAgentCard
|
||||
agent={agent}
|
||||
backgroundColor={getBackgroundColor(index)}
|
||||
/>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<div className="relative mt-4">
|
||||
<CarouselIndicator />
|
||||
<CarouselPrevious afterClick={handlePrevSlide} />
|
||||
<CarouselNext afterClick={handleNextSlide} />
|
||||
</div>
|
||||
</Carousel>
|
||||
<Link
|
||||
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
|
||||
className="block h-full"
|
||||
>
|
||||
<FeaturedAgentCard
|
||||
agent={agent}
|
||||
backgroundColor={getBackgroundColor(index)}
|
||||
/>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<div className="relative mt-4">
|
||||
<CarouselIndicator />
|
||||
<CarouselPrevious afterClick={handlePrevSlide} />
|
||||
<CarouselNext afterClick={handleNextSlide} />
|
||||
</div>
|
||||
</Carousel>
|
||||
</FadeIn>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { FilterChip } from "@/components/atoms/FilterChip/FilterChip";
|
||||
import { useFilterChips } from "./useFilterChips";
|
||||
|
||||
interface FilterChipsProps {
|
||||
@@ -9,8 +9,6 @@ interface FilterChipsProps {
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
// Some flaws in its logic
|
||||
// FRONTEND-TODO : This needs to be fixed
|
||||
export const FilterChips = ({
|
||||
badges,
|
||||
onFilterChange,
|
||||
@@ -22,18 +20,20 @@ export const FilterChips = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5">
|
||||
<div
|
||||
className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5"
|
||||
role="group"
|
||||
aria-label="Filter options"
|
||||
>
|
||||
{badges.map((badge) => (
|
||||
<Badge
|
||||
<FilterChip
|
||||
key={badge}
|
||||
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
|
||||
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
|
||||
label={badge}
|
||||
selected={selectedFilters.includes(badge)}
|
||||
onClick={() => handleBadgeClick(badge)}
|
||||
>
|
||||
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
|
||||
{badge}
|
||||
</div>
|
||||
</Badge>
|
||||
size="lg"
|
||||
className="mb-2 lg:mb-3"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
|
||||
import { FilterChips } from "../FilterChips/FilterChips";
|
||||
import { SearchBar } from "../SearchBar/SearchBar";
|
||||
import { useHeroSection } from "./useHeroSection";
|
||||
@@ -9,30 +10,36 @@ export const HeroSection = () => {
|
||||
return (
|
||||
<div className="mb-2 mt-8 flex flex-col items-center justify-center px-4 sm:mb-4 sm:mt-12 sm:px-6 md:mb-6 md:mt-16 lg:my-24 lg:px-8 xl:my-16">
|
||||
<div className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl">
|
||||
<div className="mb-4 text-center md:mb-8">
|
||||
<h1 className="text-center">
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
Explore AI agents built for{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
|
||||
you
|
||||
</span>
|
||||
<br />
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
by the{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
|
||||
community
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
|
||||
Bringing you AI agents designed by thinkers from around the world
|
||||
</h3>
|
||||
<div className="mb-4 flex justify-center sm:mb-5">
|
||||
<SearchBar height="h-[74px]" />
|
||||
</div>
|
||||
<div>
|
||||
<FadeIn direction="down" duration={0.6} delay={0}>
|
||||
<div className="mb-4 text-center md:mb-8">
|
||||
<h1 className="text-center">
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
Explore AI agents built for{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
|
||||
you
|
||||
</span>
|
||||
<br />
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
by the{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
|
||||
community
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" duration={0.6} delay={0.15}>
|
||||
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
|
||||
Bringing you AI agents designed by thinkers from around the world
|
||||
</h3>
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" duration={0.5} delay={0.3}>
|
||||
<div className="mb-4 flex justify-center sm:mb-5">
|
||||
<SearchBar height="h-[74px]" />
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" duration={0.5} delay={0.4}>
|
||||
<div className="flex justify-center">
|
||||
<FilterChips
|
||||
badges={searchTerms}
|
||||
@@ -40,7 +47,7 @@ export const HeroSection = () => {
|
||||
multiSelect={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Separator } from "@/components/atoms/Separator/Separator";
|
||||
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
|
||||
import { FeaturedSection } from "../FeaturedSection/FeaturedSection";
|
||||
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
|
||||
import { HeroSection } from "../HeroSection/HeroSection";
|
||||
@@ -54,11 +55,13 @@ export const MainMarkeplacePage = () => {
|
||||
<FeaturedCreators featuredCreators={featuredCreators.creators} />
|
||||
)}
|
||||
<Separator className="mb-[25px] mt-[60px]" />
|
||||
<BecomeACreator
|
||||
title="Become a Creator"
|
||||
description="Join our ever-growing community of hackers and tinkerers"
|
||||
buttonText="Become a Creator"
|
||||
/>
|
||||
<FadeIn direction="up" duration={0.6}>
|
||||
<BecomeACreator
|
||||
title="Become a Creator"
|
||||
description="Join our ever-growing community of hackers and tinkerers"
|
||||
buttonText="Become a Creator"
|
||||
/>
|
||||
</FadeIn>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { render, screen } from "@/tests/integrations/test-utils";
|
||||
import { MainMarkeplacePage } from "../MainMarketplacePage";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { getDeleteV2DeleteStoreSubmissionMockHandler422 } from "@/app/api/__generated__/endpoints/store/store.msw";
|
||||
|
||||
// Only for CI testing purpose, will remove it in future PR
|
||||
test("MainMarketplacePage", async () => {
|
||||
server.use(getDeleteV2DeleteStoreSubmissionMockHandler422());
|
||||
|
||||
render(<MainMarkeplacePage />);
|
||||
expect(
|
||||
await screen.findByText("Featured agents", { exact: false }),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -16,9 +16,9 @@ interface SearchBarProps {
|
||||
export const SearchBar = ({
|
||||
placeholder = 'Search for tasks like "optimise SEO"',
|
||||
backgroundColor = "bg-neutral-100 dark:bg-neutral-800",
|
||||
iconColor = "text-[#646464] dark:text-neutral-400",
|
||||
textColor = "text-[#707070] dark:text-neutral-200",
|
||||
placeholderColor = "text-[#707070] dark:text-neutral-400",
|
||||
iconColor = "text-neutral-500 dark:text-neutral-400",
|
||||
textColor = "text-neutral-500 dark:text-neutral-200",
|
||||
placeholderColor = "text-neutral-500 dark:text-neutral-400",
|
||||
width = "w-9/10 lg:w-[56.25rem]",
|
||||
height = "h-[60px]",
|
||||
}: SearchBarProps) => {
|
||||
@@ -32,10 +32,13 @@ export const SearchBar = ({
|
||||
>
|
||||
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
|
||||
<input
|
||||
type="text"
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search for AI agents"
|
||||
className={`flex-grow border-none bg-transparent ${textColor} font-sans text-lg font-normal leading-[2.25rem] tracking-tight md:text-xl placeholder:${placeholderColor} focus:outline-none`}
|
||||
data-testid="store-search-input"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import Image from "next/image";
|
||||
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
|
||||
import { Star } from "@phosphor-icons/react";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
const stars = [];
|
||||
const clampedRating = Math.max(0, Math.min(5, rating));
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<Star
|
||||
key={i}
|
||||
weight={i <= clampedRating ? "fill" : "regular"}
|
||||
className="h-4 w-4 text-neutral-900 dark:text-yellow-500"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return <>{stars}</>;
|
||||
}
|
||||
|
||||
interface StoreCardProps {
|
||||
agentName: string;
|
||||
agentImage: string;
|
||||
@@ -34,7 +49,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-all duration-300 hover:shadow-lg dark:hover:shadow-gray-700"
|
||||
className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-shadow duration-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:hover:shadow-gray-700 dark:focus-visible:ring-neutral-50"
|
||||
onClick={handleClick}
|
||||
data-testid="store-card"
|
||||
role="button"
|
||||
@@ -76,7 +91,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
<div className="mt-3 flex w-full flex-1 flex-col px-4">
|
||||
{/* Second Section: Agent Name and Creator Name */}
|
||||
<div className="flex w-full flex-col">
|
||||
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-[#272727] dark:text-neutral-100">
|
||||
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{agentName}
|
||||
</h3>
|
||||
{!hideAvatar && creatorName && (
|
||||
@@ -107,11 +122,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
<div
|
||||
className="inline-flex items-center"
|
||||
className="inline-flex items-center gap-0.5"
|
||||
role="img"
|
||||
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
|
||||
>
|
||||
{StarRatingIcons(rating)}
|
||||
<StarRating rating={rating} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { FilterChip } from "./FilterChip";
|
||||
|
||||
const meta: Meta<typeof FilterChip> = {
|
||||
title: "Atoms/FilterChip",
|
||||
component: FilterChip,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["sm", "md", "lg"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FilterChip>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: "Marketing",
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
label: "Marketing",
|
||||
selected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Dismissible: Story = {
|
||||
args: {
|
||||
label: "Marketing",
|
||||
selected: true,
|
||||
dismissible: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<FilterChip label="Small" size="sm" />
|
||||
<FilterChip label="Medium" size="md" />
|
||||
<FilterChip label="Large" size="lg" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: "Disabled",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
function FilterChipGroupDemo() {
|
||||
const filters = [
|
||||
"Marketing",
|
||||
"Sales",
|
||||
"Development",
|
||||
"Design",
|
||||
"Research",
|
||||
"Analytics",
|
||||
];
|
||||
const [selected, setSelected] = useState<string[]>(["Marketing"]);
|
||||
|
||||
function handleToggle(filter: string) {
|
||||
setSelected((prev) =>
|
||||
prev.includes(filter)
|
||||
? prev.filter((f) => f !== filter)
|
||||
: [...prev, filter],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{filters.map((filter) => (
|
||||
<FilterChip
|
||||
key={filter}
|
||||
label={filter}
|
||||
selected={selected.includes(filter)}
|
||||
onClick={() => handleToggle(filter)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FilterGroup: Story = {
|
||||
render: () => <FilterChipGroupDemo />,
|
||||
};
|
||||
|
||||
function SingleSelectDemo() {
|
||||
const filters = ["All", "Featured", "Popular", "New"];
|
||||
const [selected, setSelected] = useState("All");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{filters.map((filter) => (
|
||||
<FilterChip
|
||||
key={filter}
|
||||
label={filter}
|
||||
selected={selected === filter}
|
||||
onClick={() => setSelected(filter)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SingleSelect: Story = {
|
||||
render: () => <SingleSelectDemo />,
|
||||
};
|
||||
|
||||
function DismissibleDemo() {
|
||||
const [filters, setFilters] = useState([
|
||||
"Marketing",
|
||||
"Sales",
|
||||
"Development",
|
||||
]);
|
||||
|
||||
function handleDismiss(filter: string) {
|
||||
setFilters((prev) => prev.filter((f) => f !== filter));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{filters.map((filter) => (
|
||||
<FilterChip
|
||||
key={filter}
|
||||
label={filter}
|
||||
selected
|
||||
dismissible
|
||||
onDismiss={() => handleDismiss(filter)}
|
||||
/>
|
||||
))}
|
||||
{filters.length === 0 && (
|
||||
<span className="text-neutral-500">No filters selected</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DismissibleGroup: Story = {
|
||||
render: () => <DismissibleDemo />,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
|
||||
type FilterChipSize = "sm" | "md" | "lg";
|
||||
|
||||
interface FilterChipProps {
|
||||
/** The label text displayed in the chip */
|
||||
label: string;
|
||||
/** Whether the chip is currently selected */
|
||||
selected?: boolean;
|
||||
/** Callback when the chip is clicked */
|
||||
onClick?: () => void;
|
||||
/** Whether to show a dismiss/remove button */
|
||||
dismissible?: boolean;
|
||||
/** Callback when the dismiss button is clicked */
|
||||
onDismiss?: () => void;
|
||||
/** Size variant of the chip */
|
||||
size?: FilterChipSize;
|
||||
/** Whether the chip is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles: Record<FilterChipSize, string> = {
|
||||
sm: "px-3 py-1 text-sm gap-1.5",
|
||||
md: "px-4 py-1.5 text-base gap-2",
|
||||
lg: "px-6 py-2 text-lg gap-2.5 lg:text-xl lg:leading-9",
|
||||
};
|
||||
|
||||
const iconSizes: Record<FilterChipSize, string> = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* A filter chip component for selecting/deselecting filter options.
|
||||
* Supports single and multi-select patterns with proper accessibility.
|
||||
*/
|
||||
export function FilterChip({
|
||||
label,
|
||||
selected = false,
|
||||
onClick,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
size = "md",
|
||||
disabled = false,
|
||||
className,
|
||||
}: FilterChipProps) {
|
||||
function handleDismiss(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
onDismiss?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
// Base styles
|
||||
"inline-flex items-center justify-center rounded-full border font-medium transition-colors",
|
||||
// Focus styles
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50",
|
||||
// Size styles
|
||||
sizeStyles[size],
|
||||
// State styles
|
||||
selected
|
||||
? "border-neutral-900 bg-neutral-100 text-neutral-800 dark:border-neutral-100 dark:bg-neutral-800 dark:text-neutral-200"
|
||||
: "border-neutral-400 bg-transparent text-neutral-600 hover:bg-neutral-50 dark:border-neutral-500 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
||||
// Disabled styles
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{dismissible && selected && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDismiss}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleDismiss(e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
||||
aria-label={`Remove ${label} filter`}
|
||||
>
|
||||
<X className={iconSizes[size]} weight="bold" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Separator } from "./Separator";
|
||||
|
||||
const meta: Meta<typeof Separator> = {
|
||||
title: "Atoms/Separator",
|
||||
component: Separator,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Separator>;
|
||||
|
||||
export const Horizontal: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-md">
|
||||
<p className="mb-4 text-neutral-700 dark:text-neutral-300">
|
||||
Content above the separator
|
||||
</p>
|
||||
<Separator />
|
||||
<p className="mt-4 text-neutral-700 dark:text-neutral-300">
|
||||
Content below the separator
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Vertical: Story = {
|
||||
render: () => (
|
||||
<div className="flex h-16 items-center gap-4">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Left</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Right</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithCustomStyles: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<Separator className="bg-violet-500" />
|
||||
<Separator className="h-0.5 bg-gradient-to-r from-violet-500 to-blue-500" />
|
||||
<Separator className="bg-neutral-400 dark:bg-neutral-600" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InSection: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Featured Agents
|
||||
</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Browse our collection of featured AI agents.
|
||||
</p>
|
||||
</section>
|
||||
<Separator className="my-6" />
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Top Creators
|
||||
</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Meet the creators behind the most popular agents.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SeparatorOrientation = "horizontal" | "vertical";
|
||||
|
||||
interface SeparatorProps {
|
||||
/** The orientation of the separator */
|
||||
orientation?: SeparatorOrientation;
|
||||
/** Whether the separator is purely decorative (true) or represents a semantic boundary (false) */
|
||||
decorative?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A visual separator that divides content.
|
||||
* Uses semantic `<hr>` for horizontal separators and a styled `<div>` for vertical.
|
||||
*/
|
||||
export function Separator({
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
className,
|
||||
}: SeparatorProps) {
|
||||
const baseStyles = "shrink-0 bg-neutral-200 dark:bg-neutral-800";
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<hr
|
||||
className={cn(baseStyles, "h-px w-full border-0", className)}
|
||||
aria-hidden={decorative}
|
||||
role={decorative ? "none" : "separator"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(baseStyles, "h-full w-px", className)}
|
||||
aria-hidden={decorative}
|
||||
role={decorative ? "none" : "separator"}
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export interface ChatProps {
|
||||
urlSessionId?: string | null;
|
||||
initialPrompt?: string;
|
||||
onSessionNotFound?: () => void;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
@@ -21,7 +20,6 @@ export function Chat({
|
||||
urlSessionId,
|
||||
initialPrompt,
|
||||
onSessionNotFound,
|
||||
onStreamingChange,
|
||||
}: ChatProps) {
|
||||
const hasHandledNotFoundRef = useRef(false);
|
||||
const {
|
||||
@@ -75,7 +73,6 @@ export function Chat({
|
||||
initialMessages={messages}
|
||||
initialPrompt={initialPrompt}
|
||||
className="flex-1"
|
||||
onStreamingChange={onStreamingChange}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { ChatInput } from "../ChatInput/ChatInput";
|
||||
import { MessageList } from "../MessageList/MessageList";
|
||||
import { useChatContainer } from "./useChatContainer";
|
||||
@@ -14,7 +13,6 @@ export interface ChatContainerProps {
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
@@ -22,7 +20,6 @@ export function ChatContainer({
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
className,
|
||||
onStreamingChange,
|
||||
}: ChatContainerProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -39,10 +36,6 @@ export function ChatContainer({
|
||||
initialPrompt,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile =
|
||||
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
export function ChatLoader() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full bg-black animate-loader" />
|
||||
</div>
|
||||
<Text
|
||||
variant="small"
|
||||
className="bg-gradient-to-r from-neutral-600 via-neutral-500 to-neutral-600 bg-[length:200%_100%] bg-clip-text text-xs text-transparent [animation:shimmer_2s_ease-in-out_infinite]"
|
||||
>
|
||||
Taking a bit more time...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowsClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
@@ -339,26 +340,11 @@ export function ChatMessage({
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
className="p-1"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-green-600" />
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3 text-zinc-600"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
<CopyIcon className="size-4 text-zinc-600" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { ChatLoader } from "../ChatLoader/ChatLoader";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
@@ -8,9 +9,7 @@ export interface ThinkingMessageProps {
|
||||
|
||||
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
||||
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current === null) {
|
||||
@@ -19,21 +18,11 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
if (coffeeTimerRef.current === null) {
|
||||
coffeeTimerRef.current = setTimeout(() => {
|
||||
setShowCoffeeMessage(true);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (coffeeTimerRef.current) {
|
||||
clearTimeout(coffeeTimerRef.current);
|
||||
coffeeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -48,16 +37,16 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<AIChatBubble>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showCoffeeMessage ? (
|
||||
<span className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent animate-shimmer">
|
||||
This could take a few minutes, grab a coffee ☕️
|
||||
</span>
|
||||
) : showSlowLoader ? (
|
||||
<span className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent animate-shimmer">
|
||||
Taking a bit more time...
|
||||
</span>
|
||||
{showSlowLoader ? (
|
||||
<ChatLoader />
|
||||
) : (
|
||||
<span className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent animate-shimmer">
|
||||
<span
|
||||
className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-clip-text text-transparent"
|
||||
style={{
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 2s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
Thinking...
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FadeIn } from "./FadeIn";
|
||||
|
||||
const meta: Meta<typeof FadeIn> = {
|
||||
title: "Molecules/FadeIn",
|
||||
component: FadeIn,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
argTypes: {
|
||||
direction: {
|
||||
control: "select",
|
||||
options: ["up", "down", "left", "right", "none"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FadeIn>;
|
||||
|
||||
const DemoCard = ({ title }: { title: string }) => (
|
||||
<div className="rounded-xl bg-neutral-100 p-6 dark:bg-neutral-800">
|
||||
<h3 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
This card fades in with a smooth animation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
children: <DemoCard title="Fade Up" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeDown: Story = {
|
||||
args: {
|
||||
direction: "down",
|
||||
children: <DemoCard title="Fade Down" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeLeft: Story = {
|
||||
args: {
|
||||
direction: "left",
|
||||
children: <DemoCard title="Fade Left" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeRight: Story = {
|
||||
args: {
|
||||
direction: "right",
|
||||
children: <DemoCard title="Fade Right" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeOnly: Story = {
|
||||
args: {
|
||||
direction: "none",
|
||||
children: <DemoCard title="Fade Only (No Direction)" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDelay: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
delay: 0.5,
|
||||
children: <DemoCard title="Delayed Fade (0.5s)" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowAnimation: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
duration: 1.5,
|
||||
children: <DemoCard title="Slow Animation (1.5s)" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeDistance: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
distance: 60,
|
||||
children: <DemoCard title="Large Distance (60px)" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<FadeIn direction="up" delay={0}>
|
||||
<DemoCard title="First Card" />
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" delay={0.1}>
|
||||
<DemoCard title="Second Card" />
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" delay={0.2}>
|
||||
<DemoCard title="Third Card" />
|
||||
</FadeIn>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const HeroExample: Story = {
|
||||
render: () => (
|
||||
<div className="text-center">
|
||||
<FadeIn direction="down" delay={0}>
|
||||
<h1 className="mb-4 text-4xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
Welcome to the Marketplace
|
||||
</h1>
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" delay={0.2}>
|
||||
<p className="mb-8 text-xl text-neutral-600 dark:text-neutral-400">
|
||||
Discover AI agents built by the community
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn direction="up" delay={0.4}>
|
||||
<button className="rounded-full bg-violet-600 px-8 py-3 text-white hover:bg-violet-700">
|
||||
Get Started
|
||||
</button>
|
||||
</FadeIn>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type FadeDirection = "up" | "down" | "left" | "right" | "none";
|
||||
|
||||
interface FadeInProps {
|
||||
/** Content to animate */
|
||||
children: ReactNode;
|
||||
/** Direction the content fades in from */
|
||||
direction?: FadeDirection;
|
||||
/** Distance to travel in pixels (only applies when direction is not "none") */
|
||||
distance?: number;
|
||||
/** Animation duration in seconds */
|
||||
duration?: number;
|
||||
/** Delay before animation starts in seconds */
|
||||
delay?: number;
|
||||
/** Whether to trigger animation when element enters viewport */
|
||||
viewport?: boolean;
|
||||
/** How much of element must be visible to trigger (0-1) */
|
||||
viewportAmount?: number;
|
||||
/** Whether animation should only trigger once */
|
||||
once?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** HTML element to render as */
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
}
|
||||
|
||||
function getDirectionOffset(
|
||||
direction: FadeDirection,
|
||||
distance: number,
|
||||
): { x: number; y: number } {
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return { x: 0, y: distance };
|
||||
case "down":
|
||||
return { x: 0, y: -distance };
|
||||
case "left":
|
||||
return { x: distance, y: 0 };
|
||||
case "right":
|
||||
return { x: -distance, y: 0 };
|
||||
case "none":
|
||||
default:
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fade-in animation wrapper component.
|
||||
* Animates children with a fade effect and optional directional slide.
|
||||
* Respects user's reduced motion preferences.
|
||||
*/
|
||||
export function FadeIn({
|
||||
children,
|
||||
direction = "up",
|
||||
distance = 24,
|
||||
duration = 0.5,
|
||||
delay = 0,
|
||||
viewport = true,
|
||||
viewportAmount = 0.2,
|
||||
once = true,
|
||||
className,
|
||||
as = "div",
|
||||
}: FadeInProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const offset = getDirectionOffset(direction, distance);
|
||||
|
||||
// If user prefers reduced motion, render without animation
|
||||
if (shouldReduceMotion) {
|
||||
const Component = as as keyof JSX.IntrinsicElements;
|
||||
return <Component className={className}>{children}</Component>;
|
||||
}
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: offset.x,
|
||||
y: offset.y,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration,
|
||||
delay,
|
||||
ease: [0.25, 0.1, 0.25, 1], // Custom easing for smooth feel
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const MotionComponent = motion[as as keyof typeof motion] as typeof motion.div;
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
className={cn(className)}
|
||||
initial="hidden"
|
||||
animate={viewport ? undefined : "visible"}
|
||||
whileInView={viewport ? "visible" : undefined}
|
||||
viewport={viewport ? { once, amount: viewportAmount } : undefined}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { StaggeredList } from "./StaggeredList";
|
||||
|
||||
const meta: Meta<typeof StaggeredList> = {
|
||||
title: "Molecules/StaggeredList",
|
||||
component: StaggeredList,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
argTypes: {
|
||||
direction: {
|
||||
control: "select",
|
||||
options: ["up", "down", "left", "right", "none"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StaggeredList>;
|
||||
|
||||
const DemoCard = ({ title, index }: { title: string; index: number }) => (
|
||||
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Card #{index + 1} with staggered animation
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = ["First Item", "Second Item", "Third Item", "Fourth Item"];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
className: "space-y-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeDown: Story = {
|
||||
args: {
|
||||
direction: "down",
|
||||
className: "space-y-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeLeft: Story = {
|
||||
args: {
|
||||
direction: "left",
|
||||
className: "flex gap-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const FadeRight: Story = {
|
||||
args: {
|
||||
direction: "right",
|
||||
className: "flex gap-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const FastStagger: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
staggerDelay: 0.05,
|
||||
className: "space-y-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowStagger: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
staggerDelay: 0.3,
|
||||
className: "space-y-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInitialDelay: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
initialDelay: 0.5,
|
||||
className: "space-y-4",
|
||||
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const GridLayout: Story = {
|
||||
args: {
|
||||
direction: "up",
|
||||
staggerDelay: 0.08,
|
||||
className: "grid grid-cols-2 gap-4 md:grid-cols-4",
|
||||
children: [
|
||||
...items,
|
||||
"Fifth Item",
|
||||
"Sixth Item",
|
||||
"Seventh Item",
|
||||
"Eighth Item",
|
||||
].map((item, i) => <DemoCard key={i} title={item} index={i} />),
|
||||
},
|
||||
};
|
||||
|
||||
export const AgentCardsExample: Story = {
|
||||
render: () => {
|
||||
const agents = [
|
||||
{ name: "SEO Optimizer", runs: 1234 },
|
||||
{ name: "Content Writer", runs: 987 },
|
||||
{ name: "Data Analyzer", runs: 756 },
|
||||
{ name: "Code Reviewer", runs: 543 },
|
||||
];
|
||||
|
||||
return (
|
||||
<StaggeredList
|
||||
direction="up"
|
||||
staggerDelay={0.1}
|
||||
className="grid grid-cols-2 gap-6 md:grid-cols-4"
|
||||
>
|
||||
{agents.map((agent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl bg-white p-4 shadow-md dark:bg-neutral-900"
|
||||
>
|
||||
<div className="mb-3 aspect-video rounded-xl bg-gradient-to-br from-violet-500 to-blue-500" />
|
||||
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500">{agent.runs} runs</p>
|
||||
</div>
|
||||
))}
|
||||
</StaggeredList>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CreatorCardsExample: Story = {
|
||||
render: () => {
|
||||
const creators = [
|
||||
{ name: "Alice", agents: 12 },
|
||||
{ name: "Bob", agents: 8 },
|
||||
{ name: "Charlie", agents: 15 },
|
||||
{ name: "Diana", agents: 6 },
|
||||
];
|
||||
|
||||
const colors = [
|
||||
"bg-violet-100 dark:bg-violet-900/30",
|
||||
"bg-blue-100 dark:bg-blue-900/30",
|
||||
"bg-green-100 dark:bg-green-900/30",
|
||||
"bg-orange-100 dark:bg-orange-900/30",
|
||||
];
|
||||
|
||||
return (
|
||||
<StaggeredList
|
||||
direction="up"
|
||||
staggerDelay={0.12}
|
||||
className="grid grid-cols-2 gap-6 md:grid-cols-4"
|
||||
>
|
||||
{creators.map((creator, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-2xl p-5 ${colors[i % colors.length]}`}
|
||||
>
|
||||
<div className="mb-3 h-12 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{creator.name}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{creator.agents} agents
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</StaggeredList>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type StaggerDirection = "up" | "down" | "left" | "right" | "none";
|
||||
|
||||
interface StaggeredListProps {
|
||||
/** Array of items to render with staggered animation */
|
||||
children: ReactNode[];
|
||||
/** Direction items animate from */
|
||||
direction?: StaggerDirection;
|
||||
/** Distance to travel in pixels */
|
||||
distance?: number;
|
||||
/** Base duration for each item's animation */
|
||||
duration?: number;
|
||||
/** Delay between each item's animation start */
|
||||
staggerDelay?: number;
|
||||
/** Initial delay before first item animates */
|
||||
initialDelay?: number;
|
||||
/** Whether to trigger animation when element enters viewport */
|
||||
viewport?: boolean;
|
||||
/** How much of container must be visible to trigger */
|
||||
viewportAmount?: number;
|
||||
/** Whether animation should only trigger once */
|
||||
once?: boolean;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
/** Additional CSS classes for each item wrapper */
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
function getDirectionOffset(
|
||||
direction: StaggerDirection,
|
||||
distance: number,
|
||||
): { x: number; y: number } {
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return { x: 0, y: distance };
|
||||
case "down":
|
||||
return { x: 0, y: -distance };
|
||||
case "left":
|
||||
return { x: distance, y: 0 };
|
||||
case "right":
|
||||
return { x: -distance, y: 0 };
|
||||
case "none":
|
||||
default:
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a list of children with staggered fade-in effects.
|
||||
* Each child appears sequentially with a configurable delay.
|
||||
* Respects user's reduced motion preferences.
|
||||
*/
|
||||
export function StaggeredList({
|
||||
children,
|
||||
direction = "up",
|
||||
distance = 20,
|
||||
duration = 0.4,
|
||||
staggerDelay = 0.1,
|
||||
initialDelay = 0,
|
||||
viewport = true,
|
||||
viewportAmount = 0.1,
|
||||
once = true,
|
||||
className,
|
||||
itemClassName,
|
||||
}: StaggeredListProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const offset = getDirectionOffset(direction, distance);
|
||||
|
||||
// If user prefers reduced motion, render without animation
|
||||
if (shouldReduceMotion) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{children.map((child, index) => (
|
||||
<div key={index} className={itemClassName}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: initialDelay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: offset.x,
|
||||
y: offset.y,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(className)}
|
||||
initial="hidden"
|
||||
animate={viewport ? undefined : "visible"}
|
||||
whileInView={viewport ? "visible" : undefined}
|
||||
viewport={viewport ? { once, amount: viewportAmount } : undefined}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{children.map((child, index) => (
|
||||
<motion.div key={index} className={itemClassName} variants={itemVariants}>
|
||||
{child}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -157,21 +157,12 @@ const config = {
|
||||
backgroundPosition: "-200% 0",
|
||||
},
|
||||
},
|
||||
loader: {
|
||||
"0%": {
|
||||
boxShadow: "0 0 0 0 rgba(0, 0, 0, 0.25)",
|
||||
},
|
||||
"100%": {
|
||||
boxShadow: "0 0 0 30px rgba(0, 0, 0, 0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"fade-in": "fade-in 0.2s ease-out",
|
||||
shimmer: "shimmer 2s ease-in-out infinite",
|
||||
loader: "loader 1s infinite",
|
||||
},
|
||||
transitionDuration: {
|
||||
"2000": "2000ms",
|
||||
|
||||
Reference in New Issue
Block a user