@mohammad-irfan/create-dashboard-kit 1.0.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 +153 -0
- package/bin/create-dashboard.js +12 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +223 -0
- package/dist/configBuilder.d.ts +8 -0
- package/dist/configBuilder.js +573 -0
- package/dist/generator.d.ts +9 -0
- package/dist/generator.js +223 -0
- package/dist/presets.d.ts +8 -0
- package/dist/presets.js +72 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +121 -0
- package/dist/templateEngine.d.ts +3 -0
- package/dist/templateEngine.js +31 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +16 -0
- package/dist/validation.js +73 -0
- package/package.json +56 -0
- package/templates/source/components/controls/Button.tsx +69 -0
- package/templates/source/components/controls/Input.tsx +59 -0
- package/templates/source/components/controls/SearchBar.tsx +44 -0
- package/templates/source/components/controls/Select.tsx +61 -0
- package/templates/source/components/controls/ThemeToggle.tsx +23 -0
- package/templates/source/components/feedback/NotificationCenter.tsx +151 -0
- package/templates/source/components/feedback/Skeleton.tsx +22 -0
- package/templates/source/components/feedback/StatusBadge.tsx +35 -0
- package/templates/source/components/feedback/statusBadgeStyles.ts +28 -0
- package/templates/source/components/icons/IconSet.tsx +235 -0
- package/templates/source/components/layout/AppShell.tsx +70 -0
- package/templates/source/components/layout/Header.tsx +90 -0
- package/templates/source/components/layout/PageHeader.tsx +27 -0
- package/templates/source/components/layout/Sidebar.tsx +202 -0
- package/templates/source/components/metrics/MetricRow.tsx +61 -0
- package/templates/source/components/metrics/StatCard.tsx +49 -0
- package/templates/source/components/navigation/NavItem.tsx +75 -0
- package/templates/source/components/navigation/ShortcutPill.tsx +39 -0
- package/templates/source/components/surfaces/EmptyState.tsx +36 -0
- package/templates/source/components/surfaces/HeroSurface.tsx +64 -0
- package/templates/source/components/surfaces/Panel.tsx +43 -0
- package/templates/source/components/surfaces/PriorityCard.tsx +75 -0
- package/templates/source/components/surfaces/QuietListCard.tsx +146 -0
- package/templates/source/design-system/animations.css +25 -0
- package/templates/source/design-system/tokens.css +74 -0
- package/templates/source/design-system/typography.css +34 -0
- package/templates/source/theme/ThemeContext.tsx +82 -0
- package/templates/source/theme/useTheme.ts +10 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import type { ButtonShape } from '../../config/dashboard.config.ts'
|
|
3
|
+
import { getButtonShapeClass } from '../../config/dashboard.config.ts'
|
|
4
|
+
|
|
5
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
|
6
|
+
export type ButtonSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
|
|
8
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: ButtonVariant
|
|
10
|
+
size?: ButtonSize
|
|
11
|
+
shape?: ButtonShape
|
|
12
|
+
leftIcon?: ReactNode
|
|
13
|
+
rightIcon?: ReactNode
|
|
14
|
+
isLoading?: boolean
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Button({
|
|
19
|
+
variant = 'primary',
|
|
20
|
+
size = 'md',
|
|
21
|
+
shape = 'pill',
|
|
22
|
+
leftIcon,
|
|
23
|
+
rightIcon,
|
|
24
|
+
isLoading,
|
|
25
|
+
disabled,
|
|
26
|
+
className = '',
|
|
27
|
+
children,
|
|
28
|
+
...props
|
|
29
|
+
}: ButtonProps) {
|
|
30
|
+
const shapeClass = getButtonShapeClass(shape)
|
|
31
|
+
|
|
32
|
+
const sizeClasses = {
|
|
33
|
+
sm: 'px-3 py-1.5 text-xs gap-1.5',
|
|
34
|
+
md: 'px-4 py-2 text-sm gap-2',
|
|
35
|
+
lg: 'px-5 py-2.5 text-base gap-2.5 font-semibold',
|
|
36
|
+
}[size]
|
|
37
|
+
|
|
38
|
+
const variantClasses = {
|
|
39
|
+
primary:
|
|
40
|
+
'bg-primary text-primary-foreground shadow-2xs hover:bg-primary/90 hover:shadow-xs active:scale-[0.99]',
|
|
41
|
+
secondary:
|
|
42
|
+
'border border-border/80 bg-surface text-foreground shadow-2xs hover:bg-muted/70 active:scale-[0.99]',
|
|
43
|
+
outline:
|
|
44
|
+
'border border-border bg-transparent text-foreground hover:bg-muted/60 active:scale-[0.99]',
|
|
45
|
+
ghost:
|
|
46
|
+
'text-muted-foreground hover:bg-muted/60 hover:text-foreground active:scale-[0.99]',
|
|
47
|
+
danger:
|
|
48
|
+
'bg-danger text-white shadow-2xs hover:bg-danger/90 active:scale-[0.99]',
|
|
49
|
+
}[variant]
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
disabled={disabled || isLoading}
|
|
54
|
+
className={`inline-flex items-center justify-center font-medium transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60 select-none ${shapeClass} ${sizeClasses} ${variantClasses} ${className}`}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
{isLoading ? (
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
|
61
|
+
/>
|
|
62
|
+
) : leftIcon ? (
|
|
63
|
+
<span className="shrink-0">{leftIcon}</span>
|
|
64
|
+
) : null}
|
|
65
|
+
{children}
|
|
66
|
+
{!isLoading && rightIcon ? <span className="shrink-0">{rightIcon}</span> : null}
|
|
67
|
+
</button>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
4
|
+
label?: string
|
|
5
|
+
error?: string
|
|
6
|
+
helperText?: string
|
|
7
|
+
leftIcon?: ReactNode
|
|
8
|
+
rightIcon?: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Input({
|
|
12
|
+
id,
|
|
13
|
+
label,
|
|
14
|
+
error,
|
|
15
|
+
helperText,
|
|
16
|
+
leftIcon,
|
|
17
|
+
rightIcon,
|
|
18
|
+
className = '',
|
|
19
|
+
...props
|
|
20
|
+
}: InputProps) {
|
|
21
|
+
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="w-full">
|
|
25
|
+
{label ? (
|
|
26
|
+
<label htmlFor={inputId} className="block text-sm font-medium text-foreground mb-1">
|
|
27
|
+
{label}
|
|
28
|
+
</label>
|
|
29
|
+
) : null}
|
|
30
|
+
<div className="relative flex items-center">
|
|
31
|
+
{leftIcon ? (
|
|
32
|
+
<span className="pointer-events-none absolute left-3 flex items-center text-muted-foreground">
|
|
33
|
+
{leftIcon}
|
|
34
|
+
</span>
|
|
35
|
+
) : null}
|
|
36
|
+
<input
|
|
37
|
+
id={inputId}
|
|
38
|
+
aria-invalid={Boolean(error)}
|
|
39
|
+
className={`block w-full rounded-lg border bg-input px-3.5 py-2 text-sm text-foreground placeholder:text-muted-foreground shadow-2xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
40
|
+
error
|
|
41
|
+
? 'border-danger focus-visible:ring-danger'
|
|
42
|
+
: 'border-border focus:border-primary'
|
|
43
|
+
} ${leftIcon ? 'pl-10' : ''} ${rightIcon ? 'pr-10' : ''} ${className}`}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
{rightIcon ? (
|
|
47
|
+
<span className="absolute right-3 flex items-center text-muted-foreground">
|
|
48
|
+
{rightIcon}
|
|
49
|
+
</span>
|
|
50
|
+
) : null}
|
|
51
|
+
</div>
|
|
52
|
+
{error ? (
|
|
53
|
+
<p className="mt-1 text-xs text-danger-fg">{error}</p>
|
|
54
|
+
) : helperText ? (
|
|
55
|
+
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChangeEvent } from 'react'
|
|
2
|
+
import { CloseIcon, SearchIcon } from '../icons/IconSet.tsx'
|
|
3
|
+
|
|
4
|
+
export interface SearchBarProps {
|
|
5
|
+
value: string
|
|
6
|
+
onChange: (value: string) => void
|
|
7
|
+
placeholder?: string
|
|
8
|
+
ariaLabel?: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SearchBar({
|
|
13
|
+
value,
|
|
14
|
+
onChange,
|
|
15
|
+
placeholder = 'Search...',
|
|
16
|
+
ariaLabel = 'Search records',
|
|
17
|
+
className = '',
|
|
18
|
+
}: SearchBarProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={`relative flex items-center ${className}`}>
|
|
21
|
+
<span className="pointer-events-none absolute left-3 flex items-center text-muted-foreground">
|
|
22
|
+
<SearchIcon className="h-4 w-4" />
|
|
23
|
+
</span>
|
|
24
|
+
<input
|
|
25
|
+
type="search"
|
|
26
|
+
value={value}
|
|
27
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
aria-label={ariaLabel}
|
|
30
|
+
className="block w-full rounded-lg border border-border bg-input pl-9 pr-8 py-1.5 text-sm text-foreground placeholder:text-muted-foreground shadow-2xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-primary"
|
|
31
|
+
/>
|
|
32
|
+
{value ? (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={() => onChange('')}
|
|
36
|
+
aria-label="Clear search"
|
|
37
|
+
className="absolute right-2.5 flex items-center justify-center rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none"
|
|
38
|
+
>
|
|
39
|
+
<CloseIcon className="h-3.5 w-3.5" />
|
|
40
|
+
</button>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode, SelectHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SelectOption {
|
|
4
|
+
value: string | number
|
|
5
|
+
label: string
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
10
|
+
label?: string
|
|
11
|
+
options?: SelectOption[]
|
|
12
|
+
error?: string
|
|
13
|
+
helperText?: string
|
|
14
|
+
children?: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Select({
|
|
18
|
+
id,
|
|
19
|
+
label,
|
|
20
|
+
options,
|
|
21
|
+
error,
|
|
22
|
+
helperText,
|
|
23
|
+
className = '',
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: SelectProps) {
|
|
27
|
+
const selectId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="w-full">
|
|
31
|
+
{label ? (
|
|
32
|
+
<label htmlFor={selectId} className="block text-sm font-medium text-foreground mb-1">
|
|
33
|
+
{label}
|
|
34
|
+
</label>
|
|
35
|
+
) : null}
|
|
36
|
+
<select
|
|
37
|
+
id={selectId}
|
|
38
|
+
aria-invalid={Boolean(error)}
|
|
39
|
+
className={`block w-full rounded-lg border bg-input px-3 py-2 text-sm font-medium text-foreground shadow-2xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
40
|
+
error
|
|
41
|
+
? 'border-danger focus-visible:ring-danger'
|
|
42
|
+
: 'border-border focus:border-primary'
|
|
43
|
+
} ${className}`}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
{options
|
|
47
|
+
? options.map((opt) => (
|
|
48
|
+
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
49
|
+
{opt.label}
|
|
50
|
+
</option>
|
|
51
|
+
))
|
|
52
|
+
: children}
|
|
53
|
+
</select>
|
|
54
|
+
{error ? (
|
|
55
|
+
<p className="mt-1 text-xs text-danger-fg">{error}</p>
|
|
56
|
+
) : helperText ? (
|
|
57
|
+
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
|
58
|
+
) : null}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MoonIcon, SunIcon } from '../icons/IconSet.tsx'
|
|
2
|
+
import { useTheme } from '../../theme/useTheme.ts'
|
|
3
|
+
|
|
4
|
+
export interface ThemeToggleProps {
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ThemeToggle({ className = '' }: ThemeToggleProps) {
|
|
9
|
+
const { isDark, toggleTheme } = useTheme()
|
|
10
|
+
const label = isDark ? 'Switch to light theme' : 'Switch to dark theme'
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
onClick={toggleTheme}
|
|
16
|
+
aria-label={label}
|
|
17
|
+
title={label}
|
|
18
|
+
className={`inline-flex items-center justify-center rounded-xl p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
|
19
|
+
>
|
|
20
|
+
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
|
|
21
|
+
</button>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
|
2
|
+
import { BellIcon, CheckIcon } from '../icons/IconSet.tsx'
|
|
3
|
+
|
|
4
|
+
export interface NotificationItemData {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
message: string
|
|
8
|
+
timestamp: string
|
|
9
|
+
read?: boolean
|
|
10
|
+
link?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NotificationCenterProps {
|
|
14
|
+
notifications?: NotificationItemData[]
|
|
15
|
+
onMarkAllRead?: () => void
|
|
16
|
+
onItemClick?: (item: NotificationItemData) => void
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function NotificationCenter({
|
|
21
|
+
notifications = [],
|
|
22
|
+
onMarkAllRead,
|
|
23
|
+
onItemClick,
|
|
24
|
+
className = '',
|
|
25
|
+
}: NotificationCenterProps) {
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
const [position, setPosition] = useState<CSSProperties>({})
|
|
28
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
29
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
|
|
31
|
+
const unreadCount = notifications.filter((n) => !n.read).length
|
|
32
|
+
|
|
33
|
+
const handleToggle = () => {
|
|
34
|
+
if (!open && buttonRef.current) {
|
|
35
|
+
const rect = buttonRef.current.getBoundingClientRect()
|
|
36
|
+
setPosition({
|
|
37
|
+
top: rect.bottom + 8,
|
|
38
|
+
right: Math.max(12, window.innerWidth - rect.right),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
setOpen((prev) => !prev)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open) return
|
|
46
|
+
|
|
47
|
+
function onPointerDown(event: PointerEvent) {
|
|
48
|
+
if (
|
|
49
|
+
panelRef.current &&
|
|
50
|
+
!panelRef.current.contains(event.target as Node) &&
|
|
51
|
+
buttonRef.current &&
|
|
52
|
+
!buttonRef.current.contains(event.target as Node)
|
|
53
|
+
) {
|
|
54
|
+
setOpen(false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
59
|
+
if (event.key === 'Escape') {
|
|
60
|
+
setOpen(false)
|
|
61
|
+
buttonRef.current?.focus()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
66
|
+
document.addEventListener('keydown', onKeyDown)
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
70
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
71
|
+
}
|
|
72
|
+
}, [open])
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={`relative inline-block ${className}`}>
|
|
76
|
+
<button
|
|
77
|
+
ref={buttonRef}
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={handleToggle}
|
|
80
|
+
aria-label={`Notifications (${unreadCount} unread)`}
|
|
81
|
+
aria-haspopup="dialog"
|
|
82
|
+
aria-expanded={open}
|
|
83
|
+
className="relative inline-flex items-center justify-center rounded-xl p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
84
|
+
>
|
|
85
|
+
<BellIcon className="h-5 w-5" />
|
|
86
|
+
{unreadCount > 0 ? (
|
|
87
|
+
<span
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground"
|
|
90
|
+
>
|
|
91
|
+
{unreadCount > 9 ? '9+' : unreadCount}
|
|
92
|
+
</span>
|
|
93
|
+
) : null}
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{open ? (
|
|
97
|
+
<div
|
|
98
|
+
ref={panelRef}
|
|
99
|
+
role="dialog"
|
|
100
|
+
aria-label="Notification center"
|
|
101
|
+
style={position}
|
|
102
|
+
className="fixed z-50 w-[380px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border bg-surface text-foreground shadow-lg"
|
|
103
|
+
>
|
|
104
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
105
|
+
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
|
|
106
|
+
{onMarkAllRead && unreadCount > 0 ? (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={onMarkAllRead}
|
|
110
|
+
className="inline-flex items-center gap-1.5 rounded-md text-xs font-medium text-primary hover:text-primary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
111
|
+
>
|
|
112
|
+
<CheckIcon className="h-3.5 w-3.5" />
|
|
113
|
+
<span>Mark all read</span>
|
|
114
|
+
</button>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="max-h-80 overflow-y-auto divide-y divide-border/40">
|
|
119
|
+
{notifications.length === 0 ? (
|
|
120
|
+
<div className="p-8 text-center text-muted-foreground">
|
|
121
|
+
<BellIcon className="mx-auto h-6 w-6 mb-2 text-muted-foreground/60" />
|
|
122
|
+
<p className="text-xs font-medium">All caught up!</p>
|
|
123
|
+
<p className="text-[11px] text-muted-foreground/70 mt-0.5">
|
|
124
|
+
No new notifications right now.
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
) : (
|
|
128
|
+
notifications.map((item) => (
|
|
129
|
+
<div
|
|
130
|
+
key={item.id}
|
|
131
|
+
onClick={() => onItemClick?.(item)}
|
|
132
|
+
className={`p-3 text-left transition-colors cursor-pointer hover:bg-muted/40 ${
|
|
133
|
+
!item.read ? 'bg-primary/5' : ''
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
<div className="flex items-start justify-between gap-2">
|
|
137
|
+
<p className="text-xs font-bold text-foreground">{item.title}</p>
|
|
138
|
+
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
|
139
|
+
{item.timestamp}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{item.message}</p>
|
|
143
|
+
</div>
|
|
144
|
+
))
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SkeletonProps {
|
|
2
|
+
className?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function Skeleton({ className = '' }: SkeletonProps) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
className={`animate-pulse rounded-xl bg-muted/60 dark:bg-muted/40 ${className}`}
|
|
10
|
+
/>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CardSkeleton() {
|
|
15
|
+
return (
|
|
16
|
+
<div className="rounded-2xl border border-border/40 bg-surface p-6 shadow-2xs space-y-4">
|
|
17
|
+
<Skeleton className="h-6 w-1/3" />
|
|
18
|
+
<Skeleton className="h-4 w-2/3" />
|
|
19
|
+
<Skeleton className="h-12 w-full" />
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
STATUS_VARIANT_STYLES,
|
|
5
|
+
type StatusVariant,
|
|
6
|
+
} from './statusBadgeStyles.ts'
|
|
7
|
+
|
|
8
|
+
export { STATUS_VARIANT_STYLES, type StatusVariant }
|
|
9
|
+
|
|
10
|
+
export interface StatusBadgeProps {
|
|
11
|
+
label?: string
|
|
12
|
+
variant?: StatusVariant
|
|
13
|
+
dot?: boolean
|
|
14
|
+
className?: string
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function StatusBadge({
|
|
19
|
+
label,
|
|
20
|
+
variant = 'neutral',
|
|
21
|
+
dot = false,
|
|
22
|
+
className = '',
|
|
23
|
+
children,
|
|
24
|
+
}: StatusBadgeProps) {
|
|
25
|
+
const styles = STATUS_VARIANT_STYLES[variant] || STATUS_VARIANT_STYLES.neutral
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${styles.badge} ${className}`}
|
|
30
|
+
>
|
|
31
|
+
{dot ? <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${styles.dot}`} /> : null}
|
|
32
|
+
{children || label}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type StatusVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'purple'
|
|
2
|
+
|
|
3
|
+
export const STATUS_VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
|
|
4
|
+
success: {
|
|
5
|
+
badge: 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-500/25',
|
|
6
|
+
dot: 'bg-emerald-500',
|
|
7
|
+
},
|
|
8
|
+
warning: {
|
|
9
|
+
badge: 'bg-amber-100 text-amber-800 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-500/25',
|
|
10
|
+
dot: 'bg-amber-500',
|
|
11
|
+
},
|
|
12
|
+
danger: {
|
|
13
|
+
badge: 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-500/15 dark:text-red-300 dark:ring-red-500/25',
|
|
14
|
+
dot: 'bg-red-500',
|
|
15
|
+
},
|
|
16
|
+
info: {
|
|
17
|
+
badge: 'bg-blue-100 text-blue-700 ring-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:ring-blue-500/25',
|
|
18
|
+
dot: 'bg-blue-500',
|
|
19
|
+
},
|
|
20
|
+
neutral: {
|
|
21
|
+
badge: 'bg-slate-100 text-slate-700 ring-slate-200 dark:bg-slate-400/15 dark:text-slate-300 dark:ring-slate-400/25',
|
|
22
|
+
dot: 'bg-slate-400',
|
|
23
|
+
},
|
|
24
|
+
purple: {
|
|
25
|
+
badge: 'bg-purple-100 text-purple-700 ring-purple-200 dark:bg-purple-500/15 dark:text-purple-300 dark:ring-purple-500/25',
|
|
26
|
+
dot: 'bg-purple-500',
|
|
27
|
+
},
|
|
28
|
+
}
|