feat: interactive site redesign with dot matrix effects and component polish
This commit is contained in:
+11
-12
@@ -18,6 +18,7 @@ import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail'
|
||||
import { SettingsPage } from '@/components/settings/SettingsPage'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { ToastProvider, useToast } from '@/components/ui/toast'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Server, AlertTriangle } from 'lucide-react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
@@ -236,15 +237,13 @@ function AppContent() {
|
||||
|
||||
if (!activeConnectionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
|
||||
<div className="flex h-full items-center justify-center p-6 dot-grid">
|
||||
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-border bg-card p-8 shadow-card">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
No connection selected
|
||||
</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
<DotMatrixText text="NO CONNECTION" size="sm" className="text-foreground" />
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
Connect to a Proxmox server to get started
|
||||
</p>
|
||||
</div>
|
||||
@@ -375,11 +374,11 @@ function AppContent() {
|
||||
}, [activeConnectionId, activeServerType, view.type])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<div className="flex h-screen flex-col bg-background dot-grid">
|
||||
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
|
||||
<div className="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
|
||||
<div className="flex items-center gap-2 border-b border-dotted border-warning/30 bg-warning/10 px-4 py-1.5 dot-grid">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<p className="text-xs font-medium text-warning">Operating on a fallback node</p>
|
||||
<DotMatrixText text="FALLBACK NODE" size="xs" className="text-warning" />
|
||||
<p className="truncate font-mono text-[11px] tabular-nums text-warning/80">
|
||||
{activeConnection.currentEndpointUrl}
|
||||
</p>
|
||||
@@ -391,9 +390,9 @@ function AppContent() {
|
||||
activeView={view.type}
|
||||
onNavigate={(v) => handleNavigate(v as View)}
|
||||
/>
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<main className="flex min-h-0 flex-1 flex-col bg-background/60">
|
||||
{activeConnectionId && (
|
||||
<div className="shrink-0 border-b px-3 py-1.5">
|
||||
<div className="shrink-0 border-b border-dotted border-border px-3 py-1.5 dot-grid">
|
||||
<TaskStatusBar
|
||||
connectionId={activeConnectionId}
|
||||
onClick={() => handleNavigate({ type: 'tasks' })}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Activity, Clock, Loader2 } from 'lucide-react'
|
||||
@@ -16,20 +17,20 @@ function isRunningTask(status?: string): boolean {
|
||||
function getStatusBadgeClass(status?: string): string {
|
||||
switch (status) {
|
||||
case 'OK':
|
||||
return 'border-success/25 bg-success/10 text-success'
|
||||
return 'border-dotted border-success/30 bg-success/10 text-success'
|
||||
case 'unknown':
|
||||
return 'border-warning/25 bg-warning/10 text-warning'
|
||||
return 'border-dotted border-warning/30 bg-warning/10 text-warning'
|
||||
default:
|
||||
if (!status || status === 'Running') {
|
||||
return 'border-info/25 bg-info/10 text-info'
|
||||
return 'border-dotted border-info/30 bg-info/10 text-info'
|
||||
}
|
||||
return 'border-destructive/25 bg-destructive/10 text-destructive'
|
||||
return 'border-dotted border-destructive/30 bg-destructive/10 text-destructive'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status?: string): string {
|
||||
if (!status || status === 'Running') return 'Running'
|
||||
return status
|
||||
if (!status || status === 'Running') return 'RUNNING'
|
||||
return status.toUpperCase()
|
||||
}
|
||||
|
||||
function formatDuration(starttime: number, endtime?: number): string {
|
||||
@@ -52,7 +53,6 @@ function formatTime(timestamp: number): string {
|
||||
}
|
||||
|
||||
function getTaskTypeLabel(type: string): string {
|
||||
// Capitalize and format Proxmox task types
|
||||
return type
|
||||
.replace(/qm/gi, 'VM')
|
||||
.replace(/vz/gi, 'CT')
|
||||
@@ -68,16 +68,16 @@ function getTaskTypeLabel(type: string): string {
|
||||
export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
|
||||
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
@@ -91,30 +91,30 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
|
||||
const recentTasks = tasks?.slice(0, 15) ?? []
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
|
||||
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-4">
|
||||
{recentTasks.length > 0 ? (
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-0">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.upid}
|
||||
className="flex items-center justify-between rounded-md border border-border/70 bg-background/30 px-3 py-2.5 transition-colors duration-150 hover:border-border hover:bg-accent/40"
|
||||
className="flex items-center justify-between px-3 py-2.5 transition-colors duration-150 hover:bg-accent/30 feed-rule"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-[13px] font-medium">{getTaskTypeLabel(task.type)}</p>
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[11px] font-medium ${getStatusBadgeClass(task.status)}`}
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border border-dotted px-1.5 py-0.5 text-[10px] font-medium tracking-widest ${getStatusBadgeClass(task.status)}`}
|
||||
>
|
||||
{isRunningTask(task.status) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
@@ -20,30 +21,23 @@ function getStatusTextColor(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'text-success' : 'text-destructive'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'Online' : 'Offline'
|
||||
}
|
||||
|
||||
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
|
||||
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
|
||||
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3 border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={cn('h-2 w-2 rounded-full', getStatusColor(node.status))} />
|
||||
<CardTitle className="font-mono text-sm font-semibold tracking-tight">
|
||||
<span className="font-mono text-sm font-semibold tracking-tight text-foreground">
|
||||
{node.node}
|
||||
</CardTitle>
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium', getStatusTextColor(node.status))}>
|
||||
{getStatusLabel(node.status)}
|
||||
</span>
|
||||
<DotMatrixText text={node.status === 'online' ? 'ONLINE' : 'OFFLINE'} size="xs" className={getStatusTextColor(node.status)} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Stats row */}
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu className="h-3 w-3 shrink-0" />
|
||||
@@ -58,9 +52,7 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
<span>{(diskPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom info */}
|
||||
<div className="flex items-center justify-between border-t pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<div className="flex items-center justify-between border-t border-dotted border-border/60 pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>{formatUptime(node.uptime)}</span>
|
||||
@@ -80,7 +72,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
}
|
||||
|
||||
export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
// Count VMs per node
|
||||
const vmCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
vms?.forEach((vm) => {
|
||||
@@ -91,13 +82,13 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
|
||||
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -108,16 +99,16 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
|
||||
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.node} node={node} vmCount={vmCounts[node.node] ?? 0} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield, Zap } from 'lucide-react'
|
||||
|
||||
@@ -10,65 +11,65 @@ interface QuickActionsProps {
|
||||
|
||||
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b border-dotted border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Quick Actions</CardTitle>
|
||||
<DotMatrixText text="QUICK ACTIONS" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span className="text-xs font-medium">Refresh</span>
|
||||
<DotMatrixText text="REFRESH" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={() => onNavigate?.('nodes')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Nodes</span>
|
||||
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={() => onNavigate?.('vms')}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">VMs</span>
|
||||
<DotMatrixText text="VMS" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={() => onNavigate?.('storage')}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Storage</span>
|
||||
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={() => onNavigate?.('tasks')}
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Tasks</span>
|
||||
<DotMatrixText text="TASKS" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
|
||||
onClick={() => onNavigate?.('backups')}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Backups</span>
|
||||
<DotMatrixText text="BACKUPS" size="xs" className="text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
@@ -46,45 +47,40 @@ function getTrackColor(): string {
|
||||
export function ResourceGauge({ label, used, total, icon, formatValue }: ResourceGaugeProps) {
|
||||
const percent = total > 0 ? used / total : 0
|
||||
const clampedPercent = Math.min(Math.max(percent, 0), 1)
|
||||
const displayPercent = (clampedPercent * 100).toFixed(1)
|
||||
const displayPercent = `${(clampedPercent * 100).toFixed(1)}%`
|
||||
|
||||
const Icon = iconMap[icon]
|
||||
|
||||
// SVG circle parameters
|
||||
const radius = 40
|
||||
const strokeWidth = 8
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference * (1 - clampedPercent)
|
||||
|
||||
const defaultFormat =
|
||||
icon === 'cpu'
|
||||
? formatCores
|
||||
: formatBytes
|
||||
|
||||
const defaultFormat = icon === 'cpu' ? formatCores : formatBytes
|
||||
const formatter = formatValue ?? defaultFormat
|
||||
const gaugeStyle = getGaugeStyle(clampedPercent)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<DotMatrixText text={label} size="xs" className="text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<svg width="108" height="108" viewBox="0 0 100 100">
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeWidth={strokeWidth - 1}
|
||||
strokeDasharray="1 3"
|
||||
strokeLinecap="round"
|
||||
className={getTrackColor()}
|
||||
opacity="0.35"
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
@@ -92,7 +88,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDasharray={`${circumference * 0.018} ${circumference * 0.012}`}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={gaugeStyle.className}
|
||||
style={{
|
||||
@@ -104,9 +100,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-mono text-xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{displayPercent}%
|
||||
</span>
|
||||
<DotMatrixText text={displayPercent} size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center font-mono text-xs tabular-nums text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Server, Cpu, HardDrive, MemoryStick, AlertCircle } from 'lucide-react'
|
||||
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
|
||||
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
|
||||
@@ -31,9 +32,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
|
||||
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
|
||||
|
||||
// Track actual refresh activity rather than piggybacking on tasksLoading,
|
||||
// which is true whenever the tasks query is fetching (e.g. background
|
||||
// polling) and would keep the Refresh button spinning continuously.
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
@@ -60,19 +58,19 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
const hasError = nodesError || vmsError || tasksError
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
|
||||
<div className="flex h-full items-center justify-center p-6 dot-grid">
|
||||
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-destructive/30 bg-card p-8 shadow-card">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-destructive/30 bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Unable to load dashboard</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
<DotMatrixText text="LOAD FAILED" size="sm" className="text-destructive" />
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Check the connection to your Proxmox server and try again.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={handleRetry}>
|
||||
<Button variant="outline" className="mt-4 border-dotted" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -81,80 +79,69 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="h-full overflow-auto p-6 dot-grid">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<DotMatrixText text="DASHBOARD" size="md" className="text-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Cluster overview and resource usage
|
||||
</p>
|
||||
<hr className="dot-rule mt-3" />
|
||||
</div>
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{nodes?.filter((n) => n.status === 'online').length ?? 0}
|
||||
<span className="text-muted-foreground"> / {nodes?.length ?? 0}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">Online</p>
|
||||
<DotMatrixText text={`${nodes?.filter((n) => n.status === 'online').length ?? 0} / ${nodes?.length ?? 0}`} size="sm" className="text-foreground" />
|
||||
<p className="mt-1.5 text-xs tracking-widest text-muted-foreground">ONLINE</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<DotMatrixText text="CPU USAGE" size="xs" className="text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
|
||||
</div>
|
||||
<DotMatrixText text={totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'} size="sm" className="text-foreground" />
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{usedCPU.toFixed(1)} / {totalCPU} cores
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<DotMatrixText text="MEMORY" size="xs" className="text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'}
|
||||
</div>
|
||||
<DotMatrixText text={totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'} size="sm" className="text-foreground" />
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(usedMem)} / {formatBytes(totalMem)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="dot-grid">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'}
|
||||
</div>
|
||||
<DotMatrixText text={totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'} size="sm" className="text-foreground" />
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(usedDisk)} / {formatBytes(totalDisk)}
|
||||
</p>
|
||||
@@ -162,7 +149,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resource Gauges */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ResourceGauge
|
||||
label="CPU"
|
||||
@@ -172,14 +158,14 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
formatValue={(v) => `${v.toFixed(1)} cores`}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Memory"
|
||||
label="MEMORY"
|
||||
used={usedMem}
|
||||
total={totalMem}
|
||||
icon="memory"
|
||||
formatValue={formatBytes}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Storage"
|
||||
label="STORAGE"
|
||||
used={usedDisk}
|
||||
total={totalDisk}
|
||||
icon="disk"
|
||||
@@ -187,10 +173,8 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Node Health Grid */}
|
||||
<NodeHealthGrid nodes={nodes} vms={vms} />
|
||||
|
||||
{/* Activity Feed + Quick Actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -36,16 +37,13 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-64 flex-col border-r border-border bg-card">
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
|
||||
<img src="/icon.png" alt="Clustri" className="h-7 w-7 rounded-md" />
|
||||
<div className="flex w-64 flex-col border-r border-dotted border-border bg-card dot-grid">
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-dotted border-border px-4 bg-card/80">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm border border-dotted border-border bg-primary/10 text-primary">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">
|
||||
Clustri
|
||||
</h1>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Ops Console
|
||||
</p>
|
||||
<DotMatrixText text="CLUSTRI" size="xs" className="text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
@@ -55,10 +53,10 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
<button
|
||||
onClick={() => setActiveConnection(connection.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150',
|
||||
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150 border border-transparent',
|
||||
activeConnectionId === connection.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
? 'bg-accent text-accent-foreground border-dotted border-border'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusColor(connection.status))} />
|
||||
@@ -79,59 +77,60 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Overview"
|
||||
label="OVERVIEW"
|
||||
active={activeView === 'pbs-overview'}
|
||||
onClick={() => onNavigate?.({ type: 'pbs-overview' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={HardDrive}
|
||||
label="Datastores"
|
||||
label="DATASTORES"
|
||||
active={activeView === 'pbs-datastores' || activeView === 'pbs-datastore-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'pbs-datastores' })}
|
||||
/>
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
label="DASHBOARD"
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Server}
|
||||
label="Nodes"
|
||||
label="NODES"
|
||||
active={activeView === 'nodes'}
|
||||
onClick={() => onNavigate?.({ type: 'nodes' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
label="VMS"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
label="CONTAINERS"
|
||||
active={activeView === 'containers'}
|
||||
onClick={() => onNavigate?.({ type: 'containers' })}
|
||||
/>
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
<SidebarItem icon={HardDrive} label="STORAGE" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="BACKUPS" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
</>
|
||||
)}
|
||||
{connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Cluster nodes
|
||||
</p>
|
||||
<div className="px-3 pb-1 pt-3">
|
||||
<DotMatrixText text="CLUSTER NODES" size="xs" className="text-muted-foreground opacity-60" />
|
||||
</div>
|
||||
<hr className="dot-rule mx-2" />
|
||||
{connection.nodes.map((node) => (
|
||||
<button
|
||||
key={node.url}
|
||||
onClick={() => onNavigate?.({ type: 'node-detail', nodeName: node.name })}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground"
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground border border-transparent hover:border-dotted hover:border-border/50"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -141,7 +140,7 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{node.name}</span>
|
||||
{node.isPrimary && (
|
||||
<span className="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px text-[10px] uppercase text-primary">
|
||||
<span className="shrink-0 rounded-sm border border-dotted border-primary/40 bg-primary/10 px-1 py-px text-[10px] uppercase tracking-widest text-primary">
|
||||
primary
|
||||
</span>
|
||||
)}
|
||||
@@ -161,16 +160,16 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="space-y-1 border-t border-border p-2">
|
||||
<div className="space-y-1 border-t border-dotted border-border p-2 bg-card/60">
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
label="SETTINGS"
|
||||
active={activeView === 'settings'}
|
||||
onClick={() => onNavigate?.({ type: 'settings' })}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2"
|
||||
className="w-full justify-start gap-2 border-dotted"
|
||||
onClick={onAddConnection}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -199,19 +198,19 @@ function SidebarItem({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150',
|
||||
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150 border border-transparent',
|
||||
disabled
|
||||
? 'pointer-events-none cursor-not-allowed opacity-50'
|
||||
: active
|
||||
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
? 'bg-primary/10 text-primary border-dotted border-primary/20'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/50',
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span>{label}</span>
|
||||
<DotMatrixText text={label} size="xs" className={active ? 'text-primary' : 'text-muted-foreground'} dotClassName={active ? 'text-primary' : ''} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ListTodo, Loader2 } from 'lucide-react'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
import { useTasks } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxTask } from '@/types/proxmox'
|
||||
|
||||
@@ -27,21 +28,19 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors duration-150 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
|
||||
className="group flex w-full items-center gap-2 rounded-md border border-transparent px-3 py-2 text-sm transition-colors duration-150 hover:border-dotted hover:border-border/60 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<ListTodo className="h-4 w-4 transition-colors group-hover:text-foreground" />
|
||||
<span className="flex-1 text-left">
|
||||
{runningCount > 0 ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-info">{runningCount} task{runningCount !== 1 ? 's' : ''} running</span>
|
||||
<DotMatrixText text={`${runningCount} TASK${runningCount !== 1 ? 'S' : ''} RUNNING`} size="xs" className="text-info group-hover:text-foreground transition-colors" />
|
||||
</span>
|
||||
) : (
|
||||
<span>No tasks running</span>
|
||||
<DotMatrixText text="NO TASKS RUNNING" size="xs" className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<Loader2 className="h-3 w-3 text-info animate-spin" />
|
||||
)}
|
||||
{runningCount > 0 && <Loader2 className="h-3 w-3 text-info group-hover:text-foreground transition-colors animate-spin" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-dotted border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost:
|
||||
@@ -31,7 +31,7 @@ const buttonVariants = cva(
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -50,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-card',
|
||||
className
|
||||
'rounded-lg border border-dotted bg-card text-card-foreground shadow-card',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'font-semibold leading-none tracking-tight text-foreground',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -32,8 +32,8 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-dotted bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dot-grid',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -54,7 +54,7 @@ const DialogHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -68,7 +68,7 @@ const DialogFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -83,7 +83,7 @@ const DialogTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-base font-semibold leading-none tracking-tight',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { GLYPH_COLS, GLYPH_ROWS, getGlyph } from '@/lib/dotMatrixGlyphs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DotMatrixSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
const sizeMap: Record<DotMatrixSize, { dot: number; gap: number; charGap: number }> = {
|
||||
xs: { dot: 1.8, gap: 1.3, charGap: 5 },
|
||||
sm: { dot: 2.2, gap: 1.5, charGap: 6 },
|
||||
md: { dot: 2.6, gap: 1.8, charGap: 7 },
|
||||
lg: { dot: 3.0, gap: 2.0, charGap: 9 },
|
||||
xl: { dot: 3.8, gap: 2.4, charGap: 12 },
|
||||
}
|
||||
|
||||
interface DotMatrixTextProps {
|
||||
text: string
|
||||
size?: DotMatrixSize
|
||||
className?: string
|
||||
dotClassName?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function CharSvg({
|
||||
glyph,
|
||||
dot,
|
||||
gap,
|
||||
}: {
|
||||
glyph: readonly string[]
|
||||
dot: number
|
||||
gap: number
|
||||
}) {
|
||||
const w = GLYPH_COLS * dot + (GLYPH_COLS - 1) * gap
|
||||
const h = GLYPH_ROWS * dot + (GLYPH_ROWS - 1) * gap
|
||||
const r = dot / 2
|
||||
return (
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
{glyph.map((row, y) =>
|
||||
Array.from(row).map((cell, x) => {
|
||||
if (cell === ' ') return null
|
||||
const cx = x * (dot + gap) + r
|
||||
const cy = y * (dot + gap) + r
|
||||
return <circle key={`${x}-${y}`} cx={cx} cy={cy} r={r} fill="currentColor" />
|
||||
}),
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DotMatrixText({
|
||||
text,
|
||||
size = 'sm',
|
||||
className,
|
||||
dotClassName,
|
||||
'aria-label': ariaLabel,
|
||||
}: DotMatrixTextProps) {
|
||||
const { dot, gap, charGap } = sizeMap[size]
|
||||
const chars = Array.from(text.toUpperCase())
|
||||
const label = ariaLabel ?? text
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
role="text"
|
||||
className={cn('inline-flex items-center leading-none', dotClassName, className)}
|
||||
style={{ gap: charGap }}
|
||||
>
|
||||
{chars.map((ch, i) => {
|
||||
if (ch === ' ') {
|
||||
return <span key={i} style={{ width: charGap * 1.8 }} aria-hidden="true" />
|
||||
}
|
||||
const glyph = getGlyph(ch)
|
||||
if (!glyph) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="font-mono text-[0.7em] leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <CharSvg key={i} glyph={glyph} dot={dot} gap={gap} />
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DotMatrixHeading({
|
||||
text,
|
||||
size = 'md',
|
||||
className,
|
||||
dotClassName,
|
||||
as: Tag = 'span',
|
||||
...rest
|
||||
}: DotMatrixTextProps & { as?: 'h1' | 'h2' | 'h3' | 'span' | 'p' | 'div' }) {
|
||||
return (
|
||||
<Tag className={cn('block', className)} {...rest}>
|
||||
<DotMatrixText text={text} size={size} dotClassName={dotClassName} />
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { DotMatrixText } from '@/components/ui/dot-matrix'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
@@ -10,13 +11,13 @@ interface EmptyStateProps {
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
||||
<div className="flex flex-col items-center justify-center px-6 py-12 text-center dot-grid">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/50">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
<DotMatrixText text={title.toUpperCase()} size="xs" className="text-foreground" />
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -7,14 +7,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
'flex h-9 w-full rounded-md border border-dotted border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
|
||||
@@ -10,33 +10,33 @@ interface StatusBadgeProps {
|
||||
|
||||
const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
running: {
|
||||
label: 'Running',
|
||||
color: 'border-success/25 bg-success/10 text-success',
|
||||
label: 'RUNNING',
|
||||
color: 'border-dotted border-success/30 bg-success/10 text-success',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
stopped: {
|
||||
label: 'Stopped',
|
||||
color: 'border-border bg-muted/60 text-muted-foreground',
|
||||
label: 'STOPPED',
|
||||
color: 'border-dotted border-border bg-muted/60 text-muted-foreground',
|
||||
icon: Square,
|
||||
},
|
||||
paused: {
|
||||
label: 'Paused',
|
||||
color: 'border-warning/25 bg-warning/10 text-warning',
|
||||
label: 'PAUSED',
|
||||
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
|
||||
icon: Pause,
|
||||
},
|
||||
suspended: {
|
||||
label: 'Suspended',
|
||||
color: 'border-warning/25 bg-warning/10 text-warning',
|
||||
label: 'SUSPENDED',
|
||||
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
|
||||
icon: Clock,
|
||||
},
|
||||
online: {
|
||||
label: 'Online',
|
||||
color: 'border-success/25 bg-success/10 text-success',
|
||||
label: 'ONLINE',
|
||||
color: 'border-dotted border-success/30 bg-success/10 text-success',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
offline: {
|
||||
label: 'Offline',
|
||||
color: 'border-destructive/25 bg-destructive/10 text-destructive',
|
||||
label: 'OFFLINE',
|
||||
color: 'border-dotted border-destructive/30 bg-destructive/10 text-destructive',
|
||||
icon: XCircle,
|
||||
},
|
||||
}
|
||||
@@ -48,9 +48,9 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
|
||||
'inline-flex items-center gap-1.5 rounded-sm border border-dotted px-2 py-0.5 text-[11px] font-medium tracking-widest',
|
||||
config.color,
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
|
||||
@@ -11,8 +11,8 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground',
|
||||
className
|
||||
'inline-flex h-9 items-center justify-center rounded-md border border-dotted border-border bg-secondary p-1 text-muted-foreground dot-grid',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -26,8 +26,8 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm border border-transparent px-3 py-1 text-sm font-medium tracking-wide ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:border-dotted data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
setToasts((prev) => [...prev, { id, message, variant }])
|
||||
setTimeout(() => removeToast(id), 5000)
|
||||
},
|
||||
[removeToast]
|
||||
[removeToast],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -104,8 +104,8 @@ function ToastItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md border border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40',
|
||||
config.bar
|
||||
'flex items-start gap-3 rounded-md border border-dotted border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40 dot-grid',
|
||||
config.bar,
|
||||
)}
|
||||
style={{
|
||||
animation: 'toast-slide-in 0.3s ease-out',
|
||||
|
||||
+157
-47
@@ -1,9 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
/* ------------------------------------------------------------------
|
||||
* Typography — offline-safe variable fonts (bundled via fontsource)
|
||||
* ------------------------------------------------------------------ */
|
||||
--font-sans:
|
||||
'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
|
||||
'Segoe UI', sans-serif;
|
||||
@@ -11,9 +8,6 @@
|
||||
'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
|
||||
Menlo, monospace;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Radius scale — tighter on inner elements, softer on containers
|
||||
* ------------------------------------------------------------------ */
|
||||
--radius: 0.625rem;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
@@ -21,60 +15,59 @@
|
||||
--radius-xl: 0.75rem;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Light theme (default) — warm paper neutrals, cool ink, Proxmox accent
|
||||
* Light — cream paper, ink black, Proxmox orange ribbon
|
||||
* ------------------------------------------------------------------ */
|
||||
--color-background: oklch(0.972 0.004 85);
|
||||
--color-foreground: oklch(0.24 0.012 250);
|
||||
--color-card: oklch(0.998 0.002 85);
|
||||
--color-card-foreground: oklch(0.24 0.012 250);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.24 0.012 250);
|
||||
--color-background: oklch(0.985 0.006 85);
|
||||
--color-foreground: oklch(0.18 0.012 250);
|
||||
--color-card: oklch(0.994 0.003 85);
|
||||
--color-card-foreground: oklch(0.18 0.012 250);
|
||||
--color-popover: oklch(0.998 0.002 85);
|
||||
--color-popover-foreground: oklch(0.18 0.012 250);
|
||||
--color-primary: oklch(0.62 0.15 55);
|
||||
--color-primary-foreground: oklch(0.985 0.008 60);
|
||||
--color-secondary: oklch(0.95 0.005 85);
|
||||
--color-secondary-foreground: oklch(0.3 0.012 250);
|
||||
--color-muted: oklch(0.95 0.005 85);
|
||||
--color-muted-foreground: oklch(0.51 0.014 250);
|
||||
--color-accent: oklch(0.935 0.008 85);
|
||||
--color-accent-foreground: oklch(0.28 0.012 250);
|
||||
--color-secondary: oklch(0.94 0.01 85);
|
||||
--color-secondary-foreground: oklch(0.22 0.012 250);
|
||||
--color-muted: oklch(0.94 0.01 85);
|
||||
--color-muted-foreground: oklch(0.56 0.012 250);
|
||||
--color-accent: oklch(0.93 0.012 85);
|
||||
--color-accent-foreground: oklch(0.22 0.012 250);
|
||||
--color-destructive: oklch(0.56 0.19 25);
|
||||
--color-destructive-foreground: oklch(0.98 0.008 25);
|
||||
--color-border: oklch(0.905 0.006 85);
|
||||
--color-input: oklch(0.885 0.007 85);
|
||||
--color-border: oklch(0.88 0.012 85);
|
||||
--color-input: oklch(0.86 0.014 85);
|
||||
--color-ring: oklch(0.62 0.15 55);
|
||||
|
||||
/* Semantic status colors — data semantics, not UI accents */
|
||||
--color-success: oklch(0.55 0.13 155);
|
||||
--color-warning: oklch(0.62 0.13 80);
|
||||
--color-info: oklch(0.55 0.11 245);
|
||||
|
||||
/* Tinted card shadow — carries the warm hue of the surface */
|
||||
--shadow-card:
|
||||
0 1px 2px 0 oklch(0.3 0.02 60 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 60 / 0.05);
|
||||
0 1px 0 0 oklch(0.18 0.012 250 / 0.06) inset,
|
||||
0 1px 3px 0 oklch(0.18 0.012 250 / 0.07), 0 4px 12px -4px oklch(0.18 0.012 250 / 0.06);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* ------------------------------------------------------------------
|
||||
* Dark theme — blue-black ink surfaces, cool neutrals, muted orange
|
||||
* Dark — dark ink-paper, light ink, same orange ribbon
|
||||
* ------------------------------------------------------------------ */
|
||||
--color-background: oklch(0.16 0.011 250);
|
||||
--color-foreground: oklch(0.965 0.004 250);
|
||||
--color-card: oklch(0.19 0.012 250);
|
||||
--color-card-foreground: oklch(0.965 0.004 250);
|
||||
--color-popover: oklch(0.215 0.013 250);
|
||||
--color-popover-foreground: oklch(0.965 0.004 250);
|
||||
--color-background: oklch(0.145 0.012 250);
|
||||
--color-foreground: oklch(0.96 0.005 85);
|
||||
--color-card: oklch(0.182 0.014 250);
|
||||
--color-card-foreground: oklch(0.96 0.005 85);
|
||||
--color-popover: oklch(0.2 0.015 250);
|
||||
--color-popover-foreground: oklch(0.96 0.005 85);
|
||||
--color-primary: oklch(0.72 0.15 60);
|
||||
--color-primary-foreground: oklch(0.18 0.02 50);
|
||||
--color-secondary: oklch(0.235 0.011 250);
|
||||
--color-secondary-foreground: oklch(0.94 0.004 250);
|
||||
--color-muted: oklch(0.235 0.011 250);
|
||||
--color-muted-foreground: oklch(0.655 0.012 250);
|
||||
--color-accent: oklch(0.275 0.012 250);
|
||||
--color-accent-foreground: oklch(0.965 0.004 250);
|
||||
--color-secondary: oklch(0.22 0.014 250);
|
||||
--color-secondary-foreground: oklch(0.92 0.004 85);
|
||||
--color-muted: oklch(0.22 0.014 250);
|
||||
--color-muted-foreground: oklch(0.68 0.012 250);
|
||||
--color-accent: oklch(0.24 0.014 250);
|
||||
--color-accent-foreground: oklch(0.96 0.005 85);
|
||||
--color-destructive: oklch(0.645 0.18 25);
|
||||
--color-destructive-foreground: oklch(0.97 0.015 25);
|
||||
--color-border: oklch(0.255 0.012 250);
|
||||
--color-input: oklch(0.3 0.013 250);
|
||||
--color-border: oklch(0.26 0.014 250);
|
||||
--color-input: oklch(0.28 0.015 250);
|
||||
--color-ring: oklch(0.72 0.15 60);
|
||||
|
||||
--color-success: oklch(0.7 0.14 155);
|
||||
@@ -82,7 +75,118 @@
|
||||
--color-info: oklch(0.68 0.11 245);
|
||||
|
||||
--shadow-card:
|
||||
0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
|
||||
0 1px 0 0 oklch(1 0 0 / 0.05) inset, 0 1px 2px 0 oklch(0 0 0 / 0.45),
|
||||
0 8px 24px -8px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* ── Dot-matrix texture utilities ───────────────────────────────── */
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(0.18 0.012 250 / 0.09) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
.dark .dot-grid {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(1 0 0 / 0.07) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
}
|
||||
.dot-grid-strong {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(0.18 0.012 250 / 0.13) 1.1px,
|
||||
transparent 1.1px
|
||||
);
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
.dark .dot-grid-strong {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(1 0 0 / 0.1) 1.1px,
|
||||
transparent 1.1px
|
||||
);
|
||||
}
|
||||
|
||||
.dot-rule {
|
||||
border: none;
|
||||
border-top: 1.5px dotted oklch(0.18 0.012 250 / 0.18);
|
||||
height: 0;
|
||||
}
|
||||
.dark .dot-rule {
|
||||
border-top-color: oklch(1 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.perf-edge {
|
||||
position: relative;
|
||||
}
|
||||
.perf-edge::before,
|
||||
.perf-edge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 6px;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(0.18 0.012 250 / 0.14) 2px,
|
||||
transparent 2px
|
||||
);
|
||||
background-size: 6px 12px;
|
||||
background-repeat: repeat-y;
|
||||
pointer-events: none;
|
||||
}
|
||||
.perf-edge::before {
|
||||
left: 4px;
|
||||
}
|
||||
.perf-edge::after {
|
||||
right: 4px;
|
||||
}
|
||||
.dark .perf-edge::before,
|
||||
.dark .perf-edge::after {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(1 0 0 / 0.1) 2px,
|
||||
transparent 2px
|
||||
);
|
||||
}
|
||||
|
||||
.halftone-fill {
|
||||
position: relative;
|
||||
}
|
||||
.halftone-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(1 0 0 / 0.55) 0.9px,
|
||||
transparent 0.9px
|
||||
);
|
||||
background-size: 5px 5px;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.dark .halftone-fill::after {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
oklch(1 0 0 / 0.35) 0.9px,
|
||||
transparent 0.9px
|
||||
);
|
||||
}
|
||||
|
||||
.feed-rule {
|
||||
border-bottom: 1px dotted oklch(0.18 0.012 250 / 0.12);
|
||||
}
|
||||
.dark .feed-rule {
|
||||
border-bottom-color: oklch(1 0 0 / 0.08);
|
||||
}
|
||||
.feed-rule:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -102,12 +206,10 @@
|
||||
@apply bg-background font-sans text-foreground antialiased;
|
||||
}
|
||||
|
||||
/* Keep native form controls and webview widgets on theme */
|
||||
::selection {
|
||||
@apply bg-primary/20 text-foreground;
|
||||
}
|
||||
|
||||
/* Monospace and code contexts always use tabular numerals */
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
@@ -115,12 +217,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Consistent focus ring everywhere, including custom elements */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Native scrollbars tuned to the surface language */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
@@ -147,20 +247,19 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Very subtle fixed grain overlay to break digital flatness */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.02;
|
||||
opacity: 0.018;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
html.dark body::after {
|
||||
opacity: 0.04;
|
||||
opacity: 0.03;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,3 +273,14 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(1px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type Glyph = readonly string[]
|
||||
|
||||
export const GLYPH_COLS = 5
|
||||
export const GLYPH_ROWS = 7
|
||||
|
||||
export const GLYPHS: Record<string, Glyph> = {
|
||||
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
||||
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
|
||||
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
|
||||
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
|
||||
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
|
||||
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
|
||||
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
|
||||
G: [' ### ', '# #', '# ', '# ## ', '# #', '# #', ' ### '],
|
||||
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
|
||||
I: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', '#####'],
|
||||
J: ['#####', ' # ', ' # ', ' # ', ' # ', '# # ', ' ## '],
|
||||
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
|
||||
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
|
||||
M: ['# #', '## ##', '# # #', '# # #', '# #', '# #', '# #'],
|
||||
N: ['# #', '## #', '# # #', '# # #', '# ##', '# #', '# #'],
|
||||
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
|
||||
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
|
||||
Q: [' ### ', '# #', '# #', '# #', '# # #', '# # ', ' ## #'],
|
||||
R: ['#### ', '# #', '# #', '#### ', '# # ', '# # ', '# #'],
|
||||
S: [' ### ', '# #', '# ', ' ### ', ' #', '# #', ' ### '],
|
||||
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
|
||||
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
|
||||
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
|
||||
W: ['# #', '# #', '# #', '# # #', '# # #', '## ##', '# #'],
|
||||
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
|
||||
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
|
||||
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
|
||||
'0': [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
|
||||
'1': [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
|
||||
'2': [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
|
||||
'3': ['#### ', ' #', ' #', ' ### ', ' #', ' #', '#### '],
|
||||
'4': [' # ', ' ## ', ' # # ', '# # ', '#####', ' # ', ' # '],
|
||||
'5': ['#####', '# ', '# ', '#### ', ' #', '# #', ' ### '],
|
||||
'6': [' ### ', '# #', '# ', '#### ', '# #', '# #', ' ### '],
|
||||
'7': ['#####', ' #', ' # ', ' # ', ' # ', ' # ', ' # '],
|
||||
'8': [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
|
||||
'9': [' ### ', '# #', '# #', ' ####', ' #', '# #', ' ### '],
|
||||
'-': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
|
||||
'.': [' ', ' ', ' ', ' ', ' ', ' ## ', ' ## '],
|
||||
':': [' ', ' ## ', ' ## ', ' ', ' ## ', ' ## ', ' '],
|
||||
'/': [' #', ' #', ' # ', ' # ', ' # ', '# ', '# '],
|
||||
'%': ['## #', '## #', ' # ', ' # ', ' # ', '# ##', '# ##'],
|
||||
'(': [' ## ', ' # ', '# ', '# ', '# ', ' # ', ' ## '],
|
||||
')': [' ## ', ' # ', ' #', ' #', ' #', ' # ', ' ## '],
|
||||
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
|
||||
_: [' ', ' ', ' ', ' ', ' ', ' ', '#####'],
|
||||
'@': [' ### ', '# #', '# ## ', '# ## ', '# ## ', '# ', ' ### '],
|
||||
'#': [' # # ', ' # # ', '#####', ' # # ', '#####', ' # # ', ' # # '],
|
||||
'!': [' # ', ' # ', ' # ', ' # ', ' # ', ' ', ' # '],
|
||||
'?': [' ### ', '# #', ' #', ' ## ', ' # ', ' ', ' # '],
|
||||
'*': [' ', '# #', ' # # ', '#####', ' # # ', '# #', ' '],
|
||||
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
|
||||
',': [' ', ' ', ' ', ' ', ' ## ', ' ## ', ' # '],
|
||||
"'": [' # ', ' # ', ' # ', ' ', ' ', ' ', ' '],
|
||||
'"': [' # # ', ' # # ', ' ', ' ', ' ', ' ', ' '],
|
||||
'[': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
|
||||
']': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
|
||||
'<': [' # ', ' # ', ' # ', '# ', ' # ', ' # ', ' # '],
|
||||
'>': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
|
||||
'|': [' # ', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
|
||||
'&': [' ## ', '# # ', '# # ', ' ## ', '# # ', '# # ', ' ## #'],
|
||||
$: [' ### ', '# # ', '# ', ' ### ', ' #', ' # #', ' ### '],
|
||||
}
|
||||
|
||||
export function getGlyph(char: string): Glyph | null {
|
||||
const upper = char.toUpperCase()
|
||||
return GLYPHS[upper] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user