Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions frontend/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React from 'react'
import { motion } from 'framer-motion'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Archive02Icon,
File01Icon,
Inbox01Icon,
ZapOffIcon,
AlertCircleIcon,
} from '@hugeicons/core-free-icons'

interface EmptyStateProps {
type?: 'scans' | 'reports' | 'findings' | 'assets' | 'generic'
title?: string
description?: string
action?: {
label: string
onClick: () => void
}
className?: string
}

function EmptyIcon({
icon,
size = 48,
className = '',
}: {
icon: any
size?: number
className?: string
}) {
return <HugeiconsIcon icon={icon} size={size} strokeWidth={1.9} className={className} />
}

const emptyStateConfigs = {
scans: {
icon: Inbox01Icon,
title: 'No Scans Available',
description: 'Start a new scan to begin analyzing your targets and identifying vulnerabilities.',
defaultAction: 'Start Scan'
},
reports: {
icon: File01Icon,
title: 'No Reports Generated',
description: 'Complete a scan first to generate detailed security reports and findings.',
defaultAction: 'Run Scan'
},
findings: {
icon: AlertCircleIcon,
title: 'No Findings Detected',
description: 'Great news! No security issues were found in this scan.',
defaultAction: 'View Scans'
},
assets: {
icon: Archive02Icon,
title: 'No Assets Discovered',
description: 'Assets will appear here once scans complete and targets are identified.',
defaultAction: 'Start Discovery'
},
generic: {
icon: ZapOffIcon,
title: 'No Data Available',
description: 'There is currently no data to display.',
defaultAction: 'Refresh'
}
}

const containerVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.4, type: 'spring', stiffness: 200, damping: 20 }
}
}

export default function EmptyState({
type = 'generic',
title,
description,
action,
className = ''
}: EmptyStateProps) {
const config = emptyStateConfigs[type]
const displayTitle = title || config.title
const displayDescription = description || config.description

return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className={`py-20 px-8 text-center ${className}`}
>
<div className="flex flex-col items-center gap-8 max-w-2xl mx-auto">
{/* Icon Container */}
<div className="p-6 border-4 border-dashed border-silver-bright/20 bg-charcoal/50">
<EmptyIcon icon={config.icon} size={64} className="text-silver/30" />
</div>

{/* Text Content */}
<div className="space-y-4">
<h3 className="text-3xl md:text-4xl font-black text-silver-bright uppercase tracking-tighter italic">
{displayTitle}
</h3>
<p className="text-sm md:text-base font-mono text-silver/50 uppercase tracking-widest leading-relaxed italic">
{displayDescription}
</p>
</div>

{/* Action Button */}
{action && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={action.onClick}
className="mt-8 px-8 py-4 bg-rag-blue text-black font-black uppercase text-xs tracking-widest border-4 border-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] transition-all"
>
{action.label}
</motion.button>
)}

{/* Decorative Elements */}
<div className="pt-8 border-t-4 border-dashed border-silver-bright/10 w-full">
<p className="text-[10px] font-black text-silver/20 uppercase tracking-[0.3em] italic">
STATUS: IDLE // NO_ACTIVE_OPERATIONS
</p>
</div>
</div>
</motion.div>
)
}

export function EmptyStateInline({
type = 'generic',
title,
description,
className = ''
}: Omit<EmptyStateProps, 'action'>) {
const config = emptyStateConfigs[type]
const displayTitle = title || config.title
const displayDescription = description || config.description

return (
<div className={`py-12 px-6 text-center border-4 border-dashed border-silver-bright/10 bg-charcoal-dark/30 ${className}`}>
<div className="flex flex-col items-center gap-6">
<EmptyIcon icon={config.icon} size={40} className="text-silver/20" />
<div className="space-y-2">
<p className="text-sm font-black text-silver/40 uppercase tracking-widest">{displayTitle}</p>
<p className="text-xs font-mono text-silver/30 uppercase tracking-widest">{displayDescription}</p>
</div>
</div>
</div>
)
}
241 changes: 241 additions & 0 deletions frontend/src/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import React from 'react'
import { motion } from 'framer-motion'
import { HugeiconsIcon } from '@hugeicons/react'
import { Refresh01Icon, LoaderIcon } from '@hugeicons/core-free-icons'

interface LoadingIndicatorProps {
message?: string
size?: 'small' | 'medium' | 'large'
variant?: 'spinner' | 'dots' | 'bars' | 'pulse'
fullScreen?: boolean
className?: string
}

