feat: add new onboarding animation, adds onboarding into layout wrapper (#267)

* modularized header

* removed onboarding redirection

* added animations

* Made animations slide instead of grow

* change name of grow to slide

* Added constants

* Fixed animations, added onboarding content to card, switch display depending on current state of onboarding

* fixed initial animation

* fixed animation glitch

* removed debug settimeout

* fixed width

* Format
This commit is contained in:
Lucas Oliveira 2025-10-16 16:38:08 -03:00 committed by GitHub
parent c71cfedbe6
commit 6a971d45d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 639 additions and 435 deletions

View file

@ -1,147 +1,148 @@
"use client";
import { AlertTriangle, ExternalLink, Copy } from "lucide-react";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
import { Banner, BannerIcon, BannerTitle, BannerAction } from "@/components/ui/banner";
import { AlertTriangle, Copy, ExternalLink } from "lucide-react";
import { useState } from "react";
import {
Banner,
BannerAction,
BannerIcon,
BannerTitle,
} from "@/components/ui/banner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HEADER_HEIGHT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
interface DoclingHealthBannerProps {
className?: string;
className?: string;
}
// DoclingSetupDialog component
interface DoclingSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
}
function DoclingSetupDialog({
open,
onOpenChange,
className
open,
onOpenChange,
className,
}: DoclingSetupDialogProps) {
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>
Start docling-serve by running:
</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<DialogDescription>
Then, select <span className="font-semibold text-foreground">Start All Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogDescription>
Then, select{" "}
<span className="font-semibold text-foreground">
Start All Services
</span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogFooter>
<Button
variant="default"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Custom hook to check docling health status
export function useDoclingHealth() {
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const isHealthy = health?.status === "healthy" && !isError;
const isUnhealthy = health?.status === "unhealthy" || isError;
const isHealthy = health?.status === "healthy" && !isError;
const isUnhealthy = health?.status === "unhealthy" || isError;
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
};
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
};
}
export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
"bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800",
className
)}
>
<BannerIcon
icon={AlertTriangle}
/>
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
`bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800`,
className,
)}
>
<BannerIcon icon={AlertTriangle} />
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is
unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
<DoclingSetupDialog
open={showDialog}
onOpenChange={setShowDialog}
/>
</>
);
}
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</>
);
}
return null;
}
return null;
}

View file

@ -109,15 +109,12 @@
@layer components {
.app-grid-arrangement {
--sidebar-width: 0px;
--notifications-width: 0px;
--filters-width: 0px;
--app-header-height: 53px;
--top-banner-height: 0px;
--header-height: 54px;
--sidebar-width: 280px;
@media (width >= 48rem) {
--sidebar-width: 288px;
}
&.notifications-open {
--notifications-width: 320px;
}
@ -132,7 +129,7 @@
width: 100%;
grid-template-rows:
var(--top-banner-height)
var(--app-header-height)
var(--header-height)
1fr;
grid-template-columns:
var(--sidebar-width)
@ -147,10 +144,6 @@
grid-template-rows 0.25s ease-in-out;
}
.header-arrangement {
@apply flex w-full items-center justify-between border-b border-border;
}
.header-start-display {
@apply flex items-center gap-2;
}

View file

@ -9,37 +9,28 @@ import { Button } from "@/components/ui/button";
import { DotPattern } from "@/components/ui/dot-pattern";
import { useAuth } from "@/contexts/auth-context";
import { cn } from "@/lib/utils";
import { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery";
function LoginPageContent() {
const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const redirect =
settings && !settings.edited
? "/onboarding"
: searchParams.get("redirect") || "/chat";
const redirect = searchParams.get("redirect") || "/chat";
// Redirect if already authenticated or in no-auth mode
useEffect(() => {
if (!isLoading && !isSettingsLoading && (isAuthenticated || isNoAuthMode)) {
if (!isLoading && (isAuthenticated || isNoAuthMode)) {
router.push(redirect);
}
}, [
isLoading,
isSettingsLoading,
isAuthenticated,
isNoAuthMode,
router,
redirect,
]);
if (isLoading || isSettingsLoading) {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">

View file

@ -0,0 +1,78 @@
"use client";
import OnboardingCard from "@/app/onboarding/components/onboarding-card";
import { OnboardingStep } from "./onboarding-step";
export function OnboardingContent({
handleStepComplete,
currentStep,
}: {
handleStepComplete: () => void;
currentStep: number;
}) {
return (
<div className="space-y-6">
<OnboardingStep
isVisible={currentStep >= 0}
isCompleted={currentStep > 0}
text="Let's get started by setting up your model provider."
>
<OnboardingCard onComplete={handleStepComplete} />
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 1}
isCompleted={currentStep > 1}
text="Step 1: Configure your settings"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Let's configure some basic settings for your account.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 2}
isCompleted={currentStep > 2}
text="Step 2: Connect your model"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Choose and connect your preferred AI model provider.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 3}
isCompleted={currentStep > 3}
text="Step 3: You're all set!"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Your account is ready to use. Let's start chatting!
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Go to Chat
</button>
</div>
</OnboardingStep>
</div>
);
}

View file

@ -1,78 +1,90 @@
import { ReactNode, useEffect, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { Message } from "@/app/chat/components/message";
import DogIcon from "@/components/logo/dog-icon";
interface OnboardingStepProps {
text: string;
children: ReactNode;
isVisible: boolean;
isCompleted?: boolean;
text: string;
children: ReactNode;
isVisible: boolean;
isCompleted?: boolean;
}
export function OnboardingStep({ text, children, isVisible, isCompleted = false }: OnboardingStepProps) {
const [displayedText, setDisplayedText] = useState("");
const [showChildren, setShowChildren] = useState(false);
export function OnboardingStep({
text,
children,
isVisible,
isCompleted = false,
}: OnboardingStepProps) {
const [displayedText, setDisplayedText] = useState("");
const [showChildren, setShowChildren] = useState(false);
useEffect(() => {
if (!isVisible) {
setDisplayedText("");
setShowChildren(false);
return;
}
useEffect(() => {
if (!isVisible) {
setDisplayedText("");
setShowChildren(false);
return;
}
let currentIndex = 0;
setDisplayedText("");
setShowChildren(false);
let currentIndex = 0;
setDisplayedText("");
setShowChildren(false);
const interval = setInterval(() => {
if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(interval);
setShowChildren(true);
}
}, 10); // 10ms per character
const interval = setInterval(() => {
if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(interval);
setShowChildren(true);
}
}, 20); // 20ms per character
return () => clearInterval(interval);
}, [text, isVisible]);
return () => clearInterval(interval);
}, [text, isVisible]);
if (!isVisible) return null;
if (!isVisible) return null;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={isCompleted ? "opacity-50" : ""}
>
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon className="h-6 w-6 text-accent-foreground" disabled={isCompleted} />
</div>
}
>
<div className="space-y-4">
<p className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`}>
{displayedText}
{!showChildren && !isCompleted && <span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" />}
</p>
<AnimatePresence>
{showChildren && !isCompleted && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</Message>
</motion.div>
);
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
className={isCompleted ? "opacity-50" : ""}
>
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 text-accent-foreground"
disabled={isCompleted}
/>
</div>
}
>
<div className="space-y-4">
<p
className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`}
>
{displayedText}
{!showChildren && !isCompleted && (
<span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" />
)}
</p>
<AnimatePresence>
{showChildren && !isCompleted && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
</Message>
</motion.div>
);
}

View file

@ -8,8 +8,8 @@ export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) {
return (
<div className="w-full">
<div className="flex items-center max-w-md mx-auto gap-3">
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
<div className="flex items-center max-w-48 mx-auto gap-3">
<div className="flex-1 h-1 bg-background rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 ease-in-out"
style={{
@ -18,7 +18,7 @@ export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) {
}}
/>
</div>
<span className="text-sm text-muted-foreground whitespace-nowrap">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{currentStep + 1}/{totalSteps}
</span>
</div>

View file

@ -1,109 +1,48 @@
"use client";
import { Suspense, useState } from "react";
import { ProtectedRoute } from "@/components/protected-route";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { ProtectedRoute } from "@/components/protected-route";
import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils";
import OnboardingCard from "../onboarding/components/onboarding-card";
import { OnboardingContent } from "./components/onboarding-content";
import { OnboardingStep } from "./components/onboarding-step";
import { ProgressBar } from "./components/progress-bar";
import OnboardingCard from "../onboarding/components/onboarding-card";
const TOTAL_STEPS = 4;
function NewOnboardingPage() {
const [currentStep, setCurrentStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const handleStepComplete = () => {
if (currentStep < TOTAL_STEPS - 1) {
setCurrentStep(currentStep + 1);
}
};
const handleStepComplete = () => {
if (currentStep < TOTAL_STEPS - 1) {
setCurrentStep(currentStep + 1);
}
};
return (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-primary-foreground relative p-4">
<DoclingHealthBanner className="absolute top-0 left-0 right-0 w-full z-20" />
return (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-primary-foreground relative p-4">
<DoclingHealthBanner className="absolute top-0 left-0 right-0 w-full z-20" />
{/* Chat-like content area */}
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
<div className="space-y-6">
<OnboardingStep
isVisible={currentStep >= 0}
isCompleted={currentStep > 0}
text="Let's get started by setting up your model provider."
>
<OnboardingCard onComplete={handleStepComplete} />
</OnboardingStep>
{/* Chat-like content area */}
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
<OnboardingContent />
</div>
<OnboardingStep
isVisible={currentStep >= 1}
isCompleted={currentStep > 1}
text="Step 1: Configure your settings"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Let's configure some basic settings for your account.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 2}
isCompleted={currentStep > 2}
text="Step 2: Connect your model"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Choose and connect your preferred AI model provider.
</p>
<button
onClick={handleStepComplete}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Continue
</button>
</div>
</OnboardingStep>
<OnboardingStep
isVisible={currentStep >= 3}
isCompleted={currentStep > 3}
text="Step 3: You're all set!"
>
<div className="space-y-4">
<p className="text-muted-foreground">
Your account is ready to use. Let's start chatting!
</p>
<button
onClick={() => window.location.href = "/chat"}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Go to Chat
</button>
</div>
</OnboardingStep>
</div>
</div>
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
</div>
</div>
);
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
</div>
</div>
);
}
export default function ProtectedNewOnboardingPage() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading...</div>}>
<NewOnboardingPage />
</Suspense>
</ProtectedRoute>
);
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading...</div>}>
<NewOnboardingPage />
</Suspense>
</ProtectedRoute>
);
}

