feat(ui): add ConfirmDialog and StreamingTerminal components
This commit is contained in:
parent
2baf116841
commit
394e8cdde3
2 changed files with 151 additions and 0 deletions
62
components/ConfirmDialog.tsx
Normal file
62
components/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
title?: string
|
||||
commandPreview: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title = 'Confirm action',
|
||||
commandPreview,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-background p-6 shadow-lg">
|
||||
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The following command will be executed on the server:
|
||||
</p>
|
||||
|
||||
<pre className="mt-3 overflow-x-auto rounded-lg border border-border bg-muted/50 px-4 py-3 font-mono text-xs text-foreground select-all">
|
||||
{commandPreview}
|
||||
</pre>
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This action cannot be undone. Review the command above before confirming.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-destructive/10 border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Running…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
components/StreamingTerminal.tsx
Normal file
89
components/StreamingTerminal.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export type TerminalLine = {
|
||||
type: 'stdout' | 'stderr'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type TerminalStatus = 'idle' | 'running' | 'done' | 'failed' | 'error'
|
||||
|
||||
type Props = {
|
||||
lines: TerminalLine[]
|
||||
status: TerminalStatus
|
||||
error?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function StreamingTerminal({ lines, status, error, className = '' }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [lines])
|
||||
|
||||
const statusBar = () => {
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground border-t border-border">
|
||||
<span className="inline-block size-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
Running…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-green-400 border-t border-border">
|
||||
<span className="inline-block size-2 rounded-full bg-green-400" />
|
||||
Completed successfully
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
|
||||
<span className="inline-block size-2 rounded-full bg-red-500" />
|
||||
Exited with error
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
|
||||
<span className="inline-block size-2 rounded-full bg-red-500" />
|
||||
{error ?? 'Unknown error'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex flex-col rounded-lg border border-border bg-zinc-950 font-mono text-xs overflow-hidden ' +
|
||||
className
|
||||
}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto max-h-96 p-3 space-y-0">
|
||||
{lines.length === 0 && status === 'running' && (
|
||||
<span className="text-zinc-500 animate-pulse">Waiting for output…</span>
|
||||
)}
|
||||
{lines.map((line, i) => (
|
||||
<pre
|
||||
key={i}
|
||||
className={
|
||||
'whitespace-pre-wrap break-all leading-5 ' +
|
||||
(line.type === 'stderr' ? 'text-red-400' : 'text-zinc-100')
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</pre>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{statusBar()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue