247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Moon, Sun, Monitor} from "lucide-react"
|
|
import * as LucideIcons from "lucide-react"
|
|
import { useTheme } from "@/components/theme-provider"
|
|
import { useMetrics } from "./hooks/useMetrics"
|
|
import { useConfig } from "./hooks/useConfig"
|
|
import { ServiceStatus } from "./types/metrics"
|
|
import { ServiceCard, Section, CardStyle } from "./types/services"
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
import { AlertCircle } from "lucide-react"
|
|
|
|
const getIconUrl = (name: string | undefined, isDark: boolean = false): string => {
|
|
if (!name) return ''
|
|
// Convert to selfh.st reference format
|
|
const ref = name.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
|
// Use light version for dark mode if available
|
|
const suffix = isDark ? '-light' : ''
|
|
return `https://cdn.jsdelivr.net/gh/selfhst/icons/png/${ref}${suffix}.png`
|
|
}
|
|
|
|
function ServiceIcon({ service, section, isDark }: { service: ServiceCard, section: Section, isDark: boolean }) {
|
|
if (service.icon) {
|
|
return <div className="h-6 w-6">{service.icon}</div>
|
|
}
|
|
|
|
if (service.iconName) {
|
|
// Handle Lucide icons
|
|
if (service.iconName.startsWith('lucide-')) {
|
|
const iconName = service.iconName.replace('lucide-', '')
|
|
.split('-')
|
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join('')
|
|
|
|
// Cast to any to avoid TypeScript complexity with dynamic imports
|
|
const Icon = (LucideIcons as any)[iconName] || LucideIcons.Box
|
|
return <Icon className="h-6 w-6" />
|
|
}
|
|
|
|
// Handle selfh.st icons
|
|
return (
|
|
<img
|
|
src={getIconUrl(service.iconName, isDark)}
|
|
className="h-6 w-6 object-contain"
|
|
alt={`${service.name} icon`}
|
|
onError={(e) => {
|
|
if (isDark && e.currentTarget.src.includes('-light')) {
|
|
e.currentTarget.src = getIconUrl(service.iconName, false)
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Use section's default icon
|
|
const Icon = (LucideIcons as any)[section.icon] || LucideIcons.Box
|
|
return <Icon className="h-6 w-6" />
|
|
}
|
|
|
|
function ThemeToggle() {
|
|
const { theme, setTheme } = useTheme()
|
|
|
|
const cycleTheme = () => {
|
|
if (theme === 'light') setTheme('dark')
|
|
else if (theme === 'dark') setTheme('system')
|
|
else setTheme('light')
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={cycleTheme}
|
|
className="relative w-10 h-10"
|
|
>
|
|
<Sun className={`h-[1.2rem] w-[1.2rem] transition-all ${
|
|
theme === 'light' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
|
|
}`} />
|
|
<Moon className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${
|
|
theme === 'dark' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
|
|
}`} />
|
|
<Monitor className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${
|
|
theme === 'system' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
|
|
}`} />
|
|
<span className="sr-only">Toggle theme</span>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function StatusIndicator({ status, responseTime, certDaysRemaining }: {
|
|
status: ServiceStatus,
|
|
responseTime: number,
|
|
certDaysRemaining: number
|
|
}) {
|
|
const getStatusColor = () => {
|
|
switch (status) {
|
|
case 'up':
|
|
return 'bg-green-500'
|
|
case 'down':
|
|
return 'bg-red-500'
|
|
case 'pending':
|
|
return 'bg-yellow-500'
|
|
case 'maintenance':
|
|
return 'bg-blue-500'
|
|
}
|
|
}
|
|
|
|
const getStatusText = () => {
|
|
const statusText = status.charAt(0).toUpperCase() + status.slice(1)
|
|
const responseTimeText = `Response time: ${responseTime}ms`
|
|
const certText = certDaysRemaining > 0
|
|
? `SSL cert expires in ${certDaysRemaining} days`
|
|
: 'SSL cert expired'
|
|
|
|
return `${statusText} • ${responseTimeText} • ${certText}`
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger className="bg-transparent border-none px-0 pr-2 py-0">
|
|
<div className={`w-3 h-3 rounded-full ${getStatusColor()}`} />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{getStatusText()}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
function ServiceSection({ section, services, metrics }: {
|
|
section: Section,
|
|
services: ServiceCard[],
|
|
metrics: Record<string, any>
|
|
}) {
|
|
const { theme } = useTheme()
|
|
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center">
|
|
<h2 className="text-2xl font-bold">{section.title}</h2>
|
|
<Separator className="flex-1 ml-4" />
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
|
{services.map((service) => {
|
|
const serviceMetrics = metrics[service.monitorName || service.name] || {
|
|
status: 'pending',
|
|
responseTime: 0,
|
|
certDaysRemaining: 0
|
|
}
|
|
|
|
const style: CardStyle = {
|
|
background: service.cardStyle?.background || section.cardStyle?.background || "bg-gray-50 dark:bg-gray-950/30",
|
|
iconColor: service.cardStyle?.iconColor || section.cardStyle?.iconColor || "text-gray-600 dark:text-gray-400"
|
|
}
|
|
|
|
return (
|
|
<a
|
|
key={service.name}
|
|
href={service.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="transition-transform hover:scale-105"
|
|
>
|
|
<Card className={`${style.background} hover:${style.background}`}>
|
|
<CardHeader className={`flex flex-row items-center justify-between space-y-0 pb-2 ${style.background}`}>
|
|
<div className="flex items-center">
|
|
<div className={style.iconColor}>
|
|
<ServiceIcon service={service} section={section} isDark={isDark} />
|
|
</div>
|
|
<CardTitle className="ml-2">{service.name}</CardTitle>
|
|
</div>
|
|
<StatusIndicator
|
|
status={serviceMetrics.status}
|
|
responseTime={serviceMetrics.responseTime}
|
|
certDaysRemaining={serviceMetrics.certDaysRemaining}
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className={style.background}>
|
|
<CardDescription>{service.description}</CardDescription>
|
|
</CardContent>
|
|
</Card>
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function App() {
|
|
const { metrics } = useMetrics()
|
|
const { config, error } = useConfig()
|
|
const { services, sections } = config
|
|
|
|
return (
|
|
<div className="min-h-screen p-8 space-y-8">
|
|
<div className="flex justify-between items-center">
|
|
<h1 className="text-4xl font-bold">Kent.pw Homepage</h1>
|
|
<ThemeToggle />
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>
|
|
Failed to load configuration: {error}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{sections?.length > 0 ? (
|
|
<>
|
|
{sections.map((section: Section) => {
|
|
const sectionServices = services.filter((s: { category: string }) => s.category === section.id)
|
|
if (sectionServices.length === 0) return null
|
|
|
|
return (
|
|
<ServiceSection
|
|
key={section.id}
|
|
section={section}
|
|
services={sectionServices}
|
|
metrics={metrics}
|
|
/>
|
|
)
|
|
})}
|
|
</>
|
|
) : !error && (
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Loading</AlertTitle>
|
|
<AlertDescription>
|
|
Loading service configuration...
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|