feat(dashboard): integrate MagicUI Terminal and BorderBeam
This commit is contained in:
@@ -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,29 +143,32 @@ export default function LogsPage() {
|
||||
</div>
|
||||
|
||||
{/* Log Viewer Column */}
|
||||
<Card
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
padding={0}
|
||||
>
|
||||
<Terminal
|
||||
sequence={false}
|
||||
className="w-full max-w-none h-full max-h-none border-gray-200 dark:border-gray-800 shadow-sm"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${THEME.borderSoft}`,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: THEME.ink,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
|
||||
@@ -185,21 +189,10 @@ export default function LogsPage() {
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: 16,
|
||||
background: "#0a0a0a",
|
||||
borderBottomLeftRadius: THEME.radius,
|
||||
borderBottomRightRadius: THEME.radius,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "0.8rem",
|
||||
color: "#e5e5e5",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "0.8rem",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
@@ -207,9 +200,9 @@ export default function LogsPage() {
|
||||
{logsLoading && !logs
|
||||
? "Loading..."
|
||||
: logs || "No logs available."}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</Terminal>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Card padding={16}>
|
||||
<Card padding={16} style={{ position: "relative", overflow: "hidden" }}>
|
||||
{/* ── Magic UI Border Beam ── */}
|
||||
{deploying ? (
|
||||
<BorderBeam
|
||||
size={150}
|
||||
duration={4}
|
||||
borderWidth={2}
|
||||
colorFrom="#3b82f6"
|
||||
colorTo="#10b981"
|
||||
/>
|
||||
) : phase === "healthy" ? (
|
||||
<BorderBeam
|
||||
size={200}
|
||||
duration={12}
|
||||
borderWidth={1}
|
||||
colorFrom="#e5e7eb"
|
||||
colorTo="#9ca3af"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
107
vibn-frontend/components/ui/border-beam.tsx
Normal file
107
vibn-frontend/components/ui/border-beam.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { motion, MotionStyle, Transition } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface BorderBeamProps {
|
||||
/**
|
||||
* The size of the border beam.
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* The duration of the border beam.
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* The delay of the border beam.
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* The color of the border beam from.
|
||||
*/
|
||||
colorFrom?: string
|
||||
/**
|
||||
* The color of the border beam to.
|
||||
*/
|
||||
colorTo?: string
|
||||
/**
|
||||
* The motion transition of the border beam.
|
||||
*/
|
||||
transition?: Transition
|
||||
/**
|
||||
* The class name of the border beam.
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* The style of the border beam.
|
||||
*/
|
||||
style?: React.CSSProperties
|
||||
/**
|
||||
* Whether to reverse the animation direction.
|
||||
*/
|
||||
reverse?: boolean
|
||||
/**
|
||||
* The initial offset position (0-100).
|
||||
*/
|
||||
initialOffset?: number
|
||||
/**
|
||||
* The border width of the beam.
|
||||
*/
|
||||
borderWidth?: number
|
||||
}
|
||||
|
||||
export const BorderBeam = ({
|
||||
className,
|
||||
size = 50,
|
||||
delay = 0,
|
||||
duration = 6,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
transition,
|
||||
style,
|
||||
reverse = false,
|
||||
initialOffset = 0,
|
||||
borderWidth = 1,
|
||||
}: BorderBeamProps) => {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent mask-[linear-gradient(transparent,transparent),linear-gradient(#000,#000)] mask-intersect [mask-clip:padding-box,border-box]"
|
||||
style={
|
||||
{
|
||||
"--border-beam-width": `${borderWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute aspect-square",
|
||||
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
width: size,
|
||||
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
...style,
|
||||
} as MotionStyle
|
||||
}
|
||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||
animate={{
|
||||
offsetDistance: reverse
|
||||
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
|
||||
: [`${initialOffset}%`, `${100 + initialOffset}%`],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
duration,
|
||||
delay: -delay,
|
||||
...transition,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
300
vibn-frontend/components/ui/terminal.tsx
Normal file
300
vibn-frontend/components/ui/terminal.tsx
Normal file
@@ -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<SequenceContextValue | null>(null)
|
||||
|
||||
const useSequence = () => useContext(SequenceContext)
|
||||
|
||||
const ItemIndexContext = createContext<number | null>(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<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
||||
>
|
||||
|
||||
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<HTMLDivElement | null>(null)
|
||||
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||
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 (
|
||||
<motion.div
|
||||
ref={elementRef}
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
|
||||
className={cn("grid text-sm font-normal tracking-tight", className)}
|
||||
onAnimationComplete={() => {
|
||||
if (!sequence) return
|
||||
if (itemIndex === null) return
|
||||
sequence.completeItem(itemIndex)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
||||
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<string>("")
|
||||
const [started, setStarted] = useState(false)
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||
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<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
sequenceCompleteItemRef.current = sequence?.completeItem ?? null
|
||||
sequenceItemIndexRef.current = itemIndex
|
||||
}, [sequence?.completeItem, itemIndex])
|
||||
|
||||
useEffect(() => {
|
||||
let startTimeout: ReturnType<typeof setTimeout> | 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<typeof setInterval> | 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 (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn("text-sm font-normal tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
sequence?: boolean
|
||||
startOnView?: boolean
|
||||
}
|
||||
|
||||
export const Terminal = ({
|
||||
children,
|
||||
className,
|
||||
sequence = true,
|
||||
startOnView = true,
|
||||
}: TerminalProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const isInView = useInView(containerRef as React.RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
})
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const sequenceHasStarted = sequence ? !startOnView || isInView : false
|
||||
|
||||
const contextValue = useMemo<SequenceContextValue | null>(() => {
|
||||
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) => (
|
||||
<ItemIndexContext.Provider key={index} value={index}>
|
||||
{child as React.ReactNode}
|
||||
</ItemIndexContext.Provider>
|
||||
))
|
||||
}, [children, sequence])
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"border-border bg-background z-0 h-full max-h-100 w-full max-w-lg rounded-xl border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="border-border flex flex-col gap-y-2 border-b p-4">
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4">
|
||||
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!sequence) return content
|
||||
|
||||
return (
|
||||
<SequenceContext.Provider value={contextValue}>
|
||||
{content}
|
||||
</SequenceContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user