feat(dashboard): integrate MagicUI Terminal and BorderBeam

This commit is contained in:
2026-06-14 12:41:11 -07:00
parent 869098af1e
commit 95f54260c1
4 changed files with 467 additions and 47 deletions

View File

@@ -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() {
</div>
{/* Log Viewer Column */}
<Card
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
position: "relative",
}}
padding={0}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: `1px solid ${THEME.borderSoft}`,
}}
<Terminal
sequence={false}
className="w-full max-w-none h-full max-h-none border-gray-200 dark:border-gray-800 shadow-sm"
>
<span
<div
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: THEME.ink,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
</span>
<SecondaryButton
icon={
logsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)
}
onClick={() => activeUuid && fetchLogs(activeUuid)}
disabled={logsLoading}
>
Refresh
</SecondaryButton>
</div>
<div
style={{
flex: 1,
overflow: "auto",
padding: 16,
background: "#0a0a0a",
borderBottomLeftRadius: THEME.radius,
borderBottomRightRadius: THEME.radius,
}}
>
<pre
<span
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: "#fff",
}}
>
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
</span>
<SecondaryButton
icon={
logsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)
}
onClick={() => activeUuid && fetchLogs(activeUuid)}
disabled={logsLoading}
>
Refresh
</SecondaryButton>
</div>
<div
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>
</div>
</Terminal>
</div>
</div>
)}
</div>

View File

@@ -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",

View 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>
)
}

View 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>
)
}