feat: add observable UX for community interaction tracking (#7512)

*  (model.py): add UserOptin class to manage user opt-in actions for better organization and extensibility
♻️ (model.py): refactor User model to include user_optin field as a dictionary to store opt-in actions for users

* [autofix.ci] apply automated fixes

* change name optins

* [autofix.ci] apply automated fixes

*  (add_optins_column_to_user.py): Add optins column to the user table to store user preferences
♻️ (model.py): Refactor UserOptin class to BaseModel for better type hinting and add optins field to User model with default values and proper typing

* [autofix.ci] apply automated fixes

* 🐛 (add_optins_column_to_user.py): fix an issue where the optins column was not being added if it already existed in the user table

*  (empty-page.tsx): Add new page EmptyPageCommunity to display community information and actions
 (main-page.tsx): Add new page CollectionPage to manage collections and folders
🔧 (routes.tsx): Update import path for CollectionPage to point to the new main-page file

*  (background-gradient.tsx): Add a new component BackgroundGradient to create a visually appealing background gradient effect for UI elements
📝 (empty-page.tsx): Refactor EmptyPageCommunity component to use the newly added BackgroundGradient component for GitHub and Discord sections to enhance visual appeal and consistency

* 📝 (tasks.mdc): Add concise task management protocol for sequential mode to improve task organization and execution
 (frontend): Introduce DotBackgroundDemo component for creating a visually appealing dot background effect
♻️ (frontend): Refactor BackgroundGradient component to improve gradient styling and border consistency
🔧 (frontend): Update motion import in background-gradient.tsx to use framer-motion instead of motion/react
🔧 (icons): Add missing newline at the end of Anthropic icon file
🔧 (empty-page.tsx): Adjust styling classes and z-index to improve layout and visual hierarchy in EmptyPageCommunity component

*  (empty-page.tsx): add githubBg image import to use as background image for GitHub link
♻️ (empty-page.tsx): refactor positioning and styling of GitHub link elements for better alignment and readability

* 🔧 refactor(server.ts): change port variable case from lowercase port to uppercase PORT to improve semantics
 refactor(server.ts): add support for process.env.PORT environment variable to be able to run app on a configurable port

* 📝 (AccountMenu/index.tsx): Update imports and remove unused code for better organization and performance
🔧 (use-get-version.ts): Add functionality to refresh the latest version in darkStore after fetching version data
♻️ (darkStore.ts): Add refreshLatestVersion function to update the latest version in darkStore
📝 (dark/index.ts): Add latestVersion field and refreshLatestVersion function to DarkStoreType for better state management

*  (AccountMenu/index.tsx): Add constants for Discord, Docs, GitHub, and Twitter URLs for better maintainability and reusability
📝 (constants.ts): Update Twitter URL to a new value for consistency with other URLs
📝 (TwitterX): Add new TwitterX icon and component for use in the application
📝 (styleUtils.ts): Import and use the new TwitterXIcon in the list of node icons
📝 (utils.ts): Update formatNumber function to handle undefined input values for better error handling

* [autofix.ci] apply automated fixes

* 📝 (add_optins_column_to_user.py): Update down_revision to '1b8b740a6fa3' for consistency
🔧 (AccountMenu/index.tsx): Adjust classNameSize prop value to 'w-[272px]' for styling consistency
🔧 (HeaderMenu/index.tsx): Update HeaderMenuItems component to accept classNameSize prop for dynamic styling
🔧 (langflow-counts.tsx): Adjust styling for better visual consistency and spacing
🔧 (index.tsx): Update className for Bell icon to include text-muted-foreground and strokeWidth
🔧 (get-started-progress.tsx): Update styling and spacing for better visual consistency
🔧 (header-buttons.tsx): Add Separator component for visual separation in HeaderButtons component

* 🔧 (AccountMenu/index.tsx): Adjust padding in AccountMenu component for better alignment and spacing. Fix ThemeButtons positioning for improved layout.

*  (appHeaderComponent/index.tsx): Add support for managing flows and folders in the app header component
📝 (get-started-progress.tsx): Update heading tag to improve semantics
📝 (empty-page.tsx): Update text content in empty page to provide clearer instructions and information

*  (background-gradient.tsx): Add support for dynamic border radius in BackgroundGradient component
🔧 (empty-page.tsx): Remove BackgroundGradient import and replace it with EnhancedBeamEffect component
🔧 (empty-page.tsx): Update styles and classes for EnhancedBeamEffect component and adjust layout
 (enchanced-beam-effect.tsx): Create EnhancedBeamEffect component to add enhanced beam effect to UI components

*  (frontend): update text content and button labels in empty page component for better user experience
📝 (frontend): add data-testid attributes for testing purposes in various components
🔧 (frontend): add new test file for user progress tracking feature with Playwright tests

*  (AccountMenu/index.tsx): Add Admin Page button for admin users in the account menu component
🔧 (user-progress-track.spec.ts): Add utility function addNewUserAndLogin to facilitate adding and logging in new users for testing purposes

* [autofix.ci] apply automated fixes

* 🐛 (get-started-progress.tsx): fix calculation of percentage to ensure it does not exceed 100%

*  (empty-page.tsx): Add Lucide ExternalLink component for external links and update styling for external link icons
♻️ (empty-page.tsx): Refactor CSS classes for external link icons to improve readability and maintainability
📝 (index.css): Add custom CSS variable for Discord color
📝 (tailwind.config.mjs): Add Discord color to Tailwind CSS custom colors

* [autofix.ci] apply automated fixes

* add logo png

*  (index.tsx): Add z-50 class to improve stacking context in CardsWrapComponent
 (empty-page.tsx): Add text-center class to center text elements in EmptyPageCommunity
 (empty-page.tsx): Adjust spacing and alignment in EmptyPageCommunity for better layout and readability

* 🐛 (AccountMenu/index.tsx): fix condition to show admin options only when isAdmin is true and autoLogin is false

* 🔧 (alertDropDown/index.tsx): update z-index value in PopoverContent class to z-50 for proper stacking order

* 🔧 (index.tsx): update z-index value to improve the stacking order of the component on the page

* ♻️ (index.tsx): refactor classNames in CardsWrapComponent to improve readability and maintainability

* 🐛 (empty-page.tsx): fix data-testid attribute value to match the updated element name for better consistency and clarity
🐛 (user-progress-track.spec.ts): fix test cases to match the updated data-testid attribute value for the main page title element to ensure accurate testing and assertions

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa 2025-04-22 20:26:33 -03:00 committed by GitHub
commit a8ae17b86d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1482 additions and 209 deletions

51
.cursor/rules/tasks.mdc Normal file
View file

@ -0,0 +1,51 @@
---
description:
globs:
alwaysApply: true
---
# Concise Task Management Protocol - Sequential Mode
## File Creation
- Filename: `YYYYMMDD_HHMMSS_task_name.md`
- IMPORTANT: Always include a descriptive task name after the timestamp (e.g., `20250404_135621_api_integration.md`)
- Never create files with timestamp only
- Store in `tasks/` directory
## Task Structure
```
# Task: [Task Name]
**Status**: [Not Started | In Progress | Completed]
## Analysis
- [ ] Requirements
- [ ] Subtask 1
- [ ] Challenges
- [ ] Dependencies
## Plan
- [ ] Step 1: [Description]
- [ ] Subtask 1.1
- [ ] Step 2: [Description]
## Execution
- [ ] Implementation 1
- [ ] Details
- [ ] Implementation 2
- [ ] Files modified: [files]
## Summary
- [ ] Files modified: `filename.ext` (lines X-Y)
- [ ] Dependencies added/changed
- [ ] Edge cases considered
- [ ] Known limitations
- [ ] Future impact points
```
## Execution Rules
- Execute one subtask at a time in sequence
- Update the task file after EACH subtask is completed
- Mark completed subtasks with [x] as they are finished
- Update main task status throughout execution
- Document work incrementally, not all at once
- Never proceed to the next section until all subtasks in the current section are completed and marked

View file

@ -0,0 +1,38 @@
"""add_optins_column_to_user
Revision ID: e56d87f8994a
Revises: 1b8b740a6fa3
Create Date: 2025-04-09 15:57:46.904977
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.engine.reflection import Inspector
from langflow.utils import migration
# revision identifiers, used by Alembic.
revision: str = 'e56d87f8994a'
down_revision: Union[str, None] = '1b8b740a6fa3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
if not migration.column_exists(table_name='user', column_name='optins', conn=conn):
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('optins', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('optins')
# ### end Alembic commands ###

View file

@ -1,7 +1,9 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
from pydantic import BaseModel
from sqlalchemy import JSON, Column
from sqlmodel import Field, Relationship, SQLModel
from langflow.schema.serialize import UUIDstr
@ -13,6 +15,13 @@ if TYPE_CHECKING:
from langflow.services.database.models.variable import Variable
class UserOptin(BaseModel):
github_starred: bool = Field(default=False)
dialog_dismissed: bool = Field(default=False)
discord_clicked: bool = Field(default=False)
# Add more opt-in actions as needed
class User(SQLModel, table=True): # type: ignore[call-arg]
id: UUIDstr = Field(default_factory=uuid4, primary_key=True, unique=True)
username: str = Field(index=True, unique=True)
@ -37,11 +46,17 @@ class User(SQLModel, table=True): # type: ignore[call-arg]
back_populates="user",
sa_relationship_kwargs={"cascade": "delete"},
)
optins: dict[str, Any] | None = Field(
sa_column=Column(JSON, default=lambda: UserOptin().model_dump(), nullable=True)
)
class UserCreate(SQLModel):
username: str = Field()
password: str = Field()
optins: dict[str, Any] | None = Field(
default={"github_starred": False, "dialog_dismissed": False, "discord_clicked": False}
)
class UserRead(SQLModel):
@ -54,6 +69,7 @@ class UserRead(SQLModel):
create_at: datetime = Field()
updated_at: datetime = Field()
last_login_at: datetime | None = Field(nullable=True)
optins: dict[str, Any] | None = Field(default=None)
class UserUpdate(SQLModel):
@ -63,3 +79,4 @@ class UserUpdate(SQLModel):
is_active: bool | None = None
is_superuser: bool | None = None
last_login_at: datetime | None = None
optins: dict[str, Any] | None = None

View file

@ -74,8 +74,6 @@ const RenderInputParameters = ({
return keyMap;
}, [templateFields, data.id, data.node?.template]);
console.log({ data });
const renderInputParameter = templateFields.map(
(templateField: string, idx) => {
const template = data.node?.template[templateField];

View file

@ -47,7 +47,7 @@ const AlertDropdown = forwardRef<HTMLDivElement, AlertDropdownType>(
<PopoverContent
ref={notificationRef}
data-testid="notification-dropdown-content"
className="noflow nowheel nopan nodelete nodrag z-10 flex h-[500px] w-[500px] flex-col"
className="noflow nowheel nopan nodelete nodrag z-50 flex h-[500px] w-[500px] flex-col"
>
<div className="text-md flex flex-row justify-between pl-3 font-medium text-foreground">
Notifications

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,33 +1,31 @@
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import {
DISCORD_URL,
DOCS_URL,
GITHUB_URL,
TWITTER_URL,
} from "@/constants/constants";
import { useLogout } from "@/controllers/API/queries/auth";
import CustomFeatureFlagDialog from "@/customization/components/custom-feature-flag-dialog";
import CustomFeatureFlagMenuItems from "@/customization/components/custom-feature-flag-menu-items";
import { CustomFeedbackDialog } from "@/customization/components/custom-feedback-dialog";
import { CustomHeaderMenuItemsTitle } from "@/customization/components/custom-header-menu-items-title";
import { CustomProfileIcon } from "@/customization/components/custom-profile-icon";
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useAuthStore from "@/stores/authStore";
import { useDarkStore } from "@/stores/darkStore";
import { useState } from "react";
import { cn } from "@/utils/utils";
import { FaDiscord, FaGithub, FaTwitter } from "react-icons/fa";
import { useParams } from "react-router-dom";
import GithubStarComponent from "../GithubStarButton";
import {
HeaderMenu,
HeaderMenuItemButton,
HeaderMenuItemLink,
HeaderMenuItems,
HeaderMenuItemsSection,
HeaderMenuToggle,
} from "../HeaderMenu";
import { ProfileIcon } from "../ProfileIcon";
import ThemeButtons from "../ThemeButtons";
export const AccountMenu = () => {
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false);
const [isCustomFeatureFlagsOpen, setIsCustomFeatureFlagsOpen] =
useState(false);
const { customParam: id } = useParams();
const version = useDarkStore((state) => state.version);
const latestVersion = useDarkStore((state) => state.latestVersion);
const navigate = useCustomNavigate();
const { mutate: mutationLogout } = useLogout();
@ -40,6 +38,8 @@ export const AccountMenu = () => {
mutationLogout();
};
const isLatestVersion = version === latestVersion;
return (
<>
<HeaderMenu>
@ -48,34 +48,37 @@ export const AccountMenu = () => {
className="h-7 w-7 rounded-lg focus-visible:outline-0"
data-testid="user-profile-settings"
>
{ENABLE_DATASTAX_LANGFLOW ? <CustomProfileIcon /> : <ProfileIcon />}
<ProfileIcon />
</div>
</HeaderMenuToggle>
<HeaderMenuItems position="right">
{ENABLE_DATASTAX_LANGFLOW && (
<HeaderMenuItemsSection>
<CustomHeaderMenuItemsTitle />
</HeaderMenuItemsSection>
)}
<HeaderMenuItemsSection>
<div className="flex h-[46px] w-full items-center justify-between px-3">
<div className="pl-1 text-xs text-zinc-500">
<span
data-testid="menu_version_button"
id="menu_version_button"
>
Version {version}
</span>
<HeaderMenuItems position="right" classNameSize="w-[272px]">
<div className="divide-y divide-foreground/10">
<div>
<div className="h-[46px] items-center px-4 pt-3">
<div className="flex items-center justify-between">
<span
data-testid="menu_version_button"
id="menu_version_button"
className="text-sm"
>
Version
</span>
<div
className={cn(
"float-right text-xs",
isLatestVersion && "text-accent-emerald-foreground",
!isLatestVersion && "text-accent-amber-foreground",
)}
>
{version}{" "}
{isLatestVersion ? "(latest)" : "(update available)"}
</div>
</div>
</div>
{!ENABLE_DATASTAX_LANGFLOW && <ThemeButtons />}
</div>
{ENABLE_DATASTAX_LANGFLOW ? (
<HeaderMenuItemLink newPage href={`/settings/org/${id}/overview`}>
Account Settings
</HeaderMenuItemLink>
) : (
<div>
<HeaderMenuItemButton
icon="arrow-right"
onClick={() => {
navigate("/settings");
}}
@ -87,100 +90,84 @@ export const AccountMenu = () => {
Settings
</span>
</HeaderMenuItemButton>
)}
{!ENABLE_DATASTAX_LANGFLOW && (
<>
{isAdmin && !autoLogin && (
<HeaderMenuItemButton onClick={() => navigate("/admin")}>
{isAdmin && !autoLogin && (
<div>
<HeaderMenuItemButton
onClick={() => {
navigate("/admin");
}}
>
<span
data-testid="menu_admin_button"
id="menu_admin_button"
data-testid="menu_admin_page_button"
id="menu_admin_page_button"
>
Admin Page
</span>
</HeaderMenuItemButton>
)}
</>
)}
{ENABLE_DATASTAX_LANGFLOW ? (
<>
<HeaderMenuItemButton onClick={() => setIsFeedbackOpen(true)}>
<span
data-testid="menu_feedback_button"
id="menu_feedback_button"
>
Feedback
</span>
</HeaderMenuItemButton>
<CustomFeatureFlagMenuItems
onClick={() => setIsCustomFeatureFlagsOpen(true)}
/>
</>
) : (
<HeaderMenuItemLink newPage href="https://docs.langflow.org">
</div>
)}
<HeaderMenuItemLink newPage href={DOCS_URL}>
<span data-testid="menu_docs_button" id="menu_docs_button">
Docs
</span>
</HeaderMenuItemLink>
)}
</HeaderMenuItemsSection>
<HeaderMenuItemsSection>
{ENABLE_DATASTAX_LANGFLOW ? (
<HeaderMenuItemLink
newPage
href="https://github.com/langflow-ai/langflow"
>
<div className="-my-2 mr-2 flex w-full items-center justify-between">
<div className="text-sm">Star the repo</div>
<GithubStarComponent />
</div>
</HeaderMenuItemLink>
) : (
<HeaderMenuItemLink
newPage
href="https://github.com/langflow-ai/langflow/discussions"
>
<span data-testid="menu_github_button" id="menu_github_button">
Share Feedback on Github
</div>
<div>
<HeaderMenuItemLink newPage href={GITHUB_URL}>
<span
data-testid="menu_github_button"
id="menu_github_button"
className="flex items-center gap-2"
>
<FaGithub className="h-4 w-4" />
GitHub
</span>
</HeaderMenuItemLink>
)}
<HeaderMenuItemLink newPage href="https://twitter.com/langflow_ai">
<span data-testid="menu_twitter_button" id="menu_twitter_button">
Follow Langflow on X
</span>
</HeaderMenuItemLink>
<HeaderMenuItemLink newPage href="https://discord.gg/EqksyE2EX9">
<span data-testid="menu_discord_button" id="menu_discord_button">
Join the Langflow Discord
</span>
</HeaderMenuItemLink>
</HeaderMenuItemsSection>
{ENABLE_DATASTAX_LANGFLOW ? (
<HeaderMenuItemsSection>
<HeaderMenuItemLink href="/session/logout" icon="log-out">
Logout
<HeaderMenuItemLink newPage href={DISCORD_URL}>
<span
data-testid="menu_discord_button"
id="menu_discord_button"
className="flex items-center gap-2"
>
<FaDiscord className="h-4 w-4 text-[#5865F2]" />
Discord
</span>
</HeaderMenuItemLink>
</HeaderMenuItemsSection>
) : (
!autoLogin && (
<HeaderMenuItemsSection>
<HeaderMenuItemLink newPage href={TWITTER_URL}>
<span
data-testid="menu_twitter_button"
id="menu_twitter_button"
className="flex items-center gap-2"
>
<ForwardedIconComponent
strokeWidth={2}
name="TwitterXIcon"
className="h-4 w-4"
/>
X
</span>
</HeaderMenuItemLink>
</div>
<div className="flex items-center justify-between px-4 py-1 text-sm">
<span className="">Theme</span>
<div className="relative top-[1px] float-right">
<ThemeButtons />
</div>
</div>
{!autoLogin && (
<div>
<HeaderMenuItemButton onClick={handleLogout} icon="log-out">
Logout
</HeaderMenuItemButton>
</HeaderMenuItemsSection>
)
)}
</div>
)}
</div>
</HeaderMenuItems>
</HeaderMenu>
<CustomFeedbackDialog
isOpen={isFeedbackOpen}
setIsOpen={setIsFeedbackOpen}
/>
<CustomFeatureFlagDialog
isOpen={isCustomFeatureFlagsOpen}
setIsOpen={setIsCustomFeatureFlagsOpen}
/>
</>
);
};

View file

@ -1,21 +0,0 @@
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { useDarkStore } from "@/stores/darkStore";
import { FaGithub } from "react-icons/fa";
export const GithubStarComponent = () => {
const stars: number | undefined = useDarkStore((state) => state.stars);
return (
<ShadTooltip content="Go to Github repo" side="bottom" styleClasses="z-10">
<div className="group inline-flex h-8 items-center justify-center gap-1 rounded-md border bg-muted px-2 pr-0 hover:border-input hover:bg-secondary-hover">
<FaGithub className="h-4 w-4" />
<div className="hidden text-xs font-semibold lg:block">Star</div>
<div className="-mr-px ml-1 flex h-8 items-center justify-center rounded-md rounded-l-none border bg-background px-2 text-xs font-semibold text-secondary-foreground group-hover:border-input">
{stars?.toLocaleString() ?? 0}
</div>
</div>
</ShadTooltip>
);
};
export default GithubStarComponent;

View file

@ -22,11 +22,7 @@ export const HeaderMenuToggle = ({ children }) => (
>
<div className="flex items-center gap-1 rounded-lg px-2 py-1.5 group-hover:bg-muted">
{children}
<ChevronsUpDown
className="text-muted-foreground group-hover:text-foreground"
size={"15px"}
strokeWidth={"2px"}
/>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground group-hover:text-foreground" />
</div>
</DropdownMenuTrigger>
);
@ -72,10 +68,14 @@ export const HeaderMenuItemButton = ({ icon = "", onClick, children }) => (
export const HeaderMenuItems = ({
position = "left",
children,
}: React.PropsWithChildren<{ position?: "left" | "right" }>) => {
classNameSize = "w-[20rem]",
}: React.PropsWithChildren<{
position?: "left" | "right";
classNameSize?: string;
}>) => {
const positionClass = position === "left" ? "left-0" : "right-0";
return (
<DropdownMenuContent className={cn("w-[20rem]", positionClass)}>
<DropdownMenuContent className={cn(classNameSize, positionClass)}>
{children}
</DropdownMenuContent>
);

View file

@ -60,7 +60,7 @@ export const ThemeButtons = () => {
data-testid="menu_light_button"
id="menu_light_button"
>
<ForwardedIconComponent name="Sun" className="w-4" />
<ForwardedIconComponent strokeWidth={2} name="Sun" className="w-4" />
</Button>
{/* Dark Theme Button */}
@ -68,14 +68,14 @@ export const ThemeButtons = () => {
unstyled
className={`relative z-10 mx-1 inline-flex items-center rounded-full px-1 ${
selectedTheme === "dark"
? "text-background dark:hover:bg-purple-400"
: "text-foreground hover:bg-purple-400 hover:text-background"
? "bg-indigo-foreground text-primary hover:bg-indigo-foreground"
: "text-foreground hover:bg-indigo-foreground hover:text-background"
}`}
onClick={() => handleThemeChange("dark")}
data-testid="menu_dark_button"
id="menu_dark_button"
>
<ForwardedIconComponent name="Moon" className="w-4" />
<ForwardedIconComponent strokeWidth={2} name="Moon" className="w-4" />
</Button>
{/* System Theme Button */}
@ -90,7 +90,11 @@ export const ThemeButtons = () => {
data-testid="menu_system_button"
id="menu_system_button"
>
<ForwardedIconComponent name="Monitor" className="w-4" />
<ForwardedIconComponent
name="Monitor"
className="w-4"
strokeWidth={2}
/>
</Button>
</div>
);

View file

@ -0,0 +1,39 @@
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { useDarkStore } from "@/stores/darkStore";
import { formatNumber } from "@/utils/utils";
import { FaDiscord, FaGithub } from "react-icons/fa";
export const LangflowCounts = () => {
const stars: number | undefined = useDarkStore((state) => state.stars);
const discordCount: number = useDarkStore((state) => state.discordCount);
return (
<div className="flex items-center gap-5">
<ShadTooltip
content="Go to GitHub repo"
side="bottom"
styleClasses="z-10"
>
<div className="flex items-center gap-2 text-muted-foreground hover:text-foreground">
<FaGithub className="h-4 w-4" />
<span className="text-xs font-semibold">{formatNumber(stars)}</span>
</div>
</ShadTooltip>
<ShadTooltip
content="Go to Discord server"
side="bottom"
styleClasses="z-10"
>
<div className="flex items-center gap-2 text-muted-foreground hover:text-foreground">
<FaDiscord className="h-4 w-4" />
<span className="text-xs font-semibold">
{formatNumber(discordCount)}
</span>
</div>
</ShadTooltip>
</div>
);
};
export default LangflowCounts;

View file

@ -12,10 +12,12 @@ import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useTheme from "@/customization/hooks/use-custom-theme";
import { useResetDismissUpdateAll } from "@/hooks/use-reset-dismiss-update-all";
import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import { useFolderStore } from "@/stores/foldersStore";
import { useEffect, useRef, useState } from "react";
import { AccountMenu } from "./components/AccountMenu";
import FlowMenu from "./components/FlowMenu";
import GithubStarComponent from "./components/GithubStarButton";
import LangflowCounts from "./components/langflow-counts";
export default function AppHeader(): JSX.Element {
const notificationCenter = useAlertStore((state) => state.notificationCenter);
@ -46,9 +48,17 @@ export default function AppHeader(): JSX.Element {
useResetDismissUpdateAll();
const flows = useFlowsManagerStore((state) => state.flows);
const examples = useFlowsManagerStore((state) => state.examples);
const folders = useFolderStore((state) => state.folders);
const isEmpty = flows?.length !== examples?.length || folders?.length > 1;
return (
<div
className="flex h-[62px] w-full items-center justify-between gap-2 border-b px-5 py-2.5 dark:bg-background"
className={`flex h-[44px] w-full items-center justify-between border-b p-6 dark:bg-background ${
!isEmpty ? "hidden" : ""
}`}
data-testid="app-header"
>
{/* Left Section */}
@ -83,7 +93,7 @@ export default function AppHeader(): JSX.Element {
{/* Right Section */}
<div
className={`z-30 flex items-center gap-2`}
className={`relative left-3 z-30 flex items-center gap-2`}
data-testid="header_right_section_wrapper"
>
{!ENABLE_DATASTAX_LANGFLOW && (
@ -95,7 +105,7 @@ export default function AppHeader(): JSX.Element {
window.open("https://github.com/langflow-ai/langflow", "_blank")
}
>
<GithubStarComponent />
<LangflowCounts />
</Button>
</>
)}
@ -111,8 +121,7 @@ export default function AppHeader(): JSX.Element {
<AlertDropdown onClose={() => setActiveState(null)}>
<Button
ref={notificationRef}
variant="ghost"
className={`relative ${activeState === "notifications" ? "bg-accent text-accent-foreground" : ""}`}
unstyled
onClick={() =>
setActiveState((prev) =>
prev === "notifications" ? null : "notifications",
@ -123,47 +132,24 @@ export default function AppHeader(): JSX.Element {
<span
className={
notificationCenter
? `absolute left-[31px] top-[10px] h-1 w-1 rounded-full bg-destructive`
? `absolute right-[5.3rem] top-[10px] h-1 w-1 rounded-full bg-destructive`
: "hidden"
}
/>
<ForwardedIconComponent
name="Bell"
className="side-bar-button-size h-[18px] w-[18px]"
className="side-bar-button-size ml-1 h-4 w-4 text-muted-foreground"
strokeWidth={2}
/>
<span className="hidden whitespace-nowrap">Notifications</span>
</Button>
</AlertDropdown>
</ShadTooltip>
</AlertDropdown>
{!ENABLE_DATASTAX_LANGFLOW && (
<>
<ShadTooltip
content="Go to Langflow Store"
side="bottom"
styleClasses="z-10"
>
<Button
variant="ghost"
className={` ${lastPath === "store" ? "bg-accent text-accent-foreground" : ""} z-50`}
onClick={() => {
navigate("/store");
}}
data-testid="button-store"
>
<ForwardedIconComponent
name="Store"
className="side-bar-button-size h-[18px] w-[18px]"
/>
<span className="hidden whitespace-nowrap">Store</span>
</Button>
</ShadTooltip>
<Separator
orientation="vertical"
className="my-auto h-7 dark:border-zinc-700"
/>
</>
)}
<Separator
orientation="vertical"
className="my-auto ml-3 h-7 dark:border-zinc-700"
/>
{ENABLE_DATASTAX_LANGFLOW && (
<>
<ShadTooltip content="Docs" side="bottom" styleClasses="z-10">

View file

@ -74,7 +74,7 @@ export default function CardsWrapComponent({
className={cn(
"h-full w-full",
isDragging
? "mb-36 flex flex-col items-center justify-center gap-4 text-2xl font-light"
? "z-10 mb-36 flex flex-col items-center justify-center gap-4 text-2xl font-light"
: "",
)}
>

View file

@ -0,0 +1,234 @@
import IconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { DISCORD_URL, GITHUB_URL } from "@/constants/constants";
import { useGetUserData, useUpdateUser } from "@/controllers/API/queries/auth";
import ModalsComponent from "@/pages/MainPage/components/modalsComponent";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import { Users } from "@/types/api";
import { cn } from "@/utils/utils";
import { FC, useEffect, useMemo, useState } from "react";
import { FaDiscord, FaGithub } from "react-icons/fa";
export const GetStartedProgress: FC<{
userData: Users;
isGithubStarred: boolean;
isDiscordJoined: boolean;
handleDismissDialog: () => void;
}> = ({ userData, isGithubStarred, isDiscordJoined, handleDismissDialog }) => {
const [isGithubStarredChild, setIsGithubStarredChild] =
useState(isGithubStarred);
const [isDiscordJoinedChild, setIsDiscordJoinedChild] =
useState(isDiscordJoined);
const [newProjectModal, setNewProjectModal] = useState(false);
const flows = useFlowsManagerStore((state) => state.flows);
const { mutate: mutateLoggedUser } = useGetUserData();
const { mutate: updateUser } = useUpdateUser();
useEffect(() => {
if (!userData) {
mutateLoggedUser(null);
}
}, [userData, mutateLoggedUser]);
const hasFlows = flows && flows?.length > 0;
const percentageGetStarted = useMemo(() => {
const totalSteps = 3;
let hasFlowsCount = 0;
const completedSteps = Object.keys(userData?.optins ?? {}).filter(
(key) => userData?.optins?.[key],
)?.length;
if (hasFlows) {
hasFlowsCount = 33;
}
const percentage =
Math.round((completedSteps / totalSteps) * 100) + hasFlowsCount;
if (percentage > 100) {
return 100;
}
return percentage;
}, [userData?.optins, isGithubStarredChild, isDiscordJoinedChild, hasFlows]);
const handleUserTrack = (key: string) => {
const optins = userData?.optins ?? {};
optins[key] = true;
updateUser(
{
user_id: userData?.id!,
user: { optins },
},
{
onSuccess: () => {
mutateLoggedUser({});
if (key === "github_starred") {
setIsGithubStarredChild(true);
window.open(GITHUB_URL, "_blank", "noopener,noreferrer");
} else if (key === "discord_clicked") {
setIsDiscordJoinedChild(true);
window.open(DISCORD_URL, "_blank", "noopener,noreferrer");
} else if (key === "dialog_dismissed") {
handleDismissDialog();
}
},
},
);
};
return (
<div className="h-[180px] w-full">
<div className="mb-2 flex items-center justify-between">
<span className="font-[14px]">Get started</span>
<button
onClick={() => handleUserTrack("dialog_dismissed")}
className="text-muted-foreground hover:text-foreground"
data-testid="close_get_started_dialog"
>
<IconComponent name="X" className="h-4 w-4" />
</button>
</div>
<div className="mb-1 flex items-center justify-between gap-3">
<div className="h-1 w-full rounded-full bg-muted">
<div
className="h-1 w-[33%] rounded-full bg-accent-pink-foreground"
style={{ width: `${percentageGetStarted}%` }}
/>
</div>
<span
className="text-xs text-muted-foreground"
data-testid="get_started_progress_percentage"
>
{percentageGetStarted}%
</span>
</div>
<div className="space-y-1">
<Button
data-testid="github_starred_btn_get_started"
unstyled
className={cn(
"w-full",
isGithubStarredChild && "pointer-events-none",
)}
onClick={(e) => {
if (isGithubStarredChild) {
e.preventDefault();
return;
}
handleUserTrack("github_starred");
}}
>
<div
className={cn(
"flex items-center gap-2 rounded-md p-2 hover:bg-muted",
isGithubStarredChild && "pointer-events-none",
)}
>
{isGithubStarredChild ? (
<span data-testid="github_starred_icon_get_started">
<IconComponent
name="Check"
className="h-4 w-4 text-accent-emerald-foreground"
/>
</span>
) : (
<FaGithub className="h-4 w-4" />
)}
<span
className={cn(
"text-sm",
isGithubStarredChild && "text-muted-foreground line-through",
)}
>
Star repo for updates
</span>
</div>
</Button>
<Button
data-testid="discord_joined_btn_get_started"
unstyled
className={cn(
"w-full",
isDiscordJoinedChild && "pointer-events-none",
)}
onClick={(e) => {
if (isDiscordJoinedChild) {
e.preventDefault();
return;
}
handleUserTrack("discord_clicked");
}}
>
<div
className={cn(
"flex items-center gap-2 rounded-md p-2 hover:bg-muted",
isDiscordJoinedChild && "pointer-events-none",
)}
>
{isDiscordJoinedChild ? (
<span data-testid="discord_joined_icon_get_started">
<IconComponent
name="Check"
className="h-4 w-4 text-accent-emerald-foreground"
/>
</span>
) : (
<FaDiscord className="h-4 w-4 text-[#5865F2]" />
)}
<span
className={cn(
"text-sm",
isDiscordJoinedChild && "text-muted-foreground line-through",
)}
>
Join the community
</span>
</div>
</Button>
<Button
unstyled
className={cn("w-full", hasFlows && "pointer-events-none")}
onClick={() => setNewProjectModal(true)}
>
<div
className={cn(
"flex items-center gap-2 rounded-md p-2 hover:bg-muted",
hasFlows && "pointer-events-none text-muted-foreground",
)}
data-testid="create_flow_btn_get_started"
>
<span data-testid="create_flow_icon_get_started">
<IconComponent
name={hasFlows ? "Check" : "Plus"}
className={cn(
"h-4 w-4 text-primary",
hasFlows && "text-accent-emerald-foreground",
)}
/>
</span>
<span className={cn("text-sm", hasFlows && "line-through")}>
Create a flow
</span>
</div>
</Button>
</div>
<ModalsComponent
openModal={newProjectModal}
setOpenModal={setNewProjectModal}
openDeleteFolderModal={false}
setOpenDeleteFolderModal={() => {}}
handleDeleteFolder={() => {}}
/>
</div>
);
};

View file

@ -1,5 +1,9 @@
import IconComponent from "@/components/common/genericIconComponent";
import { GetStartedProgress } from "@/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress";
import { SidebarTrigger } from "@/components/ui/sidebar";
import useAuthStore from "@/stores/authStore";
import { Separator } from "@radix-ui/react-separator";
import { useState } from "react";
import { AddFolderButton } from "./add-folder-button";
import { UploadFolderButton } from "./upload-folder-button";
@ -13,23 +17,54 @@ export const HeaderButtons = ({
isUpdatingFolder: boolean;
isPending: boolean;
addNewFolder: () => void;
}) => (
<div className="flex shrink-0 items-center justify-between gap-2">
<SidebarTrigger className="lg:hidden">
<IconComponent name="PanelLeftClose" className="h-4 w-4" />
</SidebarTrigger>
}) => {
const userData = useAuthStore((state) => state.userData);
const userDismissedDialog = userData?.optins?.dialog_dismissed;
const isGithubStarred = userData?.optins?.github_starred;
const isDiscordJoined = userData?.optins?.discord_clicked;
<div className="flex-1 text-sm font-semibold">Folders</div>
<div className="flex items-center gap-1">
<UploadFolderButton
onClick={handleUploadFlowsToFolder}
disabled={isUpdatingFolder}
/>
<AddFolderButton
onClick={addNewFolder}
disabled={isUpdatingFolder}
loading={isPending}
/>
</div>
</div>
);
const [isDismissedDialog, setIsDismissedDialog] =
useState(userDismissedDialog);
const handleDismissDialog = () => {
setIsDismissedDialog(true);
};
return (
<>
{!isDismissedDialog && (
<>
<GetStartedProgress
userData={userData!}
isGithubStarred={isGithubStarred ?? false}
isDiscordJoined={isDiscordJoined ?? false}
handleDismissDialog={handleDismissDialog}
/>
<div className="-mx-4 mt-4 w-[280px]">
<hr className="border-t-1 w-full" />
</div>
</>
)}
<div className="flex shrink-0 items-center justify-between gap-2 pt-2">
<SidebarTrigger className="lg:hidden">
<IconComponent name="PanelLeftClose" className="h-4 w-4" />
</SidebarTrigger>
<div className="flex-1 text-sm font-semibold">Folders</div>
<div className="flex items-center gap-1">
<UploadFolderButton
onClick={handleUploadFlowsToFolder}
disabled={isUpdatingFolder}
/>
<AddFolderButton
onClick={addNewFolder}
disabled={isUpdatingFolder}
loading={isPending}
/>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,99 @@
import { cn } from "@/utils/utils";
import { motion } from "framer-motion";
import React from "react";
export const BackgroundGradient = ({
children,
className,
containerClassName,
animate = true,
borderColor,
borderRadius,
}: {
children?: React.ReactNode;
className?: string;
containerClassName?: string;
animate?: boolean;
borderColor?: string;
borderRadius?: string;
}) => {
const variants = {
initial: {
backgroundPosition: "0 50%",
},
animate: {
backgroundPosition: ["0, 50%", "100% 50%", "0 50%"],
},
};
const defaultGradient =
"radial-gradient(circle farthest-side at 0 100%,#00ccb1,transparent),radial-gradient(circle farthest-side at 100% 0,#7b61ff,transparent),radial-gradient(circle farthest-side at 100% 100%,#ffc414,transparent),radial-gradient(circle farthest-side at 0 0,#1ca0fb,#141316)";
return (
<div className={cn("group relative p-[1px]", containerClassName)}>
<motion.div
variants={animate ? variants : undefined}
initial={animate ? "initial" : undefined}
animate={animate ? "animate" : undefined}
transition={
animate
? {
duration: 5,
repeat: Infinity,
repeatType: "reverse",
}
: undefined
}
style={{
backgroundSize: animate ? "400% 400%" : undefined,
background: borderColor || defaultGradient,
position: "absolute",
inset: 0,
borderRadius: "24px",
opacity: 0.2,
filter: "blur(8px)",
transition: "all 0.4s ease-in-out",
willChange: "transform",
}}
className="group-hover:filter-[blur(15px)] group-hover:opacity-70 group-hover:brightness-125"
/>
<motion.div
variants={animate ? variants : undefined}
initial={animate ? "initial" : undefined}
animate={animate ? "animate" : undefined}
transition={
animate
? {
duration: 5,
repeat: Infinity,
repeatType: "reverse",
}
: undefined
}
style={
{
backgroundSize: animate ? "400% 400%" : undefined,
background: borderColor || defaultGradient,
position: "absolute",
inset: 0,
borderRadius: borderRadius || "24px",
willChange: "transform",
transition: "all 0.4s ease-in-out",
"--border-color": borderColor,
"--border-color-transparent": borderColor
? `${borderColor}20`
: undefined,
} as React.CSSProperties
}
className={cn(
"group-hover:brightness-125",
borderColor
? "group-hover:shadow-[0_0_15px_3px_var(--border-color),0_0_25px_5px_var(--border-color-transparent)]"
: "group-hover:shadow-[0_0_15px_3px_rgba(0,204,177,0.3),0_0_25px_5px_rgba(123,97,255,0.2)]",
)}
/>
<div className={cn("relative z-10", className)}>{children}</div>
</div>
);
};

View file

@ -0,0 +1,36 @@
import { cn } from "@/utils/utils";
export function DotBackgroundDemo({
children,
className,
containerClassName,
}: {
children: React.ReactNode;
className?: string;
containerClassName?: string;
}) {
return (
<div
className={cn(
"relative flex h-full w-full items-center justify-center bg-white dark:bg-black",
className,
)}
>
<div
className={cn(
"absolute inset-0",
"[background-size:20px_20px]",
"[background-image:radial-gradient(#d4d4d4_1px,transparent_1px)]",
"dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]",
)}
/>
<div
className={cn(
"absolute inset-0 bg-gradient-to-b from-white/10 via-white/50 to-white dark:from-background/30 dark:via-background/80 dark:to-background",
containerClassName,
)}
/>
{children}
</div>
);
}

View file

@ -1005,6 +1005,7 @@ export const GRADIENT_CLASS_DISABLED =
"linear-gradient(to right, hsl(var(--muted) / 0.3), hsl(var(--muted)))";
export const RECEIVING_INPUT_VALUE = "Receiving input";
export const SELECT_AN_OPTION = "Select an option...";
export const ICON_STROKE_WIDTH = 1.5;
@ -1068,5 +1069,9 @@ export const OPENAI_VOICES = [
export const DEFAULT_POLLING_INTERVAL = 5000;
export const DEFAULT_TIMEOUT = 30000;
export const DEFAULT_FILE_PICKER_TIMEOUT = 60000;
export const DISCORD_URL = "https://discord.com/invite/EqksyE2EX9";
export const GITHUB_URL = "https://github.com/langflow-ai/langflow";
export const TWITTER_URL = "https://x.com/langflow_ai";
export const DOCS_URL = "https://docs.langflow.org";
export const UUID_PARSING_ERROR = "uuid_parsing";

View file

@ -10,6 +10,8 @@ import { FlowStyleType, FlowType } from "../../types/flow";
import { StoreComponentResponse } from "../../types/store";
const GITHUB_API_URL = "https://api.github.com";
const DISCORD_API_URL =
"https://discord.com/api/v9/invites/EqksyE2EX9?with_counts=true";
export async function getRepoStars(owner: string, repo: string) {
try {
@ -21,6 +23,15 @@ export async function getRepoStars(owner: string, repo: string) {
}
}
export async function getDiscordCount() {
try {
const response = await api.get(DISCORD_API_URL);
return response?.data.approximate_member_count;
} catch (error) {
console.error("Error fetching repository data:", error);
return null;
}
}
export async function createApiKey(name: string) {
try {
const res = await api.post(`${BASE_URL_API}api_key/`, { name });

View file

@ -1,16 +1,18 @@
import useAuthStore from "@/stores/authStore";
import { UseMutationResult } from "@tanstack/react-query";
import { useMutationFunctionType, Users } from "../../../../types/api";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
export const useGetUserData: useMutationFunctionType<undefined, any> = (
options?,
) => {
const setUserData = useAuthStore((state) => state.setUserData);
const { mutate } = UseRequestProcessor();
const getUserData = async () => {
const response = await api.get<Users>(`${getURL("USERS")}/whoami`);
setUserData(response["data"]);
return response["data"];
};

View file

@ -7,6 +7,7 @@ import { UseRequestProcessor } from "../../services/request-processor";
interface versionQueryResponse {
version: string;
package: string;
main_version: string;
}
export const useGetVersionQuery: useQueryFunctionType<
@ -22,7 +23,9 @@ export const useGetVersionQuery: useQueryFunctionType<
const responseFn = async () => {
const { data } = await getVersionFn();
const refreshVersion = useDarkStore.getState().refreshVersion;
const refreshLatestVersion = useDarkStore.getState().refreshLatestVersion;
refreshVersion(data.version);
refreshLatestVersion(data.main_version);
return data;
};

View file

@ -0,0 +1,46 @@
const TwitterXSVG = (props) => {
return props.isdark === "true" ? (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0,0,256,256"
width="30px"
height="30px"
fillRule="nonzero"
>
<g
fill="#ffffff"
fillRule="nonzero"
stroke="none"
strokeWidth="1"
strokeLinecap="butt"
strokeLinejoin="miter"
strokeMiterlimit="10"
strokeDasharray=""
strokeDashoffset="0"
fontFamily="none"
fontWeight="none"
fontSize="none"
textAnchor="none"
style={{ mixBlendMode: "normal" }}
>
<g transform="scale(8.53333,8.53333)">
<path d="M26.37,26l-8.795,-12.822l0.015,0.012l7.93,-9.19h-2.65l-6.46,7.48l-5.13,-7.48h-6.95l8.211,11.971l-0.001,-0.001l-8.66,10.03h2.65l7.182,-8.322l5.708,8.322zM10.23,6l12.34,18h-2.1l-12.35,-18z"></path>
</g>
</g>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
{...props}
viewBox="0 0 30 30"
width="30px"
height="30px"
fill="#000000"
>
<path d="M26.37,26l-8.795-12.822l0.015,0.012L25.52,4h-2.65l-6.46,7.48L11.28,4H4.33l8.211,11.971L12.54,15.97L3.88,26h2.65 l7.182-8.322L19.42,26H26.37z M10.23,6l12.34,18h-2.1L8.12,6H10.23z" />
</svg>
);
};
export default TwitterXSVG;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="30px" height="30px" fill-rule="nonzero"><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(8.53333,8.53333)"><path d="M26.37,26l-8.795,-12.822l0.015,0.012l7.93,-9.19h-2.65l-6.46,7.48l-5.13,-7.48h-6.95l8.211,11.971l-0.001,-0.001l-8.66,10.03h2.65l7.182,-8.322l5.708,8.322zM10.23,6l12.34,18h-2.1l-12.35,-18z"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 693 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30px" height="30px"><path d="M26.37,26l-8.795-12.822l0.015,0.012L25.52,4h-2.65l-6.46,7.48L11.28,4H4.33l8.211,11.971L12.54,15.97L3.88,26h2.65 l7.182-8.322L19.42,26H26.37z M10.23,6l12.34,18h-2.1L8.12,6H10.23z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,11 @@
import { useDarkStore } from "@/stores/darkStore";
import React, { forwardRef } from "react";
import TwitterXSVG from "./TwitterX.jsx";
export const TwitterXIcon = forwardRef<
SVGSVGElement,
React.PropsWithChildren<{}>
>((props, ref) => {
const isdark = useDarkStore((state) => state.dark).toString();
return <TwitterXSVG ref={ref} isdark={isdark} {...props} />;
});

View file

@ -231,6 +231,7 @@ import {
ZoomIn,
ZoomOut,
} from "lucide-react";
import { TwitterXIcon } from "./Twitter X";
// Create a map of eagerly loaded Lucide icons
export const lucideIcons = {
@ -464,4 +465,5 @@ export const lucideIcons = {
Zap,
ZoomIn,
ZoomOut,
TwitterXIcon,
};

View file

@ -61,6 +61,7 @@ export default function DeleteConfirmationModal({
onClick={(e) => e.stopPropagation()}
className="mr-1"
variant="outline"
data-testid="btn_cancel_delete_confirmation_modal"
>
Cancel
</Button>
@ -72,6 +73,7 @@ export default function DeleteConfirmationModal({
onClick={(e) => {
onConfirm(e);
}}
data-testid="btn_delete_delete_confirmation_modal"
>
Delete
</Button>

View file

@ -15,6 +15,9 @@ import { LoadingPage } from "../LoadingPage";
export function AppInitPage() {
const refreshStars = useDarkStore((state) => state.refreshStars);
const refreshDiscordCount = useDarkStore(
(state) => state.refreshDiscordCount,
);
const isLoading = useFlowsManagerStore((state) => state.isLoading);
const { isFetched: isLoaded } = useCustomPrimaryLoading();
@ -31,6 +34,7 @@ export function AppInitPage() {
useEffect(() => {
if (isFetched) {
refreshStars();
refreshDiscordCount();
}
if (isConfigFetched) {

View file

@ -93,6 +93,7 @@ const DropdownComponent = ({
setOpenDelete(true);
}}
className="cursor-pointer text-destructive"
data-testid="btn_delete_dropdown_menu"
>
<ForwardedIconComponent
name="Trash2"

View file

@ -0,0 +1,190 @@
import LangflowLogo from "@/assets/LangflowLogo.svg?react";
import logoDarkPng from "@/assets/logo_dark.png";
import logoLightPng from "@/assets/logo_light.png";
import CardsWrapComponent from "@/components/core/cardsWrapComponent";
import { Button } from "@/components/ui/button";
import { DotBackgroundDemo } from "@/components/ui/dot-background";
import { DISCORD_URL, GITHUB_URL } from "@/constants/constants";
import { useGetUserData, useUpdateUser } from "@/controllers/API/queries/auth";
import { EnhancedBeamEffect } from "@/pages/MainPage/pages/enchanced-beam-effect";
import useAuthStore from "@/stores/authStore";
import { useDarkStore } from "@/stores/darkStore";
import { useFolderStore } from "@/stores/foldersStore";
import { formatNumber } from "@/utils/utils";
import { ExternalLink } from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import { HiArrowRight } from "react-icons/hi";
import { useShallow } from "zustand/react/shallow";
import useFileDrop from "../hooks/use-on-file-drop";
const EMPTY_PAGE_TITLE = "Your new favorite way to ship Agents";
const EMPTY_PAGE_DESCRIPTION =
"Design agents that connect to any API, model, or database.";
const EMPTY_PAGE_GITHUB_DESCRIPTION =
"Follow development, star the repo, and shape the future.";
const EMPTY_PAGE_DISCORD_DESCRIPTION =
"Join builders, ask questions, and show off your agents.";
const EMPTY_PAGE_DRAG_AND_DROP_TEXT =
"Already have a flow? Drag and drop to upload.";
const EMPTY_PAGE_FOLDER_DESCRIPTION = "Empty folder";
const EMPTY_PAGE_CREATE_FIRST_FLOW_BUTTON_TEXT = <>+&nbsp; Create first flow</>;
const EXTERNAL_LINK_ICON_CLASS =
"absolute right-6 top-[35px] h-4 w-4 shrink-0 translate-x-0 opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:opacity-100";
export const EmptyPageCommunity = ({
setOpenModal,
}: {
setOpenModal: (open: boolean) => void;
}) => {
const handleFileDrop = useFileDrop(undefined);
const folders = useFolderStore((state) => state.folders);
const userData = useAuthStore(useShallow((state) => state.userData));
const stars: number | undefined = useDarkStore((state) => state.stars);
const discordCount: number = useDarkStore((state) => state.discordCount);
const { mutate: updateUser } = useUpdateUser();
const { mutate: mutateLoggedUser } = useGetUserData();
const handleUserTrack = (key: string) => () => {
const optins = userData?.optins ?? {};
optins[key] = true;
updateUser(
{
user_id: userData?.id!,
user: { optins },
},
{
onSuccess: () => {
mutateLoggedUser({});
},
},
);
};
return (
<DotBackgroundDemo>
<CardsWrapComponent
dragMessage={`Drop your flows or components here`}
onFileDrop={handleFileDrop}
>
<div className="m-0 h-full w-full bg-background p-0">
<div className="z-50 flex h-full w-full flex-col items-center justify-center gap-6">
<div className="z-50 flex flex-col items-center gap-3">
<div className="z-50 dark:hidden">
<img
src={logoLightPng}
alt="Langflow Logo Light"
data-testid="empty_page_logo_light"
className=""
/>
</div>
<div className="z-50 hidden dark:block">
<img
src={logoDarkPng}
alt="Langflow Logo Dark"
data-testid="empty_page_logo_dark"
className=""
/>
</div>
<span
data-testid="mainpage_title"
className="z-50 text-center text-2xl font-semibold text-foreground"
>
{EMPTY_PAGE_TITLE}
</span>
<span
data-testid="empty_page_description"
className="z-50 text-center text-[14px] text-secondary-foreground"
>
{folders?.length > 1
? EMPTY_PAGE_FOLDER_DESCRIPTION
: EMPTY_PAGE_DESCRIPTION}
</span>
</div>
<div className="flex w-full max-w-[510px] flex-col gap-12 sm:gap-8">
<Button
unstyled
className="group mx-3 h-[84px] sm:mx-0"
onClick={() => {
handleUserTrack("github_starred")();
window.open(GITHUB_URL, "_blank", "noopener,noreferrer");
}}
data-testid="empty_page_github_button"
>
<div className="relative flex flex-col rounded-lg border-[1px] bg-background p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-accent-pink-foreground hover:shadow-[0_4px_10px_rgba(198,97,184,0.25)]">
<div className="grid w-full items-center justify-between gap-2">
<div className="flex gap-3">
<FaGithub className="h-6 w-6" />
<div>
<span className="font-semibold">GitHub</span>
<span className="ml-2 font-mono text-muted-foreground">
{formatNumber(stars)}
</span>
</div>
</div>
<div>
<span className="text-sm text-secondary-foreground">
{EMPTY_PAGE_GITHUB_DESCRIPTION}
</span>
</div>
</div>
<ExternalLink className={EXTERNAL_LINK_ICON_CLASS} />
</div>
</Button>
<Button
unstyled
className="group mx-3 h-[84px] sm:mx-0"
onClick={() => {
handleUserTrack("discord_joined")();
window.open(DISCORD_URL, "_blank", "noopener,noreferrer");
}}
data-testid="empty_page_discord_button"
>
<div className="relative flex flex-col rounded-lg border-[1px] bg-background p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-discord-color hover:shadow-[0_4px_10px_rgba(88,101,242,0.25)]">
<div className="grid w-full items-center justify-between gap-2">
<div className="flex gap-3">
<FaDiscord className="h-6 w-6 text-discord-color" />
<div>
<span className="font-semibold">Discord</span>
<span className="ml-2 font-mono text-muted-foreground">
{formatNumber(discordCount)}
</span>
</div>
</div>
<div>
<span className="text-sm text-secondary-foreground">
{EMPTY_PAGE_DISCORD_DESCRIPTION}
</span>
</div>
</div>
<ExternalLink className={EXTERNAL_LINK_ICON_CLASS} />
</div>
</Button>
<Button
variant="default"
className="z-10 m-auto h-10 w-full max-w-[155px] rounded-lg font-bold transition-all duration-300"
onClick={() => setOpenModal(true)}
id="new-project-btn"
data-testid="new_project_btn_empty_page"
>
<span>{EMPTY_PAGE_CREATE_FIRST_FLOW_BUTTON_TEXT}</span>
</Button>
</div>
</div>
</div>
<p
data-testid="empty_page_drag_and_drop_text"
className="absolute bottom-5 left-0 right-0 mt-4 cursor-default text-center text-sm text-muted-foreground"
>
{EMPTY_PAGE_DRAG_AND_DROP_TEXT}
</p>
</CardsWrapComponent>
</DotBackgroundDemo>
);
};
export default EmptyPageCommunity;

View file

@ -0,0 +1,43 @@
import { cn } from "@/utils/utils";
import { ReactNode } from "react";
import { BorderBeam } from "../../../components/ui/border-beams";
interface EnhancedBeamEffectProps {
children: ReactNode;
className?: string;
primaryColor?: string;
secondaryColor?: string;
size?: number;
}
export const EnhancedBeamEffect = ({
children,
className,
primaryColor = "#C661B8",
secondaryColor = "#61C6B8",
size = 200,
}: EnhancedBeamEffectProps) => {
return (
<div
className={cn(
"relative flex items-center justify-center overflow-hidden rounded-xl",
className,
)}
>
{children}
{/* Primary beam - larger, slower rotation */}
<BorderBeam
duration={12}
size={size}
className="opacity-80"
colorFrom={primaryColor}
colorTo={secondaryColor}
anchor={20}
borderWidth={1.5}
/>
</div>
);
};
export default EnhancedBeamEffect;

View file

@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import ModalsComponent from "../components/modalsComponent";
import EmptyPageCommunity from "./empty-page";
import EmptyPage from "./emptyPage";
export default function CollectionPage(): JSX.Element {
@ -80,7 +81,9 @@ export default function CollectionPage(): JSX.Element {
{flows?.length !== examples?.length || folders?.length > 1 ? (
<Outlet />
) : (
<EmptyPage setOpenModal={setOpenModal} />
// <EmptyPage setOpenModal={setOpenModal} />
<EmptyPageCommunity setOpenModal={setOpenModal} />
)}
</div>
) : (

View file

@ -23,9 +23,9 @@ import { AppWrapperPage } from "./pages/AppWrapperPage";
import { DashboardWrapperPage } from "./pages/DashboardWrapperPage";
import FlowPage from "./pages/FlowPage";
import LoginPage from "./pages/LoginPage";
import CollectionPage from "./pages/MainPage/pages";
import FilesPage from "./pages/MainPage/pages/filesPage";
import HomePage from "./pages/MainPage/pages/homePage";
import CollectionPage from "./pages/MainPage/pages/main-page";
import SettingsPage from "./pages/SettingsPage";
import ApiKeysPage from "./pages/SettingsPage/pages/ApiKeysPage";
import GeneralPage from "./pages/SettingsPage/pages/GeneralPage";

View file

@ -1,5 +1,5 @@
import { create } from "zustand";
import { getRepoStars } from "../controllers/API";
import { getDiscordCount, getRepoStars } from "../controllers/API";
import { DarkStoreType } from "../types/zustand/dark";
const startedStars = Number(window.localStorage.getItem("githubStars")) ?? 0;
@ -8,6 +8,10 @@ export const useDarkStore = create<DarkStoreType>((set, get) => ({
dark: JSON.parse(window.localStorage.getItem("isDark")!) ?? false,
stars: startedStars,
version: "",
latestVersion: "",
refreshLatestVersion: (v: string) => {
set(() => ({ latestVersion: v }));
},
setDark: (dark) => {
set(() => ({ dark: dark }));
window.localStorage.setItem("isDark", dark.toString());
@ -40,4 +44,10 @@ export const useDarkStore = create<DarkStoreType>((set, get) => ({
});
}
},
discordCount: 0,
refreshDiscordCount: () => {
getDiscordCount().then((res) => {
set(() => ({ discordCount: res }));
});
},
}));

View file

@ -69,6 +69,7 @@
--round-btn-shadow: #00000063;
--ice: #31a3cc;
--selected: #2196f3;
--discord-color: #5765f2;
--error-background: #fef2f2;
--error-foreground: #991b1b;
@ -194,6 +195,7 @@
--accent-amber: 48, 96%, 89%;
--accent-amber-foreground: 26 90.5% 37.1%;
--red-foreground: 0 90.6% 70.8%;
--indigo-foreground: 243.7 54.5% 41.4%;
}
.dark {
@ -344,6 +346,8 @@
--hover: #1a202e;
--disabled-run: #6366f1;
--selected: #0369a1;
--discord-color: #5765f2;
--filter-foreground: #eef2ff;
--filter-background: #4e46e599;
@ -450,5 +454,6 @@
--accent-amber: 22, 78%, 26%;
--accent-amber-foreground: 45.9 96.7% 64.5%;
--red-foreground: 0 72.2% 50.6%;
--indigo-foreground: 234.5 89.5% 73.9%;
}
}

View file

@ -157,6 +157,11 @@ export type changeUser = {
is_superuser?: boolean;
password?: string;
profile_image?: string;
optins?: {
github_starred?: boolean;
discord_clicked?: boolean;
dialog_dismissed?: boolean;
};
};
export type resetPasswordType = {
@ -172,6 +177,11 @@ export type Users = {
profile_image: string;
create_at: Date;
updated_at: Date;
optins?: {
github_starred?: boolean;
discord_clicked?: boolean;
dialog_dismissed?: boolean;
};
};
export type Component = {

View file

@ -2,7 +2,11 @@ export type DarkStoreType = {
dark: boolean;
stars: number;
version: string;
latestVersion: string;
setDark: (dark: boolean) => void;
refreshVersion: (v: string) => void;
refreshLatestVersion: (v: string) => void;
refreshStars: () => void;
discordCount: number;
refreshDiscordCount: () => void;
};

View file

@ -421,6 +421,7 @@ export const nodeIconToDisplayIconMap: Record<string, string> = {
ScrapeGraphMarkdownifyApi: "ScrapeGraph",
Unlink: "UnlinkIcon",
note: "StickyNote",
TwitterXIcon: "TwitterXIcon",
};
export const getLucideIconName = (name: string): string => {

View file

@ -872,3 +872,15 @@ export const convertUTCToLocalTimezone = (timestamp: string) => {
const localTimezone = moment.tz.guess();
return moment.utc(timestamp).tz(localTimezone).format("MM/DD/YYYY HH:mm:ss");
};
export const formatNumber = (num: number | undefined): string => {
if (num === undefined) return "0";
if (num >= 1000000) {
return (num / 1000000).toFixed(0) + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(0) + "k";
}
return num?.toString();
};

View file

@ -309,6 +309,8 @@ const config = {
"slider-input-border": "var(--slider-input-border)",
"zinc-foreground": "hsl(var(--zinc-foreground))",
"red-foreground": "hsl(var(--red-foreground))",
"indigo-foreground": "hsl(var(--indigo-foreground))",
"discord-color": "var(--discord-color)",
},
borderRadius: {
lg: `var(--radius)`,

View file

@ -0,0 +1,242 @@
import { expect, test } from "@playwright/test";
import { DISCORD_URL, GITHUB_URL } from "../../../src/constants/constants";
import { addNewUserAndLogin } from "../../utils/add-new-user-and-loggin";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test(
"admin user must be able to track their progress in getting started",
{ tag: ["@release", "@api"] },
async ({ page, context }) => {
await page.goto("/");
// Wait for any loading text to disappear
await page.waitForSelector('text="Loading"', {
state: "hidden",
timeout: 30000,
});
await page.waitForTimeout(2000);
let emptyButton = page.getByTestId("new_project_btn_empty_page");
while ((await emptyButton.count()) === 0) {
await page.getByTestId("home-dropdown-menu").first().click();
await page.getByTestId("btn_delete_dropdown_menu").first().click();
await page
.getByTestId("btn_delete_delete_confirmation_modal")
.first()
.click();
await page.waitForTimeout(1000);
emptyButton = page.getByTestId("new_project_btn_empty_page");
}
await expect(emptyButton).toBeVisible();
await expect(page.getByTestId("mainpage_title")).toBeVisible();
await expect(page.getByTestId("empty_page_description")).toBeVisible();
await expect(page.getByTestId("empty_page_github_button")).toBeVisible();
await expect(page.getByTestId("empty_page_discord_button")).toBeVisible();
await expect(
page.getByTestId("empty_page_drag_and_drop_text"),
).toBeVisible();
await expect(page.getByTestId("app-header")).not.toBeVisible();
await page.getByTestId("empty_page_github_button").click();
const pagePromiseGithub = context.waitForEvent("page");
const newPageGithub = await pagePromiseGithub;
await newPageGithub.waitForTimeout(3000);
const newUrlGithub = newPageGithub.url();
await expect(newUrlGithub).toContain(GITHUB_URL);
await newPageGithub.close();
await expect(page.getByTestId("mainpage_title")).toBeVisible();
await expect(page.getByTestId("empty_page_description")).toBeVisible();
await page.getByTestId("new_project_btn_empty_page").click();
await page.getByTestId("side_nav_options_all-templates").click();
await page.getByRole("heading", { name: "Basic Prompting" }).click();
await page.waitForSelector('[data-testid="icon-ChevronLeft"]', {
timeout: 100000,
});
await page.getByTestId("icon-ChevronLeft").first().click();
await page.waitForSelector('[data-testid="home-dropdown-menu"]', {
timeout: 100000,
});
await expect(page.getByTestId("app-header")).toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("get_started_progress_percentage").first(),
).toHaveText("66%");
await page.getByTestId("discord_joined_btn_get_started").click();
const pagePromiseDiscord = context.waitForEvent("page");
const newPageDiscord = await pagePromiseDiscord;
await newPageDiscord.waitForTimeout(3000);
const newUrlDiscord = newPageDiscord.url();
await expect(newUrlDiscord).toContain(DISCORD_URL);
await newPageDiscord.close();
await expect(page.getByTestId("app-header")).toBeVisible();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("get_started_progress_percentage").first(),
).toHaveText("100%");
await page.getByTestId("close_get_started_dialog").click();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).not.toBeVisible();
},
);
test(
"normal user must be able to track their progress in getting started",
{ tag: ["@release", "@api"] },
async ({ page, context }) => {
await addNewUserAndLogin(page);
// Wait for any loading text to disappear
await page.waitForSelector('text="Loading"', {
state: "hidden",
timeout: 30000,
});
await page.waitForTimeout(2000);
let emptyButton = page.getByTestId("new_project_btn_empty_page");
while ((await emptyButton.count()) === 0) {
await page.getByTestId("home-dropdown-menu").first().click();
await page.getByTestId("btn_delete_dropdown_menu").first().click();
await page
.getByTestId("btn_delete_delete_confirmation_modal")
.first()
.click();
await page.waitForTimeout(1000);
emptyButton = page.getByTestId("new_project_btn_empty_page");
}
await expect(emptyButton).toBeVisible();
await expect(page.getByTestId("mainpage_title")).toBeVisible();
await expect(page.getByTestId("empty_page_description")).toBeVisible();
await expect(page.getByTestId("empty_page_github_button")).toBeVisible();
await expect(page.getByTestId("empty_page_discord_button")).toBeVisible();
await expect(
page.getByTestId("empty_page_drag_and_drop_text"),
).toBeVisible();
await expect(page.getByTestId("app-header")).not.toBeVisible();
await page.getByTestId("empty_page_github_button").click();
const pagePromiseGithub = context.waitForEvent("page");
const newPageGithub = await pagePromiseGithub;
await newPageGithub.waitForTimeout(3000);
const newUrlGithub = newPageGithub.url();
await expect(newUrlGithub).toContain(GITHUB_URL);
await newPageGithub.close();
await expect(page.getByTestId("mainpage_title")).toBeVisible();
await expect(page.getByTestId("empty_page_description")).toBeVisible();
await page.getByTestId("new_project_btn_empty_page").click();
await page.getByTestId("side_nav_options_all-templates").click();
await page.getByRole("heading", { name: "Basic Prompting" }).click();
await page.waitForSelector('[data-testid="icon-ChevronLeft"]', {
timeout: 100000,
});
await page.getByTestId("icon-ChevronLeft").first().click();
await page.waitForSelector('[data-testid="home-dropdown-menu"]', {
timeout: 100000,
});
await expect(page.getByTestId("app-header")).toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("get_started_progress_percentage").first(),
).toHaveText("66%");
await page.getByTestId("discord_joined_btn_get_started").click();
const pagePromiseDiscord = context.waitForEvent("page");
const newPageDiscord = await pagePromiseDiscord;
await newPageDiscord.waitForTimeout(3000);
const newUrlDiscord = newPageDiscord.url();
await expect(newUrlDiscord).toContain(DISCORD_URL);
await newPageDiscord.close();
await expect(page.getByTestId("app-header")).toBeVisible();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).toBeVisible();
await expect(
page.getByTestId("get_started_progress_percentage").first(),
).toHaveText("100%");
await page.getByTestId("close_get_started_dialog").click();
await expect(
page.getByTestId("discord_joined_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("create_flow_icon_get_started"),
).not.toBeVisible();
await expect(
page.getByTestId("github_starred_icon_get_started"),
).not.toBeVisible();
},
);

View file

@ -0,0 +1,103 @@
import { expect, Page } from "@playwright/test";
export const addNewUserAndLogin = async (page: Page) => {
await page.route("**/api/v1/auto_login", (route) => {
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
detail: { auto_login: false },
}),
});
});
await page.addInitScript(() => {
window.process = window.process || {};
const newEnv = { ...window.process.env, LANGFLOW_AUTO_LOGIN: "false" };
Object.defineProperty(window.process, "env", {
value: newEnv,
writable: true,
configurable: true,
});
sessionStorage.setItem("testMockAutoLogin", "true");
});
const randomName = Math.random().toString(36).substring(5);
const randomPassword = Math.random().toString(36).substring(5);
await page.goto("/");
await page.waitForSelector("text=sign in to langflow", { timeout: 30000 });
await page.getByPlaceholder("Username").fill("langflow");
await page.getByPlaceholder("Password").fill("langflow");
await page.evaluate(() => {
sessionStorage.removeItem("testMockAutoLogin");
});
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForSelector('[data-testid="mainpage_title"]', {
timeout: 30000,
});
await page.waitForSelector('[id="new-project-btn"]', {
timeout: 30000,
});
await page.getByTestId("user-profile-settings").click();
await page.getByText("Admin Page", { exact: true }).click();
//CRUD an user
await page.getByText("New User", { exact: true }).click();
await page.getByPlaceholder("Username").last().fill(randomName);
await page.locator('input[name="password"]').fill(randomPassword);
await page.locator('input[name="confirmpassword"]').fill(randomPassword);
await page.waitForSelector("#is_active", {
timeout: 1500,
});
await page.locator("#is_active").click();
await page.getByText("Save", { exact: true }).click();
await page.waitForSelector("text=new user added", { timeout: 30000 });
await expect(page.getByText(randomName, { exact: true })).toBeVisible({
timeout: 2000,
});
await page.waitForSelector("[data-testid='user-profile-settings']", {
timeout: 1500,
});
await page.getByTestId("user-profile-settings").click();
await page.evaluate(() => {
sessionStorage.setItem("testMockAutoLogin", "true");
});
await page.getByText("Logout", { exact: true }).click();
await page.waitForSelector("text=sign in to langflow", { timeout: 30000 });
await page.getByPlaceholder("Username").fill(randomName);
await page.getByPlaceholder("Password").fill(randomPassword);
await page.waitForSelector("text=Sign in", {
timeout: 1500,
});
await page.getByRole("button", { name: "Sign In" }).click();
await page.evaluate(() => {
sessionStorage.removeItem("testMockAutoLogin");
});
};

View file

@ -0,0 +1,56 @@
# Task: Gradient Border Styling Refinement
**Status**: Completed
## Analysis
- [x] Requirements
- [x] Match the gradient border style of the GitHub card shown in the reference image
- [x] Ensure a consistent purple hue that fades from top to bottom
- [x] Maintain proper dark background for content
- [x] Challenges
- [x] Achieving precise gradient fade matching the reference image
- [x] Balancing border visibility while maintaining subtle effect
- [x] Dependencies
- [x] Existing BackgroundGradient component
## Plan
- [x] Step 1: Review the current implementation
- [x] Analyze existing gradient colors and opacity values
- [x] Compare with reference image for differences
- [x] Step 2: Adjust color and gradient parameters
- [x] Modify gradient colors to match the purple hue
- [x] Fine-tune opacity values for natural fade
- [x] Ensure proper background color for inner content
- [x] Step 3: Optimize hover effects
- [x] Refine blur and opacity transitions on hover
- [x] Test with different content types
## Execution
- [x] Modify gradient parameters
- [x] Update gradient direction and opacity stops
- [x] Simplify to single-color fade for consistency
- [x] Adjust container styles
- [x] Set proper padding for border thickness
- [x] Ensure rounded corners match reference
- [x] Test and refine
- [x] Compare implementation with reference image
- [x] Make final adjustments to achieve exact match
## Summary
- [x] Files modified: `src/frontend/src/components/ui/background-gradient.tsx`
- [x] Dependencies added/changed: None
- [x] Edge cases considered: Different screen sizes, container dimensions
- [x] Known limitations: Exact color reproduction may vary slightly based on monitor calibration
- [x] Future impact points: Component can be reused across the application for consistent styling
### Implementation Details
1. Changed the gradient to a simpler purple fade from top to bottom
2. Used opacity values of 0.7 at top and 0.3 at bottom for subtle fade
3. Reduced border thickness to 2px with `p-[2px]`
4. Made the hover effect more subtle with `opacity-60` instead of `opacity-100`
5. Changed blur on hover from `blur-lg` to `blur-md` for a less intense glow