mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 06:44:58 +00:00
feat: add data-specific ui components
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
|
||||
import { PageDefinition } from '@/lib/rendering/page/page-renderer'
|
||||
import { Eye, Layout, ShieldCheck } from '@phosphor-icons/react'
|
||||
|
||||
interface GenericPagePreviewProps {
|
||||
page: PageDefinition
|
||||
updatedAt?: string
|
||||
footerText?: string
|
||||
}
|
||||
|
||||
const layoutCopy: Record<PageDefinition['layout'], string> = {
|
||||
default: 'Default layout with header and footer',
|
||||
sidebar: 'Sidebar layout with navigation',
|
||||
dashboard: 'Dashboard layout with widgets',
|
||||
blank: 'Blank canvas for custom layouts'
|
||||
}
|
||||
|
||||
export function Preview({ page, updatedAt, footerText }: GenericPagePreviewProps) {
|
||||
const showHeader = page.metadata?.showHeader !== false
|
||||
const showFooter = page.metadata?.showFooter !== false
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye size={20} weight="duotone" />
|
||||
Page preview
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 text-muted-foreground">
|
||||
<Layout size={16} />
|
||||
{layoutCopy[page.layout]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{page.description || 'No description provided.'}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
Level {page.level}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{page.components.length} components</Badge>
|
||||
{page.permissions?.requiresAuth && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck size={14} />
|
||||
Auth required{page.permissions?.requiredRole ? ` (${page.permissions.requiredRole})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{updatedAt && <span>Last updated {updatedAt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge>{page.metadata?.headerTitle || page.title}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 shadow-inner">
|
||||
{showHeader && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm">
|
||||
<span className="font-semibold">Header</span>
|
||||
<Badge variant="outline">{page.metadata?.headerTitle || 'Default title'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`grid gap-3 ${page.layout === 'dashboard' ? 'lg:grid-cols-3 md:grid-cols-2 grid-cols-1' : ''} ${
|
||||
page.layout === 'sidebar' ? 'lg:grid-cols-[240px_1fr]' : ''
|
||||
}`}
|
||||
>
|
||||
{page.layout === 'sidebar' && (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
Sidebar navigation
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 rounded-md border border-dashed border-border/60 bg-background p-3">
|
||||
<p className="text-sm font-semibold">Component tree</p>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{page.components.slice(0, 4).map(component => (
|
||||
<div key={component.id} className="rounded border bg-muted/40 p-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-foreground">{component.type}</span>
|
||||
{component.children && component.children.length > 0 && (
|
||||
<Badge variant="outline">{component.children.length} children</Badge>
|
||||
)}
|
||||
</div>
|
||||
{component.props?.className && <p className="line-clamp-1">{component.props.className}</p>}
|
||||
</div>
|
||||
))}
|
||||
{page.components.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Add components to see them previewed here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFooter && (
|
||||
<div className="mt-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
|
||||
<span>Footer</span>
|
||||
<Badge variant="secondary">{footerText || 'Configured in metadata'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm text-muted-foreground md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-foreground">Lua hooks</p>
|
||||
<p>onLoad: {page.luaScripts?.onLoad || 'Not configured'}</p>
|
||||
<p>onUnload: {page.luaScripts?.onUnload || 'Not configured'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-foreground">Metadata</p>
|
||||
<p>Header actions: {page.metadata?.headerActions?.length ?? 0}</p>
|
||||
<p>Sidebar items: {page.metadata?.sidebarItems?.length ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea, Separator } from '@/components/ui'
|
||||
import { ListNumbers, Plus, PushPinSimple, SquaresFour } from '@phosphor-icons/react'
|
||||
|
||||
export interface PageSection {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
componentCount?: number
|
||||
status?: 'draft' | 'review' | 'published'
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface SectionListProps {
|
||||
sections: PageSection[]
|
||||
selectedSectionId?: string
|
||||
onSelectSection?: (section: PageSection) => void
|
||||
onCreateSection?: () => void
|
||||
}
|
||||
|
||||
const statusVariant: Record<NonNullable<PageSection['status']>, 'default' | 'secondary' | 'outline'> = {
|
||||
draft: 'secondary',
|
||||
review: 'outline',
|
||||
published: 'default'
|
||||
}
|
||||
|
||||
export function SectionList({ sections, selectedSectionId, onSelectSection, onCreateSection }: SectionListProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListNumbers size={20} weight="duotone" />
|
||||
Sections
|
||||
</CardTitle>
|
||||
<CardDescription>Outline the sections that make up your generic page.</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={onCreateSection} variant="secondary">
|
||||
<Plus size={16} />
|
||||
Add Section
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{sections.length === 0 ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="text-sm">No sections yet. Create your first section to start building the page.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[520px]">
|
||||
<div className="divide-y divide-border">
|
||||
{sections.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`w-full text-left transition hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
|
||||
selectedSectionId === section.id ? 'bg-muted' : ''
|
||||
}`}
|
||||
onClick={() => onSelectSection?.(section)}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="mt-1">
|
||||
{section.status ? (
|
||||
<Badge variant={statusVariant[section.status]} className="capitalize">
|
||||
{section.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold leading-none">{section.title}</p>
|
||||
{section.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{section.updatedAt && (
|
||||
<p className="text-xs text-muted-foreground whitespace-nowrap">Updated {section.updatedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<SquaresFour size={14} />
|
||||
{section.componentCount ?? 0} components
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<PushPinSimple size={14} />
|
||||
ID: {section.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Image from 'next/image'
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
|
||||
import { FilmSlate, ImageSquare } from '@phosphor-icons/react'
|
||||
|
||||
interface MediaPaneProps {
|
||||
thumbnailUrl?: string
|
||||
videoUrl?: string
|
||||
onThumbnailChange?: (value: string) => void
|
||||
onVideoChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function MediaPane({ thumbnailUrl, videoUrl, onThumbnailChange, onVideoChange }: MediaPaneProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FilmSlate size={20} weight="duotone" />
|
||||
Media
|
||||
</CardTitle>
|
||||
<CardDescription>Optional visuals to make the quick guide easier to follow.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thumbnail-url">Thumbnail image</Label>
|
||||
<Input
|
||||
id="thumbnail-url"
|
||||
value={thumbnailUrl || ''}
|
||||
onChange={(e) => onThumbnailChange?.(e.target.value)}
|
||||
placeholder="https://images.example.com/quick-guide.png"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Shown in dashboards and previews.</p>
|
||||
{thumbnailUrl && (
|
||||
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border bg-muted">
|
||||
<Image src={thumbnailUrl} alt="Quick guide thumbnail" fill className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-url">Demo video (optional)</Label>
|
||||
<Input
|
||||
id="video-url"
|
||||
value={videoUrl || ''}
|
||||
onChange={(e) => onVideoChange?.(e.target.value)}
|
||||
placeholder="YouTube or direct MP4 link"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Embed a short clip that shows the flow in action.</p>
|
||||
{videoUrl && (
|
||||
<div className="rounded-lg border bg-black p-3 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="mb-2 inline-flex items-center gap-1">
|
||||
<ImageSquare size={14} />
|
||||
Preview
|
||||
</Badge>
|
||||
<div className="aspect-video overflow-hidden rounded-md bg-muted">
|
||||
<iframe
|
||||
className="h-full w-full"
|
||||
src={videoUrl}
|
||||
title="Quick guide demo"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Textarea } from '@/components/ui'
|
||||
import { ArrowCounterClockwise, ListNumbers, Plus, Trash } from '@phosphor-icons/react'
|
||||
|
||||
export interface GuideStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
mediaUrl?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
interface StepsEditorProps {
|
||||
steps: GuideStep[]
|
||||
onChange?: (steps: GuideStep[]) => void
|
||||
}
|
||||
|
||||
export function StepsEditor({ steps, onChange }: StepsEditorProps) {
|
||||
const [localSteps, setLocalSteps] = useState<GuideStep[]>(steps)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSteps(steps)
|
||||
}, [steps])
|
||||
|
||||
const updateStep = (id: string, payload: Partial<GuideStep>) => {
|
||||
const nextSteps = localSteps.map(step => (step.id === id ? { ...step, ...payload } : step))
|
||||
setLocalSteps(nextSteps)
|
||||
onChange?.(nextSteps)
|
||||
}
|
||||
|
||||
const removeStep = (id: string) => {
|
||||
const nextSteps = localSteps.filter(step => step.id !== id)
|
||||
setLocalSteps(nextSteps)
|
||||
onChange?.(nextSteps)
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
const newStep: GuideStep = {
|
||||
id: crypto.randomUUID(),
|
||||
title: 'New step',
|
||||
description: 'Describe what happens in this step.',
|
||||
duration: '1-2 min'
|
||||
}
|
||||
|
||||
const nextSteps = [...localSteps, newStep]
|
||||
setLocalSteps(nextSteps)
|
||||
onChange?.(nextSteps)
|
||||
}
|
||||
|
||||
const resetOrdering = () => {
|
||||
const nextSteps = localSteps.map((step, index) => ({ ...step, id: `step_${index + 1}` }))
|
||||
setLocalSteps(nextSteps)
|
||||
onChange?.(nextSteps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListNumbers size={20} weight="duotone" />
|
||||
Steps
|
||||
</CardTitle>
|
||||
<CardDescription>Keep your quick guide instructions concise and actionable.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={resetOrdering}>
|
||||
<ArrowCounterClockwise size={16} />
|
||||
Reset IDs
|
||||
</Button>
|
||||
<Button size="sm" onClick={addStep}>
|
||||
<Plus size={16} />
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{localSteps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Add your first step to get started.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{localSteps.map((step, index) => (
|
||||
<div key={step.id} className="rounded-lg border border-border/80 bg-card/60 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">Step {index + 1}</Badge>
|
||||
<span>Duration: {step.duration || 'n/a'}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeStep(step.id)}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`title-${step.id}`}>Title</Label>
|
||||
<Input
|
||||
id={`title-${step.id}`}
|
||||
value={step.title}
|
||||
onChange={(e) => updateStep(step.id, { title: e.target.value })}
|
||||
placeholder="Give this step a short name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`duration-${step.id}`}>Expected duration</Label>
|
||||
<Input
|
||||
id={`duration-${step.id}`}
|
||||
value={step.duration || ''}
|
||||
onChange={(e) => updateStep(step.id, { duration: e.target.value })}
|
||||
placeholder="e.g. 30s, 1-2 min"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor={`description-${step.id}`}>Description</Label>
|
||||
<Textarea
|
||||
id={`description-${step.id}`}
|
||||
value={step.description}
|
||||
onChange={(e) => updateStep(step.id, { description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Outline the actions or context for this step"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor={`media-${step.id}`}>Media URL (optional)</Label>
|
||||
<Input
|
||||
id={`media-${step.id}`}
|
||||
value={step.mediaUrl || ''}
|
||||
onChange={(e) => updateStep(step.id, { mediaUrl: e.target.value })}
|
||||
placeholder="Link to an image, GIF, or short video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Switch } from '@/components/ui'
|
||||
import { EnvelopeSimple, FloppyDisk } from '@phosphor-icons/react'
|
||||
import type { SMTPConfig } from '@/lib/password-utils'
|
||||
|
||||
interface ConnectionFormProps {
|
||||
value: SMTPConfig
|
||||
onChange: (value: SMTPConfig) => void
|
||||
onSave?: () => void
|
||||
onTest?: () => void
|
||||
}
|
||||
|
||||
export function ConnectionForm({ value, onChange, onSave, onTest }: ConnectionFormProps) {
|
||||
const securePort = useMemo(() => (value.tls ? 465 : 587), [value.tls])
|
||||
|
||||
const updateField = <K extends keyof SMTPConfig>(key: K, fieldValue: SMTPConfig[K]) => {
|
||||
onChange({ ...value, [key]: fieldValue })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<EnvelopeSimple size={20} weight="duotone" />
|
||||
SMTP connection
|
||||
</CardTitle>
|
||||
<CardDescription>Configure how MetaBuilder connects to your mail provider.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host">Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
value={value.host}
|
||||
onChange={(e) => updateField('host', e.target.value)}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={value.port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value || '0', 10))}
|
||||
placeholder={securePort.toString()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={value.username}
|
||||
onChange={(e) => updateField('username', e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={value.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="App password or token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromName">From name</Label>
|
||||
<Input
|
||||
id="fromName"
|
||||
value={value.fromName || ''}
|
||||
onChange={(e) => updateField('fromName', e.target.value)}
|
||||
placeholder="MetaBuilder"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromEmail">From email</Label>
|
||||
<Input
|
||||
id="fromEmail"
|
||||
type="email"
|
||||
value={value.fromEmail}
|
||||
onChange={(e) => updateField('fromEmail', e.target.value)}
|
||||
placeholder="no-reply@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3">
|
||||
<div>
|
||||
<p className="font-medium">Use secure connection (TLS)</p>
|
||||
<p className="text-sm text-muted-foreground">Switching on updates the recommended port to {securePort}.</p>
|
||||
</div>
|
||||
<Switch checked={value.tls} onCheckedChange={(checked) => updateField('tls', checked)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" onClick={onTest}>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button onClick={onSave}>
|
||||
<FloppyDisk size={16} />
|
||||
Save configuration
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { CheckCircle, Clock, WarningCircle } from '@phosphor-icons/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type ConnectionStatus = 'idle' | 'connected' | 'error'
|
||||
|
||||
interface StatusCardProps {
|
||||
status: ConnectionStatus
|
||||
host?: string
|
||||
lastChecked?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const statusCopy: Record<ConnectionStatus, { label: string; tone: string; icon: ReactNode }> = {
|
||||
idle: {
|
||||
label: 'Not tested',
|
||||
tone: 'bg-muted text-muted-foreground',
|
||||
icon: <Clock size={16} />
|
||||
},
|
||||
connected: {
|
||||
label: 'Connected',
|
||||
tone: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
icon: <CheckCircle size={16} />
|
||||
},
|
||||
error: {
|
||||
label: 'Connection failed',
|
||||
tone: 'bg-destructive/15 text-destructive',
|
||||
icon: <WarningCircle size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
export function StatusCard({ status, host, lastChecked, message }: StatusCardProps) {
|
||||
const copy = statusCopy[status]
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Connection status</CardTitle>
|
||||
<CardDescription>Stay aware of how the platform talks to your SMTP provider.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Badge variant="secondary" className={`inline-flex items-center gap-2 ${copy.tone}`}>
|
||||
{copy.icon}
|
||||
{copy.label}
|
||||
</Badge>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>Host: {host || 'Not configured'}</p>
|
||||
<p>Last checked: {lastChecked || 'Pending test'}</p>
|
||||
<p>{message || 'Run a test to see connection details.'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user