View file

@ -0,0 +1,53 @@
import { motion } from "framer-motion";
import { ANIMATION_DURATION } from "@/lib/constants";
export const AnimatedConditional = ({
children,
isOpen,
className,
slide = false,
delay,
vertical = false,
}: {
children: React.ReactNode;
isOpen: boolean;
className?: string;
delay?: number;
vertical?: boolean;
slide?: boolean;
}) => {
const animationProperty = slide
? vertical
? "translateY"
: "translateX"
: vertical
? "height"
: "width";
const animationValue = isOpen
? slide
? "0px"
: "auto"
: slide
? "-100%"
: "0px";
return (
<motion.div
initial={{ [animationProperty]: animationValue }}
animate={{ [animationProperty]: animationValue }}
exit={{ [animationProperty]: 0 }}
transition={{
duration: ANIMATION_DURATION,
ease: "easeOut",
delay: delay,
}}
style={{
overflow: "hidden",
whiteSpace: vertical ? "normal" : "nowrap",
}}
className={className}
>
{children}
</motion.div>
);
};

View file

@ -0,0 +1,180 @@
"use client";
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
type ChatConversation,
useGetConversationsQuery,
} from "@/app/api/queries/useGetConversationsQuery";
import type { Settings } from "@/app/api/queries/useGetSettingsQuery";
import { OnboardingContent } from "@/app/new-onboarding/components/onboarding-content";
import { ProgressBar } from "@/app/new-onboarding/components/progress-bar";
import { AnimatedConditional } from "@/components/animated-conditional";
import { Header } from "@/components/header";
import { Navigation } from "@/components/navigation";
import { useAuth } from "@/contexts/auth-context";
import { useChat } from "@/contexts/chat-context";
import {
ANIMATION_DURATION,
HEADER_HEIGHT,
SIDEBAR_WIDTH,
TOTAL_ONBOARDING_STEPS,
} from "@/lib/constants";
import { cn } from "@/lib/utils";
export function ChatRenderer({
settings,
children,
}: {
settings: Settings;
children: React.ReactNode;
}) {
const pathname = usePathname();
const { isAuthenticated, isNoAuthMode } = useAuth();
const {
endpoint,
refreshTrigger,
refreshConversations,
startNewConversation,
} = useChat();
// Onboarding animation state
const [showLayout, setShowLayout] = useState(!!settings?.edited);
// Only fetch conversations on chat page
const isOnChatPage = pathname === "/" || pathname === "/chat";
const { data: conversations = [], isLoading: isConversationsLoading } =
useGetConversationsQuery(endpoint, refreshTrigger, {
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
}) as { data: ChatConversation[]; isLoading: boolean };
const handleNewConversation = () => {
refreshConversations();
startNewConversation();
};
const [currentStep, setCurrentStep] = useState(0);
const handleStepComplete = () => {
if (currentStep < TOTAL_ONBOARDING_STEPS - 1) {
setCurrentStep(currentStep + 1);
} else {
setShowLayout(true);
}
};
// List of paths with smaller max-width
const smallWidthPaths = ["/settings/connector/new"];
const isSmallWidthPath = smallWidthPaths.includes(pathname);
const x = showLayout ? "0px" : `calc(-${SIDEBAR_WIDTH / 2}px + 50vw)`;
const y = showLayout ? "0px" : `calc(-${HEADER_HEIGHT / 2}px + 50vh)`;
const translateY = showLayout ? "0px" : `-50vh`;
const translateX = showLayout ? "0px" : `-50vw`;
// For all other pages, render with Langflow-styled navigation and task menu
return (
<>
<AnimatedConditional
className="[grid-area:header] bg-background border-b"
vertical
slide
isOpen={showLayout}
delay={ANIMATION_DURATION / 2}
>
<Header />
</AnimatedConditional>
{/* Sidebar Navigation */}
<AnimatedConditional
isOpen={showLayout}
slide
className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`}
>
<Navigation
conversations={conversations}
isConversationsLoading={isConversationsLoading}
onNewConversation={handleNewConversation}
/>
</AnimatedConditional>
{/* Main Content */}
<main className="overflow-visible w-full flex items-center justify-center [grid-area:main]">
<motion.div
initial={{
width: showLayout ? "100%" : "100vw",
height: showLayout ? "100%" : "100vh",
x: x,
y: y,
translateX: translateX,
translateY: translateY,
}}
animate={{
width: showLayout ? "100%" : "850px",
borderRadius: showLayout ? "0" : "16px",
border: showLayout ? "0" : "1px solid #27272A",
height: showLayout ? "100%" : "800px",
x: x,
y: y,
translateX: translateX,
translateY: translateY,
}}
transition={{
duration: ANIMATION_DURATION,
ease: "easeOut",
}}
className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
!showLayout && "absolute",
)}
>
<div
className={cn(
"h-full bg-background",
showLayout && "p-6 container",
showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
!showLayout &&
"w-full bg-card rounded-lg shadow-2xl p-8 overflow-y-auto",
)}
>
<motion.div
initial={{
opacity: showLayout ? 1 : 0,
}}
animate={{
opacity: "100%",
}}
transition={{
duration: ANIMATION_DURATION,
ease: "easeOut",
delay: ANIMATION_DURATION,
}}
className={cn("w-full h-full 0v")}
>
<div className={cn("w-full h-full", !showLayout && "hidden")}>
{children}
</div>
{!showLayout && (
<OnboardingContent
handleStepComplete={handleStepComplete}
currentStep={currentStep}
/>
)}
</motion.div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
className={cn("absolute bottom-10 left-0 right-0")}
>
<ProgressBar
currentStep={currentStep}
totalSteps={TOTAL_ONBOARDING_STEPS}
/>
</motion.div>
</main>
</>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import { Bell } from "lucide-react";
import Logo from "@/components/logo/logo";
import { UserNav } from "@/components/user-nav";
import { useTask } from "@/contexts/task-context";
import { cn } from "@/lib/utils";
export function Header() {
const { tasks, toggleMenu } = useTask();
// Calculate active tasks for the bell icon
const activeTasks = tasks.filter(
(task) =>
task.status === "pending" ||
task.status === "running" ||
task.status === "processing",
);
return (
<header className={cn(`flex w-full h-full items-center justify-between`)}>
<div className="header-start-display px-[16px]">
{/* Logo/Title */}
<div className="flex items-center">
<Logo className="fill-primary" width={24} height={22} />
<span className="text-lg font-semibold pl-2.5">OpenRAG</span>
</div>
</div>
<div className="header-end-division">
<div className="justify-end flex items-center">
{/* Knowledge Filter Dropdown */}
{/* <KnowledgeFilterDropdown
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/> */}
{/* GitHub Star Button */}
{/* <GitHubStarButton repo="phact/openrag" /> */}
{/* Discord Link */}
{/* <DiscordLink inviteCode="EqksyE2EX9" /> */}
{/* Task Notification Bell */}
<button
onClick={toggleMenu}
className="relative h-8 w-8 hover:bg-muted rounded-lg flex items-center justify-center"
>
<Bell size={16} className="text-muted-foreground" />
{activeTasks.length > 0 && <div className="header-notifications" />}
</button>
{/* Separator */}
<div className="w-px h-6 bg-border mx-3" />
<UserNav />
</div>
</div>
</header>
);
}

View file

@ -1,39 +1,25 @@
"use client";
import { Bell, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { usePathname } from "next/navigation";
import {
useGetConversationsQuery,
type ChatConversation,
} from "@/app/api/queries/useGetConversationsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
import Logo from "@/components/logo/logo";
import { Navigation } from "@/components/navigation";
import { TaskNotificationMenu } from "@/components/task-notification-menu";
import { UserNav } from "@/components/user-nav";
import { useAuth } from "@/contexts/auth-context";
import { useChat } from "@/contexts/chat-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
// import { GitHubStarButton } from "@/components/github-star-button"
// import { DiscordLink } from "@/components/discord-link"
import { useTask } from "@/contexts/task-context";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
import { ChatRenderer } from "./chat-renderer";
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { tasks, isMenuOpen, toggleMenu } = useTask();
const { isMenuOpen } = useTask();
const { isPanelOpen } = useKnowledgeFilter();
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
const {
endpoint,
refreshTrigger,
refreshConversations,
startNewConversation,
} = useChat();
const { isLoading: isSettingsLoading } = useGetSettingsQuery({
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const {
@ -42,40 +28,16 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
isError,
} = useDoclingHealthQuery();
// Only fetch conversations on chat page
const isOnChatPage = pathname === "/" || pathname === "/chat";
const { data: conversations = [], isLoading: isConversationsLoading } =
useGetConversationsQuery(endpoint, refreshTrigger, {
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
}) as { data: ChatConversation[]; isLoading: boolean };
const handleNewConversation = () => {
refreshConversations();
startNewConversation();
};
// List of paths that should not show navigation
const authPaths = ["/login", "/auth/callback", "/onboarding", "/new-onboarding"];
const authPaths = ["/login", "/auth/callback"];
const isAuthPage = authPaths.includes(pathname);
const isOnKnowledgePage = pathname.startsWith("/knowledge");
// List of paths with smaller max-width
const smallWidthPaths = ["/settings/connector/new"];
const isSmallWidthPath = smallWidthPaths.includes(pathname);
// Calculate active tasks for the bell icon
const activeTasks = tasks.filter(
task =>
task.status === "pending" ||
task.status === "running" ||
task.status === "processing"
);
const isUnhealthy = health?.status === "unhealthy" || isError;
const isBannerVisible = !isHealthLoading && isUnhealthy;
// Show loading state when backend isn't ready
if (isLoading || isSettingsLoading) {
if (isLoading || isSettingsLoading || !settings) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
@ -93,88 +55,31 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
// For all other pages, render with Langflow-styled navigation and task menu
return (
<div
className={cn(
"app-grid-arrangement",
isBannerVisible && "banner-visible",
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
isMenuOpen && "notifications-open"
)}
>
<div className="w-full [grid-area:banner]">
<DoclingHealthBanner className="w-full" />
<div className=" h-screen w-screen flex items-center justify-center">
<div
className={cn(
"app-grid-arrangement bg-black relative",
isBannerVisible && "banner-visible",
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
isMenuOpen && "notifications-open",
)}
>
<div className={`w-full z-10 bg-background [grid-area:banner]`}>
<DoclingHealthBanner className="w-full" />
</div>
<ChatRenderer settings={settings}>{children}</ChatRenderer>
{/* Task Notifications Panel */}
<aside className="overflow-y-auto overflow-x-hidden [grid-area:notifications]">
{isMenuOpen && <TaskNotificationMenu />}
</aside>
{/* Knowledge Filter Panel */}
<aside className="overflow-y-auto overflow-x-hidden [grid-area:filters]">
{isPanelOpen && <KnowledgeFilterPanel />}
</aside>
</div>
<header className="header-arrangement bg-background [grid-area:header]">
<div className="header-start-display px-[16px]">
{/* Logo/Title */}
<div className="flex items-center">
<Logo className="fill-primary" width={24} height={22} />
<span className="text-lg font-semibold pl-2.5">OpenRAG</span>
</div>
</div>
<div className="header-end-division">
<div className="justify-end flex items-center">
{/* Knowledge Filter Dropdown */}
{/* <KnowledgeFilterDropdown
selectedFilter={selectedFilter}
onFilterSelect={setSelectedFilter}
/> */}
{/* GitHub Star Button */}
{/* <GitHubStarButton repo="phact/openrag" /> */}
{/* Discord Link */}
{/* <DiscordLink inviteCode="EqksyE2EX9" /> */}
{/* Task Notification Bell */}
<button
onClick={toggleMenu}
className="relative h-8 w-8 hover:bg-muted rounded-lg flex items-center justify-center"
>
<Bell size={16} className="text-muted-foreground" />
{activeTasks.length > 0 && (
<div className="header-notifications" />
)}
</button>
{/* Separator */}
<div className="w-px h-6 bg-border mx-3" />
<UserNav />
</div>
</div>
</header>
{/* Sidebar Navigation */}
<aside className="bg-background border-r overflow-hidden [grid-area:nav]">
<Navigation
conversations={conversations}
isConversationsLoading={isConversationsLoading}
onNewConversation={handleNewConversation}
/>
</aside>
{/* Main Content */}
<main className="overflow-y-auto [grid-area:main]">
<div
className={cn(
"p-6 h-full container",
isSmallWidthPath && "max-w-[850px] ml-0"
)}
>
{children}
</div>
</main>
{/* Task Notifications Panel */}
<aside className="overflow-y-auto overflow-x-hidden [grid-area:notifications]">
{isMenuOpen && <TaskNotificationMenu />}
</aside>
{/* Knowledge Filter Panel */}
<aside className="overflow-y-auto overflow-x-hidden [grid-area:filters]">
{isPanelOpen && <KnowledgeFilterPanel />}
</aside>
</div>
);
}

View file

@ -3,7 +3,6 @@
import { Loader2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
interface ProtectedRouteProps {
@ -12,10 +11,6 @@ interface ProtectedRouteProps {
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
const { data: settings = {}, isLoading: isSettingsLoading } =
useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const router = useRouter();
const pathname = usePathname();
@ -31,30 +26,22 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
);
useEffect(() => {
if (!isLoading && !isSettingsLoading && !isAuthenticated && !isNoAuthMode) {
if (!isLoading && !isAuthenticated && !isNoAuthMode) {
// Redirect to login with current path as redirect parameter
const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`;
router.push(redirectUrl);
return;
}
if (!isLoading && !isSettingsLoading && !settings.edited) {
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
router.push(updatedOnboarding ? "/new-onboarding" : "/onboarding");
}
}, [
isLoading,
isSettingsLoading,
isAuthenticated,
isNoAuthMode,
router,
pathname,
isSettingsLoading,
settings.edited,
]);
// Show loading state while checking authentication
if (isLoading || isSettingsLoading) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">

View file

@ -22,4 +22,9 @@ export const DEFAULT_KNOWLEDGE_SETTINGS = {
*/
export const UI_CONSTANTS = {
MAX_SYSTEM_PROMPT_CHARS: 2000,
} as const;
} as const;
export const ANIMATION_DURATION = 0.4;
export const SIDEBAR_WIDTH = 280;
export const HEADER_HEIGHT = 54;
export const TOTAL_ONBOARDING_STEPS = 4;