ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HardDrive, Menu, LogOut } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [zfsAvailable, setZfsAvailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkZfsAvailability = async () => {
|
||||
const status = await api.getSystemStatus()
|
||||
setZfsAvailable(status.zfs_available)
|
||||
}
|
||||
checkZfsAvailability()
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.logout()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const isActive = (path: string) => pathname === path
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
|
||||
<HardDrive className="w-6 h-6" />
|
||||
<span>ZMB Webui</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{zfsAvailable && (
|
||||
<>
|
||||
<Link
|
||||
href="/snapshots"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/snapshots")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Snapshots
|
||||
</Link>
|
||||
<Link
|
||||
href="/datasets"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/datasets")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Datasets
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/navigator"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/navigator")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Navigator
|
||||
</Link>
|
||||
<Link
|
||||
href="/shares"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/shares")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Shares
|
||||
</Link>
|
||||
<Link
|
||||
href="/file-sharing"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/file-sharing")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
File Sharing
|
||||
</Link>
|
||||
<Link
|
||||
href="/identities"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/identities")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Identities
|
||||
</Link>
|
||||
<Link
|
||||
href="/logs"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/logs")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
<Link
|
||||
href="/services"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive("/services")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Services
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</Button>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMenuOpen && (
|
||||
<nav className="md:hidden pb-4 space-y-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{zfsAvailable && (
|
||||
<>
|
||||
<Link
|
||||
href="/snapshots"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/snapshots")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Snapshots
|
||||
</Link>
|
||||
<Link
|
||||
href="/datasets"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/datasets")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Datasets
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/files"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/files")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Files
|
||||
</Link>
|
||||
<Link
|
||||
href="/shares"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/shares")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Shares
|
||||
</Link>
|
||||
<Link
|
||||
href="/file-sharing"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/file-sharing")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
File Sharing
|
||||
</Link>
|
||||
<Link
|
||||
href="/identities"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/identities")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Identities
|
||||
</Link>
|
||||
<Link
|
||||
href="/logs"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/logs")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
<Link
|
||||
href="/services"
|
||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
||||
isActive("/services")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Services
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { Pool } from "@/lib/api"
|
||||
import { formatBytes } from "@/lib/utils"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
interface PoolCardProps {
|
||||
pool: Pool
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function PoolCard({ pool, onClick }: PoolCardProps) {
|
||||
const usedBytes = pool.alloc
|
||||
const freeBytes = pool.free
|
||||
const totalBytes = pool.size
|
||||
const capacityPercent = parseInt(pool.capacity)
|
||||
|
||||
let badgeVariant: "success" | "warning" | "destructive" = "success"
|
||||
if (pool.health === "DEGRADED") badgeVariant = "warning"
|
||||
else if (pool.health !== "ONLINE") badgeVariant = "destructive"
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer hover:shadow-lg transition-shadow ${onClick ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">{pool.name}</CardTitle>
|
||||
<Badge variant={badgeVariant}>{pool.health}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Capacity Bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Capacity</span>
|
||||
<span className="font-medium">{pool.capacity}</span>
|
||||
</div>
|
||||
<Progress value={capacityPercent} max={100} />
|
||||
</div>
|
||||
|
||||
{/* Size Information */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Total</div>
|
||||
<div className="font-medium">{formatBytes(totalBytes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Used</div>
|
||||
<div className="font-medium">{formatBytes(usedBytes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Free</div>
|
||||
<div className="font-medium">{formatBytes(freeBytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fragmentation */}
|
||||
<div className="flex justify-between items-center text-sm pt-2 border-t border-border">
|
||||
<span className="text-muted-foreground">Fragmentation</span>
|
||||
<span className="font-medium">{pool.fragmentation}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { Vdev } from "@/lib/api"
|
||||
|
||||
function stateColor(state: string) {
|
||||
switch (state?.toUpperCase()) {
|
||||
case "ONLINE": return "text-green-500"
|
||||
case "DEGRADED": return "text-yellow-500"
|
||||
case "FAULTED":
|
||||
case "OFFLINE":
|
||||
case "UNAVAIL": return "text-red-500"
|
||||
default: return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
function stateIcon(state: string) {
|
||||
switch (state?.toUpperCase()) {
|
||||
case "ONLINE": return "●"
|
||||
case "DEGRADED": return "◑"
|
||||
default: return "○"
|
||||
}
|
||||
}
|
||||
|
||||
interface VdevRowProps {
|
||||
vdev: Vdev
|
||||
depth?: number
|
||||
}
|
||||
|
||||
function VdevRow({ vdev, depth = 0 }: VdevRowProps) {
|
||||
const hasErrors =
|
||||
vdev.read !== 0 || vdev.write !== 0 || vdev.cksum !== 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border/50 last:border-0 hover:bg-muted/30">
|
||||
<td className="py-2 pr-4">
|
||||
<span style={{ paddingLeft: `${depth * 20}px` }} className="flex items-center gap-2">
|
||||
<span className={`text-sm ${stateColor(vdev.state)}`}>{stateIcon(vdev.state)}</span>
|
||||
<span className={`font-mono text-sm ${depth === 0 ? "font-semibold" : ""}`}>
|
||||
{vdev.name}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-sm font-medium ${stateColor(vdev.state)}`}>
|
||||
{vdev.state}
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
|
||||
{vdev.read}
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
|
||||
{vdev.write}
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
|
||||
{vdev.cksum}
|
||||
</td>
|
||||
</tr>
|
||||
{vdev.children?.map((child) => (
|
||||
<VdevRow key={child.name} vdev={child} depth={depth + 1} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface VdevTreeProps {
|
||||
vdevs: Vdev[]
|
||||
}
|
||||
|
||||
export function VdevTree({ vdevs }: VdevTreeProps) {
|
||||
if (!vdevs || vdevs.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
No VDEV information available.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 px-3">State</th>
|
||||
<th className="pb-2 px-3 text-center">Read</th>
|
||||
<th className="pb-2 px-3 text-center">Write</th>
|
||||
<th className="pb-2 px-3 text-center">CkSum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vdevs.map((vdev) => (
|
||||
<VdevRow key={vdev.name} vdev={vdev} depth={0} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { ChevronRight, ChevronDown, Folder, Loader2 } from "lucide-react"
|
||||
|
||||
interface DirNode {
|
||||
name: string
|
||||
path: string
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
interface DirectoryTreeProps {
|
||||
currentPath: string
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: DirNode
|
||||
depth: number
|
||||
isActive: boolean
|
||||
isExpanded: boolean
|
||||
isLoading: boolean
|
||||
subdirs: DirNode[]
|
||||
basePath: string
|
||||
onExpand: (path: string) => void
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
const TreeNode = ({
|
||||
node,
|
||||
depth,
|
||||
isActive,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
subdirs,
|
||||
basePath,
|
||||
onExpand,
|
||||
onNavigate,
|
||||
}: TreeNodeProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded cursor-pointer transition-colors ${
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
>
|
||||
{node.has_children ? (
|
||||
<button
|
||||
onClick={() => onExpand(node.path)}
|
||||
className="p-0 w-4 h-4 flex items-center justify-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
|
||||
<Folder className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
|
||||
onNavigate(fullPath)
|
||||
}}
|
||||
className="text-sm truncate text-left"
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && subdirs.length > 0 && (
|
||||
<div>
|
||||
{subdirs.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isActive={isActive}
|
||||
isExpanded={false}
|
||||
isLoading={false}
|
||||
subdirs={[]}
|
||||
basePath={basePath}
|
||||
onExpand={onExpand}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BookmarkButton = ({
|
||||
label,
|
||||
path,
|
||||
onNavigate,
|
||||
}: {
|
||||
label: string
|
||||
path: string
|
||||
onNavigate: (path: string) => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onNavigate(path)}
|
||||
className="w-full text-left px-2 py-1.5 rounded text-xs hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
export function DirectoryTree({
|
||||
currentPath,
|
||||
onNavigate,
|
||||
}: DirectoryTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [childrenMap, setChildrenMap] = useState<Map<string, DirNode[]>>(
|
||||
new Map()
|
||||
)
|
||||
const [loading, setLoading] = useState<Set<string>>(new Set())
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
const getApiUrl = (path: string) => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || ""
|
||||
return baseUrl + path
|
||||
}
|
||||
|
||||
const fetchChildren = useCallback(
|
||||
async (path: string) => {
|
||||
// If already loaded, just toggle expand
|
||||
if (childrenMap.has(path)) {
|
||||
setExpanded((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path)
|
||||
} else {
|
||||
newSet.add(path)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch children
|
||||
setLoading((prev) => new Set(prev).add(path))
|
||||
|
||||
try {
|
||||
const qs = `path=${encodeURIComponent(path)}&admin=true`
|
||||
const res = await fetch(getApiUrl(`/api/navigator/dirs?${qs}`), {
|
||||
headers: getAuthHeader(),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
setChildrenMap((prev) => new Map(prev).set(path, data.dirs || []))
|
||||
setExpanded((prev) => new Set(prev).add(path))
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch subdirectories:", err)
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(path)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
},
|
||||
[childrenMap]
|
||||
)
|
||||
|
||||
// Auto-expand ancestors when currentPath changes
|
||||
useEffect(() => {
|
||||
if (!currentPath || currentPath === "/") return
|
||||
|
||||
const parts = currentPath.replace(/^\//, "").split("/").filter(Boolean)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const ancestorPath = parts.slice(0, i).join("/")
|
||||
if (!childrenMap.has(ancestorPath)) {
|
||||
fetchChildren(ancestorPath)
|
||||
} else {
|
||||
setExpanded((prev) => new Set(prev).add(ancestorPath))
|
||||
}
|
||||
}
|
||||
}, [currentPath, childrenMap, fetchChildren])
|
||||
|
||||
// Load root on mount
|
||||
useEffect(() => {
|
||||
fetchChildren("")
|
||||
}, [fetchChildren])
|
||||
|
||||
const basePath = "/"
|
||||
const rootPath = ""
|
||||
const rootChildren = childrenMap.get(rootPath) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Bookmarks Section */}
|
||||
<div className="border-b border-border pb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground px-2 mb-1">
|
||||
Favoriten
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
<BookmarkButton label="Wurzel" path="/" onNavigate={onNavigate} />
|
||||
<BookmarkButton label="Home" path="/home" onNavigate={onNavigate} />
|
||||
<BookmarkButton label="Root" path="/root" onNavigate={onNavigate} />
|
||||
<BookmarkButton label="Tank" path="/tank" onNavigate={onNavigate} />
|
||||
<BookmarkButton
|
||||
label="Var/Log"
|
||||
path="/var/log"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Tree */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold text-muted-foreground px-2">
|
||||
Verzeichnisse
|
||||
</p>
|
||||
<div
|
||||
className="space-y-0.5"
|
||||
style={{ maxHeight: "calc(100vh - 14rem)", overflowY: "auto" }}
|
||||
>
|
||||
{rootChildren.length > 0 ? (
|
||||
rootChildren.map((node) => {
|
||||
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
|
||||
return (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
isActive={currentPath === fullPath}
|
||||
isExpanded={expanded.has(node.path)}
|
||||
isLoading={loading.has(node.path)}
|
||||
subdirs={childrenMap.get(node.path) || []}
|
||||
basePath={basePath}
|
||||
onExpand={fetchChildren}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">
|
||||
{loading.has(rootPath) ? "Laden..." : "Keine Verzeichnisse"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { api } from "@/lib/api"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
interface CreateNfsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (share: any) => void
|
||||
}
|
||||
|
||||
export default function CreateNfsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: CreateNfsDialogProps) {
|
||||
const [path, setPath] = useState("")
|
||||
const [clients, setClients] = useState("")
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
const [sync, setSync] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (!path.trim()) {
|
||||
setError("Path is required")
|
||||
return
|
||||
}
|
||||
if (!clients.trim()) {
|
||||
setError("Clients are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Build options
|
||||
const opts = []
|
||||
opts.push(readonly ? "ro" : "rw")
|
||||
opts.push(sync ? "sync" : "async")
|
||||
opts.push("no_subtree_check")
|
||||
const options = opts.join(",")
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await api.createNfsShare({ path, clients, options })
|
||||
|
||||
onCreated({
|
||||
path,
|
||||
clients,
|
||||
options,
|
||||
})
|
||||
|
||||
setPath("")
|
||||
setClients("")
|
||||
setReadonly(false)
|
||||
setSync(true)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create share")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Create NFS Share</CardTitle>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="e.g., /tank/share"
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Must be an existing filesystem path
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Clients</label>
|
||||
<textarea
|
||||
value={clients}
|
||||
onChange={(e) => setClients(e.target.value)}
|
||||
placeholder="e.g., 192.168.1.0/24 10.0.0.0/8"
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-sm min-h-[80px] font-mono text-xs"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Space-separated CIDR ranges or IPs (e.g., 192.168.1.0/24, 10.0.0.5)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={readonly}
|
||||
onChange={(e) => setReadonly(e.target.checked)}
|
||||
disabled={loading}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm font-medium">Read-Only</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync}
|
||||
onChange={(e) => setSync(e.target.checked)}
|
||||
disabled={loading}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm font-medium">Sync Mode</span>
|
||||
<span className="text-xs text-muted-foreground">(safer than async)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Generated options:</strong>
|
||||
<br />
|
||||
{readonly ? "ro" : "rw"},{sync ? "sync" : "async"},no_subtree_check
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Create Share"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { api } from "@/lib/api"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
interface CreateSambaDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (share: any) => void
|
||||
}
|
||||
|
||||
export default function CreateSambaDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: CreateSambaDialogProps) {
|
||||
const [name, setName] = useState("")
|
||||
const [path, setPath] = useState("")
|
||||
const [comment, setComment] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Share name is required")
|
||||
return
|
||||
}
|
||||
if (!path.trim()) {
|
||||
setError("Path is required")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await api.createSambaShare({ name, path, comment: comment || undefined })
|
||||
|
||||
// Return the created share
|
||||
onCreated({
|
||||
name,
|
||||
path,
|
||||
comment: comment || null,
|
||||
valid_users: null,
|
||||
read_only: false,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
setName("")
|
||||
setPath("")
|
||||
setComment("")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create share")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Create Samba Share</CardTitle>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Share Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., share, media, backup"
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Alphanumeric, max 15 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="e.g., /tank/share"
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Must be an existing filesystem path
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Share description"
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Default permissions:</strong> Read/Write, authenticated users only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Create Share"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
type: string
|
||||
name: string
|
||||
onConfirm: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
name,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
Delete {type}?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Are you sure you want to delete this {type.toLowerCase()}?
|
||||
</p>
|
||||
<div className="bg-muted p-3 rounded border border-border">
|
||||
<p className="font-mono text-sm break-all">{name}</p>
|
||||
</div>
|
||||
<p className="text-xs text-red-600 mt-3">
|
||||
⚠️ This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning"
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-100 text-green-800",
|
||||
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const baseStyles =
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
|
||||
const variantStyle = variantStyles[variant]
|
||||
|
||||
return (
|
||||
<div className={`${baseStyles} ${variantStyle} ${className}`} {...props} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost"
|
||||
size?: "default" | "sm" | "lg"
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-sm",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className = "",
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
const variantStyle = variantStyles[variant]
|
||||
const sizeStyle = sizeStyles[size]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variantStyle} ${sizeStyle} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export function Card({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border bg-card text-card-foreground shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col space-y-1.5 p-6 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={`text-2xl font-semibold leading-none tracking-tight ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={`text-sm text-muted-foreground ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={`p-6 pt-0 ${className}`} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className = "",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center p-6 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, children }: DialogProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
|
||||
document.addEventListener("keydown", handler)
|
||||
return () => document.removeEventListener("keydown", handler)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="relative z-10 w-full max-w-md mx-4 rounded-lg border border-border bg-background shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: number
|
||||
max?: number
|
||||
color?: "default" | "success" | "warning" | "danger"
|
||||
}
|
||||
|
||||
const colorStyles = {
|
||||
default: "bg-primary",
|
||||
success: "bg-green-600",
|
||||
warning: "bg-yellow-600",
|
||||
danger: "bg-red-600",
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
color = "default",
|
||||
className = "",
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const percentage = Math.min((value / max) * 100, 100)
|
||||
|
||||
let colorClass = colorStyles[color]
|
||||
// Auto-select color based on percentage
|
||||
if (color === "default") {
|
||||
if (percentage >= 90) {
|
||||
colorClass = colorStyles.danger
|
||||
} else if (percentage >= 75) {
|
||||
colorClass = colorStyles.warning
|
||||
} else {
|
||||
colorClass = colorStyles.success
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full h-2 rounded-full bg-secondary overflow-hidden ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={`h-full ${colorClass} transition-all`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user