Dns Record Manager
blockdevopsCloudflare/Vercel style DNS records management table and nameserver inspector with type badges, proxy toggle, copyable values, zone file exporter, and add/edit record panel.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/dns-record-manager.json$npx shadcn@latest add https://uipkge.dev/r/react/dns-record-manager.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/dns-record-manager.json$bunx shadcn@latest add https://uipkge.dev/r/react/dns-record-manager.jsonNamed registry:
npx shadcn@latest add @uipkge-react/dns-record-managerInstalls to:components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
domain | string | — | optional |
nameservers | string[] | — | optional |
dnssecEnabled | boolean | — | optional |
initialRecords | DnsRecord[] | — | optional |
initialAddOpen | boolean | — | optional |
className | string | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DnsRecordinterface DnsRecord {
id: string
type: DnsRecordType
name: string
content: string
ttl: string
proxied: boolean
priority?: number
comment?: string
}npm dependencies
Files installed (1)
components/blocks/DnsRecordManager.tsx36.4 kB
'use client' import * as React from 'react' import { Check, Cloud, CloudOff, Copy, Download, Edit2, FileCode, Globe, MoreHorizontal, Plus, Search, ShieldAlert, ShieldCheck, Trash2, X, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'CAA' export interface DnsRecord { id: string type: DnsRecordType name: string content: string ttl: string proxied: boolean priority?: number comment?: string } export interface DnsRecordManagerProps { domain?: string nameservers?: string[] dnssecEnabled?: boolean initialRecords?: DnsRecord[] initialAddOpen?: boolean className?: string } const defaultRecords: DnsRecord[] = [ { id: 'rec-1', type: 'A', name: '@', content: '76.76.21.21', ttl: 'Auto', proxied: true, comment: 'Apex production load balancer', }, { id: 'rec-2', type: 'CNAME', name: 'www', content: 'cname.vercel-dns.com', ttl: 'Auto', proxied: true, comment: 'Primary web redirect', }, { id: 'rec-3', type: 'CNAME', name: 'api', content: 'api-gateway.uipkge.net', ttl: '300s', proxied: false, comment: 'Direct REST gateway route', }, { id: 'rec-4', type: 'AAAA', name: '@', content: '2606:4700:3038::6815:1515', ttl: 'Auto', proxied: true, comment: 'IPv6 edge ingress', }, { id: 'rec-5', type: 'MX', name: 'mail', content: 'aspmx.l.google.com', ttl: '3600s', proxied: false, priority: 10, comment: 'Google Workspace MX', }, { id: 'rec-6', type: 'TXT', name: '_dmarc', content: 'v=spf1 include:_spf.google.com ~all', ttl: '3600s', proxied: false, comment: 'Sender Policy Framework verification', }, { id: 'rec-7', type: 'CAA', name: '@', content: '0 issue "letsencrypt.org"', ttl: 'Auto', proxied: false, comment: 'Certificate authority restriction', }, ] const typeBadgeStyles: Record<DnsRecordType, { bg: string; text: string; border: string }> = { A: { bg: 'bg-info/10', text: 'text-info', border: 'border-info/30', }, AAAA: { bg: 'bg-chart-1/10', text: 'text-chart-1', border: 'border-chart-1/30', }, CNAME: { bg: 'bg-chart-2/10', text: 'text-chart-2', border: 'border-chart-2/30', }, MX: { bg: 'bg-warning/10', text: 'text-warning', border: 'border-warning/30', }, TXT: { bg: 'bg-success/10', text: 'text-success', border: 'border-success/30', }, CAA: { bg: 'bg-info/10', text: 'text-info', border: 'border-info/30', }, } const placeholderMap: Record<DnsRecordType, string> = { A: 'e.g. 76.76.21.21', AAAA: 'e.g. 2606:4700:3038::6815:1515', CNAME: 'e.g. cname.vercel-dns.com', MX: 'e.g. mail.google.com', TXT: 'e.g. v=spf1 include:_spf.google.com ~all', CAA: 'e.g. 0 issue "letsencrypt.org"', } export function DnsRecordManager({ domain = 'uipkge.dev', nameservers = ['ns1.uipkge.net', 'ns2.uipkge.net'], dnssecEnabled = true, initialRecords, initialAddOpen = false, className, }: DnsRecordManagerProps) { const [records, setRecords] = React.useState<DnsRecord[]>(() => initialRecords ? initialRecords.map((r) => ({ ...r })) : defaultRecords.map((r) => ({ ...r })), ) const [searchQuery, setSearchQuery] = React.useState('') const [selectedTypeFilter, setSelectedTypeFilter] = React.useState<string>('ALL') const [isAddOpen, setIsAddOpen] = React.useState(initialAddOpen) const [isZoneFileOpen, setIsZoneFileOpen] = React.useState(false) const [editingId, setEditingId] = React.useState<string | null>(null) // Form states const [formType, setFormType] = React.useState<DnsRecordType>('A') const [formName, setFormName] = React.useState('') const [formContent, setFormContent] = React.useState('') const [formTtl, setFormTtl] = React.useState('Auto') const [formProxied, setFormProxied] = React.useState(true) const [formPriority, setFormPriority] = React.useState(10) const [formComment, setFormComment] = React.useState('') const [copiedKey, setCopiedKey] = React.useState<string | null>(null) const copyTimeoutRef = React.useRef<number | undefined>(undefined) const copyText = React.useCallback(async (key: string, text: string) => { try { await navigator.clipboard.writeText(text) setCopiedKey(key) window.clearTimeout(copyTimeoutRef.current) copyTimeoutRef.current = window.setTimeout(() => { setCopiedKey(null) }, 1600) } catch { // Clipboard unavailable } }, []) const handleTypeChange = (newType: DnsRecordType) => { setFormType(newType) if (['MX', 'TXT', 'CAA'].includes(newType)) { setFormProxied(false) } else if (!editingId) { setFormProxied(true) } } const toggleProxy = (id: string) => { setRecords((prev) => prev.map((r) => { if (r.id === id && ['A', 'AAAA', 'CNAME'].includes(r.type)) { return { ...r, proxied: !r.proxied } } return r }), ) } const deleteRecord = (id: string) => { setRecords((prev) => prev.filter((r) => r.id !== id)) if (editingId === id) { cancelForm() } } const startAdd = () => { setEditingId(null) setFormType('A') setFormName('') setFormContent('') setFormTtl('Auto') setFormProxied(true) setFormPriority(10) setFormComment('') setIsAddOpen(true) } const startEdit = (record: DnsRecord) => { setEditingId(record.id) setFormType(record.type) setFormName(record.name) setFormContent(record.content) setFormTtl(record.ttl) setFormProxied(record.proxied) setFormPriority(record.priority ?? 10) setFormComment(record.comment ?? '') setIsAddOpen(true) } const cancelForm = () => { setIsAddOpen(false) setEditingId(null) } const saveRecord = () => { const name = formName.trim() || '@' const content = formContent.trim() if (!content) return if (editingId) { setRecords((prev) => prev.map((r) => { if (r.id === editingId) { return { ...r, type: formType, name, content, ttl: formTtl, proxied: ['A', 'AAAA', 'CNAME'].includes(formType) ? formProxied : false, priority: formType === 'MX' ? Number(formPriority) : undefined, comment: formComment.trim() || undefined, } } return r }), ) } else { const newRecord: DnsRecord = { id: `rec-${Date.now()}`, type: formType, name, content, ttl: formTtl, proxied: ['A', 'AAAA', 'CNAME'].includes(formType) ? formProxied : false, priority: formType === 'MX' ? Number(formPriority) : undefined, comment: formComment.trim() || undefined, } setRecords((prev) => [newRecord, ...prev]) } cancelForm() } const filteredRecords = React.useMemo(() => { const query = searchQuery.trim().toLowerCase() return records.filter((record) => { const matchesType = selectedTypeFilter === 'ALL' || record.type === selectedTypeFilter const matchesQuery = !query || record.name.toLowerCase().includes(query) || record.content.toLowerCase().includes(query) || record.type.toLowerCase().includes(query) || (record.comment && record.comment.toLowerCase().includes(query)) return matchesType && matchesQuery }) }, [records, searchQuery, selectedTypeFilter]) const zoneFileContent = React.useMemo(() => { const date = new Date().toISOString().split('T')[0].replace(/-/g, '') const lines: string[] = [ `; BIND zone file for ${domain}`, `; Generated on ${new Date().toUTCString()}`, `$ORIGIN ${domain}.`, `$TTL 3600`, ``, `; SOA Record`, `@ IN SOA ${nameservers[0]}. hostmaster.${domain}. (`, ` ${date}01 ; Serial`, ` 7200 ; Refresh (2 hours)`, ` 3600 ; Retry (1 hour)`, ` 1209600 ; Expire (2 weeks)`, ` 3600 ; Minimum TTL (1 hour)`, `)`, ``, `; Nameservers`, ] nameservers.forEach((ns) => { lines.push(`@ IN NS ${ns}.`) }) lines.push('', '; Resource Records') for (const r of records) { const recName = (r.name === '@' ? '@' : r.name).padEnd(12, ' ') const ttlValue = r.ttl === 'Auto' ? '3600' : r.ttl.replace('s', '') const ttlStr = ttlValue.padEnd(6, ' ') const typeStr = r.type.padEnd(7, ' ') if (r.type === 'MX') { const prio = String(r.priority ?? 10).padEnd(4, ' ') lines.push(`${recName} ${ttlStr} IN ${typeStr} ${prio} ${r.content}.`) } else if (r.type === 'TXT') { lines.push(`${recName} ${ttlStr} IN ${typeStr} "${r.content}"`) } else if (r.type === 'CNAME') { lines.push(`${recName} ${ttlStr} IN ${typeStr} ${r.content}.`) } else { lines.push(`${recName} ${ttlStr} IN ${typeStr} ${r.content}`) } } return lines.join('\n') }, [domain, nameservers, records]) const downloadZoneFile = () => { const blob = new Blob([zoneFileContent], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${domain}.zone` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } const resolveHostname = (recordName: string): string => { if (recordName === '@') return domain return `${recordName}.${domain}` } return ( <div data-slot="dns-record-manager" className={cn('w-full space-y-6', className)}> {/* Domain Header & Status Card */} <Card className="border-border bg-card shadow-xs"> <CardHeader className="pb-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="space-y-1.5"> <div className="flex flex-wrap items-center gap-2.5"> <div className="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg"> <Globe className="size-4.5" /> </div> <h2 className="text-foreground text-xl font-bold tracking-tight sm:text-2xl">{domain}</h2> {dnssecEnabled ? ( <Badge variant="outline" className="border-success/30 bg-success/10 text-success gap-1.5 py-0.5"> <span className="bg-success size-1.5 rounded-full" /> <ShieldCheck className="size-3.5" /> Active · DNSSEC Enabled </Badge> ) : ( <Badge variant="outline" className="border-warning/30 bg-warning/10 text-warning gap-1.5 py-0.5"> <span className="bg-warning size-1.5 rounded-full" /> <ShieldAlert className="size-3.5" /> Active · DNSSEC Inactive </Badge> )} </div> <p className="text-muted-foreground text-xs sm:text-sm"> Authoritative DNS routing and global edge proxy management for {domain}. </p> </div> {/* Header CTA Buttons */} <div className="flex items-center gap-2"> <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs font-medium" onClick={() => setIsZoneFileOpen((prev) => !prev)} > <FileCode className="size-3.5" /> {isZoneFileOpen ? 'Hide Zone File' : 'Export Zone File'} </Button> <Button size="sm" className="h-8 gap-1.5 text-xs font-medium" onClick={startAdd}> <Plus className="size-3.5" /> Add Record </Button> </div> </div> <Separator className="my-4" /> {/* Nameserver Inspector Bar */} <div className="flex flex-wrap items-center justify-between gap-3 text-xs"> <div className="flex flex-wrap items-center gap-2"> <span className="text-muted-foreground font-medium">Nameservers:</span> <div className="flex flex-wrap items-center gap-1.5"> {nameservers.map((ns) => ( <button key={ns} type="button" aria-label={`Copy nameserver ${ns}`} className="border-border bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 group text-foreground flex min-h-6 items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-xs transition-colors focus-visible:ring-[2px] focus-visible:outline-none" onClick={() => copyText(ns, ns)} > <span>{ns}</span> {copiedKey === ns ? ( <Check className="text-success size-3" /> ) : ( <Copy className="text-muted-foreground group-hover:text-foreground size-3" /> )} </button> ))} </div> </div> <div className="text-muted-foreground flex items-center gap-2"> <span className="bg-success size-2 animate-pulse rounded-full" /> <span>Propagation: 100% Synced</span> </div> </div> </CardHeader> </Card> {/* Zone File Export Card (Collapsible) */} {isZoneFileOpen && ( <Card className="border-border bg-card shadow-xs"> <CardHeader className="pb-3"> <div className="flex items-center justify-between"> <div> <CardTitle className="text-sm font-semibold sm:text-base">BIND RFC 1035 Zone File</CardTitle> <CardDescription className="text-xs"> Complete DNS zone mapping for {domain} ready for import into Route53, Bind9, or Cloudflare. </CardDescription> </div> <Button variant="ghost" size="icon-sm" className="size-7" aria-label="Close zone file viewer" onClick={() => setIsZoneFileOpen(false)} > <X className="size-4" /> </Button> </div> </CardHeader> <CardContent className="space-y-3 pt-0"> <div className="relative"> <pre className="border-border bg-muted/70 text-foreground max-h-56 overflow-x-auto rounded-lg border p-3 font-mono text-xs leading-relaxed select-all"> {zoneFileContent} </pre> </div> <div className="flex items-center justify-end gap-2"> <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => copyText('zone-file', zoneFileContent)} > {copiedKey === 'zone-file' ? ( <Check className="text-success size-3.5" /> ) : ( <Copy className="size-3.5" /> )} {copiedKey === 'zone-file' ? 'Copied Zone File' : 'Copy Zone Content'} </Button> <Button aria-label="Download attachment" size="sm" className="h-8 gap-1.5 text-xs" onClick={downloadZoneFile} > <Download className="size-3.5" /> Download .zone File </Button> </div> </CardContent> </Card> )} {/* Add / Edit DNS Record Card */} {isAddOpen && ( <Card className="border-primary/30 bg-muted/20 shadow-xs"> <CardHeader className="pb-4"> <div className="flex items-center justify-between"> <div> <CardTitle className="text-base font-semibold"> {editingId ? 'Edit DNS Record' : 'Add New DNS Record'} </CardTitle> <CardDescription className="text-xs"> {editingId ? 'Update configuration, target destination, or edge proxy status.' : `Configure host routing, MX exchangers, or verification TXT tags for ${domain}.`} </CardDescription> </div> <Button variant="ghost" size="icon-sm" className="size-7" aria-label="Cancel record form" onClick={cancelForm} > <X className="size-4" /> </Button> </div> </CardHeader> <CardContent className="space-y-4 pt-0"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-12"> {/* Type Select */} <div className="sm:col-span-3"> <label htmlFor="dns-type" className="text-foreground mb-1.5 block text-xs font-medium"> Type </label> <Select value={formType} onValueChange={(val) => handleTypeChange(val as DnsRecordType)}> <SelectTrigger id="dns-type" className="w-full [&_svg]:shrink-0 [&>span]:truncate"> <SelectValue placeholder="Record type" /> </SelectTrigger> <SelectContent> <SelectItem value="A">A (IPv4 Address)</SelectItem> <SelectItem value="AAAA">AAAA (IPv6 Address)</SelectItem> <SelectItem value="CNAME">CNAME (Canonical Alias)</SelectItem> <SelectItem value="MX">MX (Mail Exchanger)</SelectItem> <SelectItem value="TXT">TXT (Text Record)</SelectItem> <SelectItem value="CAA">CAA (Cert Authority)</SelectItem> </SelectContent> </Select> </div> {/* Name Input */} <div className="sm:col-span-5"> <label htmlFor="dns-name" className="text-foreground mb-1.5 block text-xs font-medium"> Name / Subdomain <span className="text-muted-foreground font-normal">(@ for root)</span> </label> <Input id="dns-name" value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="@ or subdomain" className="font-mono text-xs" /> </div> {/* TTL Select */} <div className="sm:col-span-4"> <label htmlFor="dns-ttl" className="text-foreground mb-1.5 block text-xs font-medium"> TTL </label> <Select value={formTtl} onValueChange={setFormTtl}> <SelectTrigger id="dns-ttl" className="w-full [&_svg]:shrink-0 [&>span]:truncate"> <SelectValue placeholder="TTL" /> </SelectTrigger> <SelectContent> <SelectItem value="Auto">Auto (Automatic)</SelectItem> <SelectItem value="60s">1 min (60s)</SelectItem> <SelectItem value="300s">5 mins (300s)</SelectItem> <SelectItem value="1800s">30 mins (1800s)</SelectItem> <SelectItem value="3600s">1 hour (3600s)</SelectItem> <SelectItem value="86400s">1 day (86400s)</SelectItem> </SelectContent> </Select> </div> </div> <div className={cn('grid grid-cols-1 gap-3', formType === 'MX' ? 'sm:grid-cols-12' : '')}> {/* Content / Target Input */} <div className={formType === 'MX' ? 'sm:col-span-9' : 'w-full'}> <label htmlFor="dns-content" className="text-foreground mb-1.5 block text-xs font-medium"> {formType === 'A' ? 'IPv4 Address' : formType === 'AAAA' ? 'IPv6 Address' : formType === 'CNAME' ? 'Target Domain' : formType === 'MX' ? 'Mail Server Hostname' : formType === 'TXT' ? 'TXT Content / Value' : 'Target / Tag Value'} </label> <Input id="dns-content" value={formContent} onChange={(e) => setFormContent(e.target.value)} placeholder={placeholderMap[formType]} className="font-mono text-xs" /> </div> {/* Priority Input for MX */} {formType === 'MX' && ( <div className="sm:col-span-3"> <label htmlFor="dns-priority" className="text-foreground mb-1.5 block text-xs font-medium"> Priority </label> <Input id="dns-priority" type="number" min={0} max={65535} value={formPriority} onChange={(e) => setFormPriority(Number(e.target.value))} placeholder="10" className="font-mono text-xs" /> </div> )} </div> {/* Optional Comment */} <div> <label htmlFor="dns-comment" className="text-foreground mb-1.5 block text-xs font-medium"> Comment <span className="text-muted-foreground font-normal">(optional note)</span> </label> <Input id="dns-comment" value={formComment} onChange={(e) => setFormComment(e.target.value)} placeholder="e.g. Production ingress or verification tag" className="text-xs" /> </div> {/* Proxy Switch (for A, AAAA, CNAME) */} {['A', 'AAAA', 'CNAME'].includes(formType) && ( <div className="border-border bg-background/80 flex items-center justify-between rounded-lg border p-3"> <div className="space-y-0.5"> <div className="flex items-center gap-2"> <Cloud className="text-warning size-4" /> <span className="text-foreground text-xs font-medium">Proxy Status</span> </div> <p className="text-muted-foreground text-xs"> {formProxied ? 'Proxied: Accelerate traffic and protect origin IP behind Cloudflare edge.' : 'DNS only: Direct traffic route without edge proxy caching.'} </p> </div> <Switch id="proxy-toggle" checked={formProxied} onCheckedChange={setFormProxied} /> </div> )} {/* Form Actions */} <div className="flex items-center justify-end gap-2 pt-2"> <Button variant="outline" size="sm" className="h-8 text-xs" onClick={cancelForm}> Cancel </Button> <Button size="sm" className="h-8 text-xs" disabled={!formContent.trim()} onClick={saveRecord}> {editingId ? 'Update Record' : 'Save Record'} </Button> </div> </CardContent> </Card> )} {/* DNS Records Management Section */} <div className="space-y-3"> {/* Toolbar Filter & Search */} <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between"> <div className="relative w-full max-w-sm"> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search records by name, target, or content..." className="h-8.5 pl-8 text-xs" /> </div> {/* Type Filter Buttons */} <div className="flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0"> <button type="button" className={cn( 'min-h-6 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', selectedTypeFilter === 'ALL' ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', )} onClick={() => setSelectedTypeFilter('ALL')} > All ({records.length}) </button> {(['A', 'CNAME', 'MX', 'TXT', 'AAAA', 'CAA'] as DnsRecordType[]).map((t) => ( <button key={t} type="button" className={cn( 'min-h-6 min-w-6 rounded-md px-2 py-1 font-mono text-xs font-medium transition-colors', selectedTypeFilter === t ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', )} onClick={() => setSelectedTypeFilter(t)} > {t} </button> ))} </div> </div> {/* DNS Records Table */} <div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs"> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow className="bg-muted/40"> <TableHead className="w-24">Type</TableHead> <TableHead className="min-w-[140px]">Name</TableHead> <TableHead className="min-w-[240px]">Target Content</TableHead> <TableHead className="w-28 text-center">Proxy Status</TableHead> <TableHead className="w-20">TTL</TableHead> <TableHead className="w-12 text-right"> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {filteredRecords.map((record) => ( <TableRow key={record.id} className={cn( 'hover:bg-muted/30 transition-colors', editingId === record.id ? 'bg-primary/5' : undefined, )} > {/* Record Type Badge */} <TableCell> <span className={cn( 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 font-mono text-xs font-bold', typeBadgeStyles[record.type].bg, typeBadgeStyles[record.type].text, typeBadgeStyles[record.type].border, )} > {record.type} </span> </TableCell> {/* Name & Hostname helper */} <TableCell> <div className="flex flex-col"> <span className="text-foreground font-mono text-xs font-semibold">{record.name}</span> <span className="text-muted-foreground truncate font-mono text-xs"> {resolveHostname(record.name)} </span> </div> </TableCell> {/* Target Content with Copy button */} <TableCell> <div className="flex items-center gap-2"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-1.5"> {record.type === 'MX' && ( <span className="border-border bg-muted text-muted-foreground rounded px-1 font-mono text-xs"> Pri {record.priority} </span> )} <code className="text-foreground/90 font-mono text-xs break-all select-all"> {record.content} </code> </div> {record.comment && ( <p className="text-muted-foreground mt-0.5 truncate text-xs">{record.comment}</p> )} </div> <Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-foreground size-7 shrink-0" aria-label={`Copy content for ${record.name}`} onClick={() => copyText(record.id, record.content)} > {copiedKey === record.id ? ( <Check className="text-success size-3.5" /> ) : ( <Copy className="size-3.5" /> )} </Button> </div> </TableCell> {/* Proxy Status Switch / Cloud badge */} <TableCell className="text-center"> {['A', 'AAAA', 'CNAME'].includes(record.type) ? ( <div className="flex justify-center"> <button type="button" aria-label={`Toggle proxy status for ${record.name}, currently ${record.proxied ? 'proxied' : 'dns only'}`} className={cn( 'group inline-flex min-h-6 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:ring-[2px] focus-visible:outline-none', record.proxied ? 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 text-warning' : 'border-border bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground', )} onClick={() => toggleProxy(record.id)} > {record.proxied ? ( <Cloud className="fill-warning text-warning size-3.5" /> ) : ( <CloudOff className="text-muted-foreground group-hover:text-foreground size-3.5" /> )} <span>{record.proxied ? 'Proxied' : 'DNS only'}</span> </button> </div> ) : ( <div className="flex justify-center"> <span className="text-muted-foreground/60 inline-flex items-center gap-1 font-mono text-xs"> <CloudOff className="size-3" /> DNS only </span> </div> )} </TableCell> {/* TTL Badge */} <TableCell> <Badge variant="outline" className="border-border bg-muted/40 font-mono text-xs font-normal"> {record.ttl} </Badge> </TableCell> {/* Row Actions Dropdown */} <TableCell className="text-right"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground size-7 p-0"> <MoreHorizontal className="size-4" /> <span className="sr-only">Open record actions</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44"> <DropdownMenuItem onClick={() => startEdit(record)}> <Edit2 className="mr-2 size-3.5" /> Edit Record </DropdownMenuItem> <DropdownMenuItem onClick={() => copyText(record.id, record.content)}> <Copy className="mr-2 size-3.5" /> Copy Target </DropdownMenuItem> <DropdownMenuItem onClick={() => copyText(`${record.id}-bind`, `${record.name} IN ${record.type} ${record.content}`) } > <FileCode className="mr-2 size-3.5" /> Copy BIND Row </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => deleteRecord(record.id)} > <Trash2 className="mr-2 size-3.5" /> Delete Record </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> ))} {/* Empty State */} {filteredRecords.length === 0 && ( <TableRow> <TableCell colSpan={6} className="h-32 text-center"> <div className="flex flex-col items-center justify-center gap-1.5 text-center"> <Globe className="text-muted-foreground/50 size-8" /> <p className="text-foreground text-sm font-medium">No DNS records found</p> <p className="text-muted-foreground max-w-sm text-xs"> {searchQuery ? 'No records match your search filter query.' : 'No records configured for this domain yet. Add your first record to begin routing.'} </p> <Button size="sm" className="mt-2 h-7 gap-1 text-xs" onClick={startAdd}> <Plus className="size-3" /> Add DNS Record </Button> </div> </TableCell> </TableRow> )} </TableBody> </Table> </div> </div> {/* Table Footer info */} <div className="text-muted-foreground flex flex-wrap items-center justify-between gap-2 px-1 text-xs"> <p> Showing <span className="text-foreground font-medium">{filteredRecords.length}</span> of{' '} <span className="text-foreground font-medium">{records.length}</span> configured records </p> <div className="flex items-center gap-4"> <span className="flex items-center gap-1.5"> <span className="bg-warning size-2 rounded-full" /> {records.filter((r) => r.proxied).length} Proxied </span> <span className="flex items-center gap-1.5"> <span className="bg-muted-foreground size-2 rounded-full" /> {records.filter((r) => !r.proxied).length} DNS Only </span> </div> </div> </div> </div> ) }
Raw manifest:https://uipkge.dev/r/react/dns-record-manager.json