Files
homepage/src/App.tsx
2025-02-08 18:38:25 -05:00

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