diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx index ac49382..97cdb35 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/logs/page.tsx @@ -11,6 +11,7 @@ import { EmptyState, SecondaryButton, } from "@/components/project/dashboard-ui"; +import { Terminal } from "@/components/ui/terminal"; type LiveApp = Anatomy["hosting"]["live"][number]; @@ -142,64 +143,56 @@ export default function LogsPage() { {/* Log Viewer Column */} - -
- - {live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"} - - - ) : ( - - ) - } - onClick={() => activeUuid && fetchLogs(activeUuid)} - disabled={logsLoading} - > - Refresh - -
-
-
+                    {live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
+                  
+                  
+                      ) : (
+                        
+                      )
+                    }
+                    onClick={() => activeUuid && fetchLogs(activeUuid)}
+                    disabled={logsLoading}
+                  >
+                    Refresh
+                  
+                
+
-
-
+ + + )} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx index d8e60be..55ba5eb 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx @@ -26,6 +26,7 @@ import { SecondaryButton, StatusDot, } from "@/components/project/dashboard-ui"; +import { BorderBeam } from "@/components/ui/border-beam"; import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; /** @@ -232,7 +233,26 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { }; return ( - + + {/* ── Magic UI Border Beam ── */} + {deploying ? ( + + ) : phase === "healthy" ? ( + + ) : null} +
{ + return ( +
+ +
+ ) +} diff --git a/vibn-frontend/components/ui/terminal.tsx b/vibn-frontend/components/ui/terminal.tsx new file mode 100644 index 0000000..0370980 --- /dev/null +++ b/vibn-frontend/components/ui/terminal.tsx @@ -0,0 +1,300 @@ +"use client" + +import { + Children, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, + type RefAttributes, +} from "react" +import { + motion, + useInView, + type DOMMotionComponents, + type HTMLMotionProps, + type MotionProps, +} from "motion/react" + +import { cn } from "@/lib/utils" + +interface SequenceContextValue { + completeItem: (index: number) => void + activeIndex: number + sequenceStarted: boolean +} + +const SequenceContext = createContext(null) + +const useSequence = () => useContext(SequenceContext) + +const ItemIndexContext = createContext(null) +const useItemIndex = () => useContext(ItemIndexContext) + +const motionElements = { + article: motion.article, + div: motion.div, + h1: motion.h1, + h2: motion.h2, + h3: motion.h3, + h4: motion.h4, + h5: motion.h5, + h6: motion.h6, + li: motion.li, + p: motion.p, + section: motion.section, + span: motion.span, +} as const + +type MotionElementType = Extract< + keyof DOMMotionComponents, + keyof typeof motionElements +> +type TerminalTypingMotionComponent = ComponentType< + Omit, "ref"> & RefAttributes +> + +interface AnimatedSpanProps extends MotionProps { + children: React.ReactNode + delay?: number + className?: string + startOnView?: boolean +} + +export const AnimatedSpan = ({ + children, + delay = 0, + className, + startOnView = false, + ...props +}: AnimatedSpanProps) => { + const elementRef = useRef(null) + const isInView = useInView(elementRef as React.RefObject, { + amount: 0.3, + once: true, + }) + + const sequence = useSequence() + const itemIndex = useItemIndex() + const [hasStarted, setHasStarted] = useState(false) + useEffect(() => { + if (!sequence || itemIndex === null) return + if (!sequence.sequenceStarted) return + if (hasStarted) return + if (sequence.activeIndex === itemIndex) { + setHasStarted(true) + } + }, [sequence, hasStarted, itemIndex]) + + const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true + + return ( + { + if (!sequence) return + if (itemIndex === null) return + sequence.completeItem(itemIndex) + }} + {...props} + > + {children} + + ) +} + +interface TypingAnimationProps extends Omit { + children: string + className?: string + duration?: number + delay?: number + as?: MotionElementType + startOnView?: boolean +} + +export const TypingAnimation = ({ + children, + className, + duration = 60, + delay = 0, + as: Component = "span", + startOnView = true, + ...props +}: TypingAnimationProps) => { + if (typeof children !== "string") { + throw new Error("TypingAnimation: children must be a string. Received:") + } + + const MotionComponent = motionElements[ + Component + ] as TerminalTypingMotionComponent + + const [displayedText, setDisplayedText] = useState("") + const [started, setStarted] = useState(false) + const elementRef = useRef(null) + const isInView = useInView(elementRef as React.RefObject, { + amount: 0.3, + once: true, + }) + + const sequence = useSequence() + const itemIndex = useItemIndex() + const hasSequence = sequence !== null + const sequenceStarted = sequence?.sequenceStarted ?? false + const sequenceActiveIndex = sequence?.activeIndex ?? null + const sequenceCompleteItemRef = useRef< + SequenceContextValue["completeItem"] | null + >(null) + const sequenceItemIndexRef = useRef(null) + + useEffect(() => { + sequenceCompleteItemRef.current = sequence?.completeItem ?? null + sequenceItemIndexRef.current = itemIndex + }, [sequence?.completeItem, itemIndex]) + + useEffect(() => { + let startTimeout: ReturnType | null = null + + if (hasSequence && itemIndex !== null) { + if (sequenceStarted && !started && sequenceActiveIndex === itemIndex) { + setStarted(true) + } + } else if (!startOnView || isInView) { + startTimeout = setTimeout(() => setStarted(true), delay) + } + + return () => { + if (startTimeout !== null) { + clearTimeout(startTimeout) + } + } + }, [ + delay, + startOnView, + isInView, + started, + hasSequence, + sequenceActiveIndex, + sequenceStarted, + itemIndex, + ]) + + useEffect(() => { + let typingEffect: ReturnType | null = null + + if (started) { + let i = 0 + typingEffect = setInterval(() => { + if (i < children.length) { + setDisplayedText(children.substring(0, i + 1)) + i++ + } else { + if (typingEffect !== null) { + clearInterval(typingEffect) + } + const completeItem = sequenceCompleteItemRef.current + const currentItemIndex = sequenceItemIndexRef.current + if (completeItem && currentItemIndex !== null) { + completeItem(currentItemIndex) + } + } + }, duration) + } + + return () => { + if (typingEffect !== null) { + clearInterval(typingEffect) + } + } + }, [children, duration, started]) + + return ( + + {displayedText} + + ) +} + +interface TerminalProps { + children: React.ReactNode + className?: string + sequence?: boolean + startOnView?: boolean +} + +export const Terminal = ({ + children, + className, + sequence = true, + startOnView = true, +}: TerminalProps) => { + const containerRef = useRef(null) + const isInView = useInView(containerRef as React.RefObject, { + amount: 0.3, + once: true, + }) + + const [activeIndex, setActiveIndex] = useState(0) + const sequenceHasStarted = sequence ? !startOnView || isInView : false + + const contextValue = useMemo(() => { + if (!sequence) return null + return { + completeItem: (index: number) => { + setActiveIndex((current) => (index === current ? current + 1 : current)) + }, + activeIndex, + sequenceStarted: sequenceHasStarted, + } + }, [sequence, activeIndex, sequenceHasStarted]) + + const wrappedChildren = useMemo(() => { + if (!sequence) return children + const array = Children.toArray(children) + return array.map((child, index) => ( + + {child as React.ReactNode} + + )) + }, [children, sequence]) + + const content = ( +
+
+
+
+
+
+
+
+
+        {wrappedChildren}
+      
+
+ ) + + if (!sequence) return content + + return ( + + {content} + + ) +}