feat(dashboard): integrate MagicUI Terminal and BorderBeam
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
EmptyState,
|
EmptyState,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from "@/components/project/dashboard-ui";
|
} from "@/components/project/dashboard-ui";
|
||||||
|
import { Terminal } from "@/components/ui/terminal";
|
||||||
|
|
||||||
type LiveApp = Anatomy["hosting"]["live"][number];
|
type LiveApp = Anatomy["hosting"]["live"][number];
|
||||||
|
|
||||||
@@ -142,64 +143,56 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log Viewer Column */}
|
{/* Log Viewer Column */}
|
||||||
<Card
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
padding={0}
|
|
||||||
>
|
>
|
||||||
<div
|
<Terminal
|
||||||
style={{
|
sequence={false}
|
||||||
display: "flex",
|
className="w-full max-w-none h-full max-h-none border-gray-200 dark:border-gray-800 shadow-sm"
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "12px 16px",
|
|
||||||
borderBottom: `1px solid ${THEME.borderSoft}`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
display: "flex",
|
||||||
fontWeight: 600,
|
justifyContent: "space-between",
|
||||||
color: THEME.ink,
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
|
<span
|
||||||
</span>
|
style={{
|
||||||
<SecondaryButton
|
fontSize: "0.85rem",
|
||||||
icon={
|
fontWeight: 600,
|
||||||
logsLoading ? (
|
color: "#fff",
|
||||||
<Loader2 size={14} className="animate-spin" />
|
}}
|
||||||
) : (
|
>
|
||||||
<RefreshCw size={14} />
|
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
|
||||||
)
|
</span>
|
||||||
}
|
<SecondaryButton
|
||||||
onClick={() => activeUuid && fetchLogs(activeUuid)}
|
icon={
|
||||||
disabled={logsLoading}
|
logsLoading ? (
|
||||||
>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
Refresh
|
) : (
|
||||||
</SecondaryButton>
|
<RefreshCw size={14} />
|
||||||
</div>
|
)
|
||||||
<div
|
}
|
||||||
style={{
|
onClick={() => activeUuid && fetchLogs(activeUuid)}
|
||||||
flex: 1,
|
disabled={logsLoading}
|
||||||
overflow: "auto",
|
>
|
||||||
padding: 16,
|
Refresh
|
||||||
background: "#0a0a0a",
|
</SecondaryButton>
|
||||||
borderBottomLeftRadius: THEME.radius,
|
</div>
|
||||||
borderBottomRightRadius: THEME.radius,
|
<div
|
||||||
}}
|
|
||||||
>
|
|
||||||
<pre
|
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "#e5e5e5",
|
color: "#e5e5e5",
|
||||||
fontFamily:
|
fontFamily:
|
||||||
"ui-monospace, SFMono-Regular, Menlo, monospace",
|
"ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
fontSize: "0.8rem",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
wordBreak: "break-all",
|
wordBreak: "break-all",
|
||||||
}}
|
}}
|
||||||
@@ -207,9 +200,9 @@ export default function LogsPage() {
|
|||||||
{logsLoading && !logs
|
{logsLoading && !logs
|
||||||
? "Loading..."
|
? "Loading..."
|
||||||
: logs || "No logs available."}
|
: logs || "No logs available."}
|
||||||
</pre>
|
</div>
|
||||||
</div>
|
</Terminal>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
} from "@/components/project/dashboard-ui";
|
} from "@/components/project/dashboard-ui";
|
||||||
|
import { BorderBeam } from "@/components/ui/border-beam";
|
||||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,7 +233,26 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
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