feat: add data-specific ui components

This commit is contained in:
2025-12-27 19:00:17 +00:00
parent 99d4411a41
commit d6c6a85e5a
6 changed files with 596 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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