@annondeveloper/ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/chunk-5OKSXPWK.js +270 -0
- package/dist/chunk-5OKSXPWK.js.map +1 -0
- package/dist/cli/index.js +430 -0
- package/dist/form.d.ts +65 -0
- package/dist/form.js +148 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +942 -0
- package/dist/index.js +2812 -0
- package/dist/index.js.map +1 -0
- package/dist/select-nnBJUO8U.d.ts +26 -0
- package/package.json +114 -0
- package/src/components/animated-counter.stories.tsx +68 -0
- package/src/components/animated-counter.tsx +85 -0
- package/src/components/avatar.tsx +106 -0
- package/src/components/badge.stories.tsx +70 -0
- package/src/components/badge.tsx +97 -0
- package/src/components/button.stories.tsx +101 -0
- package/src/components/button.tsx +67 -0
- package/src/components/card.tsx +128 -0
- package/src/components/checkbox.stories.tsx +64 -0
- package/src/components/checkbox.tsx +58 -0
- package/src/components/confirm-dialog.stories.tsx +96 -0
- package/src/components/confirm-dialog.tsx +145 -0
- package/src/components/data-table.stories.tsx +125 -0
- package/src/components/data-table.tsx +791 -0
- package/src/components/dropdown-menu.tsx +111 -0
- package/src/components/empty-state.stories.tsx +42 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/filter-pill.stories.tsx +71 -0
- package/src/components/filter-pill.tsx +45 -0
- package/src/components/form-input.stories.tsx +91 -0
- package/src/components/form-input.tsx +77 -0
- package/src/components/log-viewer.tsx +212 -0
- package/src/components/metric-card.tsx +141 -0
- package/src/components/pipeline-stage.tsx +134 -0
- package/src/components/popover.tsx +72 -0
- package/src/components/port-status-grid.tsx +102 -0
- package/src/components/progress.tsx +128 -0
- package/src/components/radio-group.tsx +162 -0
- package/src/components/select.stories.tsx +52 -0
- package/src/components/select.tsx +92 -0
- package/src/components/severity-timeline.tsx +125 -0
- package/src/components/sheet.tsx +164 -0
- package/src/components/skeleton.stories.tsx +64 -0
- package/src/components/skeleton.tsx +62 -0
- package/src/components/slider.tsx +208 -0
- package/src/components/sparkline.tsx +104 -0
- package/src/components/status-badge.stories.tsx +84 -0
- package/src/components/status-badge.tsx +71 -0
- package/src/components/status-pulse.stories.tsx +56 -0
- package/src/components/status-pulse.tsx +78 -0
- package/src/components/success-checkmark.stories.tsx +67 -0
- package/src/components/success-checkmark.tsx +53 -0
- package/src/components/tabs.tsx +177 -0
- package/src/components/threshold-gauge.tsx +149 -0
- package/src/components/time-range-selector.tsx +86 -0
- package/src/components/toast.stories.tsx +70 -0
- package/src/components/toast.tsx +48 -0
- package/src/components/toggle-switch.stories.tsx +66 -0
- package/src/components/toggle-switch.tsx +51 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/truncated-text.stories.tsx +56 -0
- package/src/components/truncated-text.tsx +80 -0
- package/src/components/uptime-tracker.tsx +138 -0
- package/src/components/utilization-bar.tsx +103 -0
- package/src/theme.css +178 -0
- package/src/utils.ts +123 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react'
|
|
4
|
+
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import type { LucideIcon } from 'lucide-react'
|
|
7
|
+
import { cn } from '../utils'
|
|
8
|
+
|
|
9
|
+
/** A single menu item definition. */
|
|
10
|
+
export interface MenuItem {
|
|
11
|
+
/** Display label. */
|
|
12
|
+
label: string
|
|
13
|
+
/** Optional icon component. */
|
|
14
|
+
icon?: LucideIcon
|
|
15
|
+
/** Callback when item is clicked. */
|
|
16
|
+
onClick: () => void
|
|
17
|
+
/** Visual variant — danger for destructive actions. */
|
|
18
|
+
variant?: 'default' | 'danger'
|
|
19
|
+
/** Whether this item is disabled. */
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DropdownMenuProps {
|
|
24
|
+
/** Trigger element that opens the menu. */
|
|
25
|
+
trigger: ReactNode
|
|
26
|
+
/** Array of menu item definitions. */
|
|
27
|
+
items: MenuItem[]
|
|
28
|
+
/** Alignment of the menu relative to the trigger. */
|
|
29
|
+
align?: 'start' | 'center' | 'end'
|
|
30
|
+
/** Additional class name for the content container. */
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contentVariants = {
|
|
35
|
+
hidden: { opacity: 0, scale: 0.95, y: -4 },
|
|
36
|
+
visible: { opacity: 1, scale: 1, y: 0 },
|
|
37
|
+
exit: { opacity: 0, scale: 0.95, y: -4 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @description A context/action dropdown menu built on Radix DropdownMenu.
|
|
42
|
+
* Features Framer Motion entry/exit animations, keyboard accessibility,
|
|
43
|
+
* and a danger variant for destructive actions.
|
|
44
|
+
*/
|
|
45
|
+
export function DropdownMenu({ trigger, items, align = 'end', className }: DropdownMenuProps) {
|
|
46
|
+
const prefersReducedMotion = useReducedMotion()
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<DropdownMenuPrimitive.Root>
|
|
50
|
+
<DropdownMenuPrimitive.Trigger asChild>{trigger}</DropdownMenuPrimitive.Trigger>
|
|
51
|
+
|
|
52
|
+
<AnimatePresence>
|
|
53
|
+
<DropdownMenuPrimitive.Portal>
|
|
54
|
+
<DropdownMenuPrimitive.Content
|
|
55
|
+
align={align}
|
|
56
|
+
sideOffset={6}
|
|
57
|
+
asChild
|
|
58
|
+
>
|
|
59
|
+
<motion.div
|
|
60
|
+
initial="hidden"
|
|
61
|
+
animate="visible"
|
|
62
|
+
exit="exit"
|
|
63
|
+
variants={contentVariants}
|
|
64
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
65
|
+
className={cn(
|
|
66
|
+
'z-50 min-w-[160px] overflow-hidden rounded-xl p-1',
|
|
67
|
+
'border border-[hsl(var(--border-default))]',
|
|
68
|
+
'bg-[hsl(var(--bg-elevated))] shadow-xl',
|
|
69
|
+
'focus:outline-none',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{items.map((item, i) => {
|
|
74
|
+
const Icon = item.icon
|
|
75
|
+
const isDanger = item.variant === 'danger'
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<DropdownMenuPrimitive.Item
|
|
79
|
+
key={i}
|
|
80
|
+
disabled={item.disabled}
|
|
81
|
+
onSelect={item.onClick}
|
|
82
|
+
className={cn(
|
|
83
|
+
'relative flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg',
|
|
84
|
+
'outline-none cursor-pointer select-none transition-colors duration-100',
|
|
85
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-40',
|
|
86
|
+
isDanger
|
|
87
|
+
? 'text-[hsl(var(--status-critical))] data-[highlighted]:bg-[hsl(var(--status-critical)/0.1)]'
|
|
88
|
+
: 'text-[hsl(var(--text-primary))] data-[highlighted]:bg-[hsl(var(--bg-overlay))]',
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{Icon && (
|
|
92
|
+
<Icon
|
|
93
|
+
className={cn(
|
|
94
|
+
'h-4 w-4 shrink-0',
|
|
95
|
+
isDanger
|
|
96
|
+
? 'text-[hsl(var(--status-critical))]'
|
|
97
|
+
: 'text-[hsl(var(--text-secondary))]',
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
{item.label}
|
|
102
|
+
</DropdownMenuPrimitive.Item>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</motion.div>
|
|
106
|
+
</DropdownMenuPrimitive.Content>
|
|
107
|
+
</DropdownMenuPrimitive.Portal>
|
|
108
|
+
</AnimatePresence>
|
|
109
|
+
</DropdownMenuPrimitive.Root>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { Inbox, Search, ServerCrash, Plus } from 'lucide-react'
|
|
3
|
+
import { EmptyState } from './empty-state'
|
|
4
|
+
import { Button } from './button'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof EmptyState> = {
|
|
7
|
+
title: 'Components/EmptyState',
|
|
8
|
+
component: EmptyState,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
}
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof EmptyState>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
icon: Inbox,
|
|
17
|
+
title: 'No entities found',
|
|
18
|
+
description: 'There are no entities matching your filters. Try broadening your search.',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const WithActions: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
icon: ServerCrash,
|
|
25
|
+
title: 'No devices discovered',
|
|
26
|
+
description: 'Run a network scan to discover devices on your network.',
|
|
27
|
+
actions: (
|
|
28
|
+
<>
|
|
29
|
+
<Button variant="primary"><Plus className="h-4 w-4" /> Run Discovery</Button>
|
|
30
|
+
<Button variant="outline">Import CSV</Button>
|
|
31
|
+
</>
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SearchEmpty: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
icon: Search,
|
|
39
|
+
title: 'No results',
|
|
40
|
+
description: 'No rows match your search or filter criteria.',
|
|
41
|
+
},
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
import type { LucideIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
export interface EmptyStateProps {
|
|
7
|
+
/** Icon component displayed in the center. */
|
|
8
|
+
icon: LucideIcon
|
|
9
|
+
/** Title text. */
|
|
10
|
+
title: string
|
|
11
|
+
/** Description text. */
|
|
12
|
+
description: string
|
|
13
|
+
/** Optional action buttons rendered below the description. */
|
|
14
|
+
actions?: React.ReactNode
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @description A decorative empty state placeholder with icon, title, description, and optional actions.
|
|
20
|
+
* Features a subtle gradient background and glass-morphism styling.
|
|
21
|
+
*/
|
|
22
|
+
export function EmptyState({ icon: Icon, title, description, actions, className }: EmptyStateProps) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
'relative overflow-hidden flex flex-col items-center justify-center rounded-2xl border border-dashed border-[hsl(var(--border-default))]',
|
|
27
|
+
'bg-[hsl(var(--bg-surface)/0.6)] backdrop-blur-xl px-6 py-12 text-center',
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{/* Decorative gradient background */}
|
|
32
|
+
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-[hsl(var(--brand-primary)/0.04)] to-transparent" />
|
|
33
|
+
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,hsl(var(--brand-primary)/0.06),transparent_70%)]" />
|
|
34
|
+
|
|
35
|
+
<div className="relative mb-4 flex size-14 items-center justify-center rounded-2xl bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-subtle)/0.6)] shadow-[0_1px_3px_hsl(0_0%_0%/0.12)]">
|
|
36
|
+
<Icon className="size-6 text-[hsl(var(--text-disabled))]" />
|
|
37
|
+
</div>
|
|
38
|
+
<h3 className="relative text-heading-3 text-[hsl(var(--text-primary))] mb-1">{title}</h3>
|
|
39
|
+
<p className="relative text-body text-[hsl(var(--text-secondary))] max-w-sm">{description}</p>
|
|
40
|
+
{actions && <div className="relative mt-6 flex gap-3">{actions}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { FilterPill } from './filter-pill'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FilterPill> = {
|
|
6
|
+
title: 'Components/FilterPill',
|
|
7
|
+
component: FilterPill,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
}
|
|
10
|
+
export default meta
|
|
11
|
+
type Story = StoryObj<typeof FilterPill>
|
|
12
|
+
|
|
13
|
+
export const Active: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
label: 'Network Devices',
|
|
16
|
+
active: true,
|
|
17
|
+
onClick: () => {},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const Inactive: Story = {
|
|
22
|
+
args: {
|
|
23
|
+
label: 'Firewalls',
|
|
24
|
+
active: false,
|
|
25
|
+
onClick: () => {},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const WithCount: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
label: 'Hosts',
|
|
32
|
+
count: 42,
|
|
33
|
+
active: true,
|
|
34
|
+
onClick: () => {},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WithCountInactive: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
label: 'VMs',
|
|
41
|
+
count: 128,
|
|
42
|
+
active: false,
|
|
43
|
+
onClick: () => {},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FilterGroup: Story = {
|
|
48
|
+
render: () => {
|
|
49
|
+
const [active, setActive] = useState('all')
|
|
50
|
+
const filters = [
|
|
51
|
+
{ key: 'all', label: 'All', count: 312 },
|
|
52
|
+
{ key: 'network', label: 'Network', count: 47 },
|
|
53
|
+
{ key: 'compute', label: 'Compute', count: 89 },
|
|
54
|
+
{ key: 'storage', label: 'Storage', count: 12 },
|
|
55
|
+
{ key: 'firewall', label: 'Firewall', count: 8 },
|
|
56
|
+
]
|
|
57
|
+
return (
|
|
58
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
59
|
+
{filters.map(f => (
|
|
60
|
+
<FilterPill
|
|
61
|
+
key={f.key}
|
|
62
|
+
label={f.label}
|
|
63
|
+
count={f.count}
|
|
64
|
+
active={active === f.key}
|
|
65
|
+
onClick={() => setActive(f.key)}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
export interface FilterPillProps {
|
|
6
|
+
/** Label text for the pill. */
|
|
7
|
+
label: string
|
|
8
|
+
/** Optional count displayed after the label. */
|
|
9
|
+
count?: number
|
|
10
|
+
/** Whether this pill is currently active/selected. */
|
|
11
|
+
active: boolean
|
|
12
|
+
/** Click handler. */
|
|
13
|
+
onClick: () => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @description A rounded pill-style filter toggle with active state and optional count.
|
|
19
|
+
* Uses CSS custom property tokens for dark/light mode compatibility.
|
|
20
|
+
*/
|
|
21
|
+
export function FilterPill({ label, count, active, onClick, className }: FilterPillProps) {
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
onClick={onClick}
|
|
26
|
+
className={cn(
|
|
27
|
+
'rounded-full px-3 py-1 text-xs transition-colors whitespace-nowrap',
|
|
28
|
+
active
|
|
29
|
+
? 'bg-[hsl(var(--brand-primary))]/10 text-[hsl(var(--brand-primary))] font-medium'
|
|
30
|
+
: 'bg-[hsl(var(--bg-elevated))] text-[hsl(var(--text-secondary))] hover:bg-[hsl(var(--bg-overlay))]',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{label}
|
|
35
|
+
{count != null && (
|
|
36
|
+
<span className={cn(
|
|
37
|
+
'ml-1.5 tabular-nums',
|
|
38
|
+
active ? 'text-[hsl(var(--brand-primary))]/70' : 'text-[hsl(var(--text-tertiary))]',
|
|
39
|
+
)}>
|
|
40
|
+
{count}
|
|
41
|
+
</span>
|
|
42
|
+
)}
|
|
43
|
+
</button>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { FormInput } from './form-input'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FormInput> = {
|
|
6
|
+
title: 'Components/FormInput',
|
|
7
|
+
component: FormInput,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
type: { control: 'select', options: ['text', 'email', 'password', 'number', 'url'] },
|
|
11
|
+
required: { control: 'boolean' },
|
|
12
|
+
disabled: { control: 'boolean' },
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof FormInput>
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {
|
|
19
|
+
render: (args) => {
|
|
20
|
+
const [value, setValue] = useState('')
|
|
21
|
+
return <FormInput {...args} value={value} onChange={setValue} />
|
|
22
|
+
},
|
|
23
|
+
args: {
|
|
24
|
+
label: 'Hostname',
|
|
25
|
+
placeholder: 'e.g. switch-core-01',
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const WithLabel: Story = {
|
|
30
|
+
render: () => {
|
|
31
|
+
const [value, setValue] = useState('admin@netrak.io')
|
|
32
|
+
return <FormInput label="Email" type="email" value={value} onChange={setValue} />
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const WithHint: Story = {
|
|
37
|
+
render: () => {
|
|
38
|
+
const [value, setValue] = useState('')
|
|
39
|
+
return (
|
|
40
|
+
<FormInput
|
|
41
|
+
label="API Key"
|
|
42
|
+
value={value}
|
|
43
|
+
onChange={setValue}
|
|
44
|
+
hint="Generate a key from Settings > API Keys."
|
|
45
|
+
placeholder="sk-..."
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const Required: Story = {
|
|
52
|
+
render: () => {
|
|
53
|
+
const [value, setValue] = useState('')
|
|
54
|
+
return (
|
|
55
|
+
<FormInput
|
|
56
|
+
label="Device Name"
|
|
57
|
+
value={value}
|
|
58
|
+
onChange={setValue}
|
|
59
|
+
required
|
|
60
|
+
placeholder="Required field"
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const Disabled: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<FormInput
|
|
69
|
+
label="Read Only"
|
|
70
|
+
value="Cannot edit this"
|
|
71
|
+
onChange={() => {}}
|
|
72
|
+
disabled
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const Password: Story = {
|
|
78
|
+
render: () => {
|
|
79
|
+
const [value, setValue] = useState('')
|
|
80
|
+
return (
|
|
81
|
+
<FormInput
|
|
82
|
+
label="Password"
|
|
83
|
+
type="password"
|
|
84
|
+
value={value}
|
|
85
|
+
onChange={setValue}
|
|
86
|
+
required
|
|
87
|
+
placeholder="Enter password"
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
// ── Shared class constants ──────────────────────────────────────────────────
|
|
6
|
+
// Import these in any page that needs raw class strings (e.g. for <textarea>)
|
|
7
|
+
|
|
8
|
+
export const INPUT_CLS = cn(
|
|
9
|
+
'w-full rounded-lg border border-[hsl(var(--border-default))]',
|
|
10
|
+
'bg-[hsl(var(--bg-base))] px-3 py-2 text-sm',
|
|
11
|
+
'text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-tertiary))]',
|
|
12
|
+
'focus:outline-none focus:ring-2 focus:ring-[hsl(var(--brand-primary))]',
|
|
13
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
export const LABEL_CLS = cn(
|
|
17
|
+
'mb-1.5 block text-xs font-medium uppercase tracking-wider',
|
|
18
|
+
'text-[hsl(var(--text-secondary))]',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export const TEXTAREA_CLS = cn(
|
|
22
|
+
INPUT_CLS,
|
|
23
|
+
'resize-none font-mono text-xs leading-relaxed',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export interface FormInputProps {
|
|
27
|
+
/** Label text displayed above the input. */
|
|
28
|
+
label: string
|
|
29
|
+
/** Current input value. */
|
|
30
|
+
value: string
|
|
31
|
+
/** Callback when value changes. */
|
|
32
|
+
onChange: (value: string) => void
|
|
33
|
+
/** HTML input type. */
|
|
34
|
+
type?: string
|
|
35
|
+
/** Placeholder text. */
|
|
36
|
+
placeholder?: string
|
|
37
|
+
/** Mark the field as required. */
|
|
38
|
+
required?: boolean
|
|
39
|
+
/** Disable the input. */
|
|
40
|
+
disabled?: boolean
|
|
41
|
+
/** Help text shown below the input. */
|
|
42
|
+
hint?: string
|
|
43
|
+
className?: string
|
|
44
|
+
/** HTML autocomplete attribute. */
|
|
45
|
+
autoComplete?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @description A themed form input with label, validation indicator, and optional hint text.
|
|
50
|
+
* Uses CSS custom property tokens for dark/light mode compatibility.
|
|
51
|
+
*/
|
|
52
|
+
export function FormInput({
|
|
53
|
+
label, value, onChange, type = 'text',
|
|
54
|
+
placeholder, required, disabled, hint, className, autoComplete,
|
|
55
|
+
}: FormInputProps) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn('space-y-1.5', className)}>
|
|
58
|
+
<label className={LABEL_CLS}>
|
|
59
|
+
{label}
|
|
60
|
+
{required && <span className="text-[hsl(var(--status-critical))] ml-0.5">*</span>}
|
|
61
|
+
</label>
|
|
62
|
+
<input
|
|
63
|
+
type={type}
|
|
64
|
+
value={value}
|
|
65
|
+
onChange={(e) => onChange(e.target.value)}
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
required={required}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
autoComplete={autoComplete}
|
|
70
|
+
className={INPUT_CLS}
|
|
71
|
+
/>
|
|
72
|
+
{hint && (
|
|
73
|
+
<p className="text-[10px] text-[hsl(var(--text-tertiary))]">{hint}</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { ArrowDown, Search } from 'lucide-react'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
|
|
8
|
+
export interface LogEntry {
|
|
9
|
+
/** ISO 8601 timestamp. */
|
|
10
|
+
time: string
|
|
11
|
+
/** Log level. */
|
|
12
|
+
level: 'error' | 'warn' | 'info' | 'debug' | 'trace'
|
|
13
|
+
/** Log message content. */
|
|
14
|
+
message: string
|
|
15
|
+
/** Optional source identifier. */
|
|
16
|
+
source?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LogViewerProps {
|
|
20
|
+
/** Log entries to display. */
|
|
21
|
+
entries: LogEntry[]
|
|
22
|
+
/** Maximum container height in pixels. */
|
|
23
|
+
maxHeight?: number
|
|
24
|
+
/** Auto-scroll to bottom on new entries. */
|
|
25
|
+
autoScroll?: boolean
|
|
26
|
+
/** Show timestamps column. */
|
|
27
|
+
showTimestamps?: boolean
|
|
28
|
+
/** Show level badge. */
|
|
29
|
+
showLevel?: boolean
|
|
30
|
+
/** Callback when a log entry is clicked. */
|
|
31
|
+
onEntryClick?: (entry: LogEntry) => void
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const levelBorder: Record<string, string> = {
|
|
36
|
+
error: 'border-l-[hsl(var(--status-critical))]',
|
|
37
|
+
warn: 'border-l-[hsl(var(--status-warning))]',
|
|
38
|
+
info: 'border-l-[hsl(var(--brand-primary))]',
|
|
39
|
+
debug: 'border-l-[hsl(var(--text-tertiary))]',
|
|
40
|
+
trace: 'border-l-[hsl(var(--text-disabled))]',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const levelText: Record<string, string> = {
|
|
44
|
+
error: 'text-[hsl(var(--status-critical))]',
|
|
45
|
+
warn: 'text-[hsl(var(--status-warning))]',
|
|
46
|
+
info: 'text-[hsl(var(--brand-primary))]',
|
|
47
|
+
debug: 'text-[hsl(var(--text-tertiary))]',
|
|
48
|
+
trace: 'text-[hsl(var(--text-disabled))]',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const levelLabel: Record<string, string> = {
|
|
52
|
+
error: 'ERR',
|
|
53
|
+
warn: 'WRN',
|
|
54
|
+
info: 'INF',
|
|
55
|
+
debug: 'DBG',
|
|
56
|
+
trace: 'TRC',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatTs(iso: string): string {
|
|
60
|
+
try {
|
|
61
|
+
const d = new Date(iso)
|
|
62
|
+
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
63
|
+
} catch {
|
|
64
|
+
return iso
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @description A log stream viewer with severity-colored borders, auto-scroll,
|
|
70
|
+
* search filtering, and a "new entries" indicator when scrolled up.
|
|
71
|
+
* Designed for real-time log tailing in monitoring dashboards.
|
|
72
|
+
*/
|
|
73
|
+
export function LogViewer({
|
|
74
|
+
entries,
|
|
75
|
+
maxHeight = 400,
|
|
76
|
+
autoScroll = true,
|
|
77
|
+
showTimestamps = true,
|
|
78
|
+
showLevel = true,
|
|
79
|
+
onEntryClick,
|
|
80
|
+
className,
|
|
81
|
+
}: LogViewerProps) {
|
|
82
|
+
const reduced = useReducedMotion()
|
|
83
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
84
|
+
const [isAtBottom, setIsAtBottom] = useState(true)
|
|
85
|
+
const [filter, setFilter] = useState('')
|
|
86
|
+
const [newCount, setNewCount] = useState(0)
|
|
87
|
+
const prevLenRef = useRef(entries.length)
|
|
88
|
+
|
|
89
|
+
const checkAtBottom = useCallback(() => {
|
|
90
|
+
const el = containerRef.current
|
|
91
|
+
if (!el) return
|
|
92
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 32
|
|
93
|
+
setIsAtBottom(atBottom)
|
|
94
|
+
if (atBottom) setNewCount(0)
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
// Auto-scroll on new entries
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const added = entries.length - prevLenRef.current
|
|
100
|
+
prevLenRef.current = entries.length
|
|
101
|
+
|
|
102
|
+
if (added > 0 && !isAtBottom) {
|
|
103
|
+
setNewCount(prev => prev + added)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (autoScroll && isAtBottom && containerRef.current) {
|
|
107
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
108
|
+
}
|
|
109
|
+
}, [entries.length, autoScroll, isAtBottom])
|
|
110
|
+
|
|
111
|
+
const scrollToBottom = useCallback(() => {
|
|
112
|
+
if (containerRef.current) {
|
|
113
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
114
|
+
setNewCount(0)
|
|
115
|
+
setIsAtBottom(true)
|
|
116
|
+
}
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const lowerFilter = filter.toLowerCase()
|
|
120
|
+
const filtered = filter
|
|
121
|
+
? entries.filter(e =>
|
|
122
|
+
e.message.toLowerCase().includes(lowerFilter) ||
|
|
123
|
+
e.source?.toLowerCase().includes(lowerFilter) ||
|
|
124
|
+
e.level.includes(lowerFilter)
|
|
125
|
+
)
|
|
126
|
+
: entries
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={cn('relative rounded-xl border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] overflow-hidden', className)}>
|
|
130
|
+
{/* Search bar */}
|
|
131
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-elevated))]">
|
|
132
|
+
<Search className="size-3.5 text-[hsl(var(--text-tertiary))] shrink-0" />
|
|
133
|
+
<input
|
|
134
|
+
type="text"
|
|
135
|
+
value={filter}
|
|
136
|
+
onChange={e => setFilter(e.target.value)}
|
|
137
|
+
placeholder="Filter logs..."
|
|
138
|
+
className="flex-1 bg-transparent text-[0.75rem] text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-disabled))] outline-none font-mono"
|
|
139
|
+
/>
|
|
140
|
+
{filter && (
|
|
141
|
+
<span className="text-[0.6875rem] text-[hsl(var(--text-tertiary))] tabular-nums shrink-0">
|
|
142
|
+
{filtered.length} / {entries.length}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Log entries */}
|
|
148
|
+
<div
|
|
149
|
+
ref={containerRef}
|
|
150
|
+
onScroll={checkAtBottom}
|
|
151
|
+
className="overflow-y-auto"
|
|
152
|
+
style={{ maxHeight }}
|
|
153
|
+
>
|
|
154
|
+
{filtered.map((entry, i) => (
|
|
155
|
+
<div
|
|
156
|
+
key={`${entry.time}-${i}`}
|
|
157
|
+
onClick={() => onEntryClick?.(entry)}
|
|
158
|
+
className={cn(
|
|
159
|
+
'flex items-start gap-2 px-3 py-1 border-l-2 font-mono text-[0.75rem] leading-5',
|
|
160
|
+
'hover:bg-[hsl(var(--bg-elevated))] transition-colors',
|
|
161
|
+
onEntryClick && 'cursor-pointer',
|
|
162
|
+
levelBorder[entry.level] ?? levelBorder.info,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{showTimestamps && (
|
|
166
|
+
<span className="text-[hsl(var(--text-tertiary))] tabular-nums shrink-0 select-all">
|
|
167
|
+
{formatTs(entry.time)}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
{showLevel && (
|
|
171
|
+
<span className={cn('font-semibold shrink-0 w-[2.5ch]', levelText[entry.level] ?? levelText.info)}>
|
|
172
|
+
{levelLabel[entry.level] ?? entry.level.toUpperCase().slice(0, 3)}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
{entry.source && (
|
|
176
|
+
<span className="text-[hsl(var(--text-secondary))] shrink-0">
|
|
177
|
+
[{entry.source}]
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
<span className="text-[hsl(var(--text-primary))] break-all min-w-0">
|
|
181
|
+
{entry.message}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* New entries indicator */}
|
|
188
|
+
<AnimatePresence>
|
|
189
|
+
{newCount > 0 && !isAtBottom && (
|
|
190
|
+
<motion.button
|
|
191
|
+
type="button"
|
|
192
|
+
initial={reduced ? false : { opacity: 0, y: 8 }}
|
|
193
|
+
animate={{ opacity: 1, y: 0 }}
|
|
194
|
+
exit={{ opacity: 0, y: 8 }}
|
|
195
|
+
transition={{ duration: 0.15 }}
|
|
196
|
+
onClick={scrollToBottom}
|
|
197
|
+
className={cn(
|
|
198
|
+
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
|
199
|
+
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full',
|
|
200
|
+
'bg-[hsl(var(--brand-primary))] text-[hsl(var(--text-on-brand))]',
|
|
201
|
+
'text-[0.6875rem] font-medium shadow-lg cursor-pointer',
|
|
202
|
+
'hover:opacity-90 transition-opacity',
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
<ArrowDown className="size-3" />
|
|
206
|
+
{newCount} new {newCount === 1 ? 'entry' : 'entries'}
|
|
207
|
+
</motion.button>
|
|
208
|
+
)}
|
|
209
|
+
</AnimatePresence>
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|