function Icon({
icon,
size = 32,
className = '',
}: {
icon: any
size?: number
className?: string
}) {
return <HugeiconsIcon icon={icon} size={size} strokeWidth={1.9} className={className} />
}

const sizeMap = {
small: { icon: 24, container: 'py-8' },
medium: { icon: 40, container: 'py-20' },
large: { icon: 64, container: 'py-32' }
}

const spinnerVariants = {
rotate: {
rotate: 360,
transition: {
duration: 1.5,
repeat: Infinity,
ease: 'linear'
}
}
}

const dotsVariants = {
animate: {
transition: {
staggerChildren: 0.2
}
}
}

const dotVariants = {
animate: {
y: [-8, 0],
opacity: [0.5, 1],
transition: {
duration: 0.8,
repeat: Infinity,
repeatType: 'reverse' as const
}
}
}

const barsVariants = {
animate: {
transition: {
staggerChildren: 0.1
}
}
}

const barVariants = {
animate: {
scaleY: [0.4, 1],
opacity: [0.5, 1],
transition: {
duration: 0.6,
repeat: Infinity,
repeatType: 'reverse' as const
}
}
}

export function SpinnerIndicator({ size = 40 }: { size?: number }) {
return (
<motion.div variants={spinnerVariants} animate="rotate">
<Icon icon={Refresh01Icon} size={size} className="text-silver/40" />
</motion.div>
)
}

export function DotsIndicator({ size = 12 }: { size?: number }) {
return (
<motion.div
variants={dotsVariants}
animate="animate"
className="flex items-center gap-2"
>
{[...Array(3)].map((_, i) => (
<motion.div
key={i}
variants={dotVariants}
className={`w-${size} h-${size} rounded-full bg-silver/40`}
style={{ width: size, height: size }}
/>
))}
</motion.div>
)
}

export function BarsIndicator({ size = 24 }: { size?: number }) {
return (
<motion.div
variants={barsVariants}
animate="animate"
className="flex items-end gap-2 h-16"
>
{[...Array(4)].map((_, i) => (
<motion.div
key={i}
variants={barVariants}
className="w-1 bg-silver/40 rounded-full origin-bottom"
style={{ height: (i + 1) * 16 }}
/>
))}
</motion.div>
)
}

export function PulseIndicator() {
return (
<motion.div
animate={{
scale: [0.8, 1, 0.8],
opacity: [0.5, 1, 0.5]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: 'easeInOut'
}}
className="w-12 h-12 rounded-full border-4 border-silver/40"
/>
)
}

export default function LoadingIndicator({
message = 'Loading...',
size = 'medium',
variant = 'spinner',
fullScreen = false,
className = ''
}: LoadingIndicatorProps) {
const sizeConfig = sizeMap[size]

const renderIndicator = () => {
switch (variant) {
case 'dots':
return <DotsIndicator />
case 'bars':
return <BarsIndicator />
case 'pulse':
return <PulseIndicator />
default:
return <SpinnerIndicator size={sizeConfig.icon} />
}
}

const containerClass = fullScreen
? 'fixed inset-0 flex items-center justify-center bg-charcoal-dark/80 backdrop-blur-sm z-50'
: `flex flex-col items-center justify-center gap-6 ${sizeConfig.container} ${className}`

return (
<div className={containerClass}>
<div className="flex flex-col items-center gap-6">
{renderIndicator()}
{message && (
<motion.p
animate={{ opacity: [0.6, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
className="text-xs md:text-sm font-black uppercase tracking-[0.3em] text-silver/50 italic text-center"
>
{message}
</motion.p>
)}
</div>
</div>
)
}

// Progress indicator for multi-step operations
export function ProgressIndicator({
current = 0,
total = 10,
message = 'Processing...'
}: {
current?: number
total?: number
message?: string
}) {
const percentage = (current / total) * 100

return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between items-center">
<p className="text-xs font-black text-silver/50 uppercase tracking-widest italic">
{message}
</p>
<span className="text-[10px] font-mono text-silver/40 uppercase font-black">
{current} / {total}
</span>
</div>
<div className="w-full h-2 bg-charcoal-dark border-2 border-black overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percentage}%` }}
transition={{ duration: 0.5 }}
className="h-full bg-rag-green"
/>
</div>
</div>
</div>
)
}

// Skeleton with loading indicator overlay
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-charcoal-dark/60 backdrop-blur-sm flex items-center justify-center rounded-lg"
>
<div className="flex flex-col items-center gap-4">
<SpinnerIndicator size={48} />
<p className="text-xs font-black text-silver-bright uppercase tracking-widest">{message}</p>
</div>
</motion.div>
)
}
Loading
Loading