Files
Extrudex/frontend/src/pages/PrintersPage.tsx
hex-bot 668d8a965e
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m26s
CUB-134: Build Printers list page
2026-05-12 15:00:20 -04:00

469 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Search,
Wifi,
WifiOff,
HelpCircle,
Pencil,
Plus,
Trash2,
AlertTriangle,
ChevronLeft,
ChevronRight,
Server,
Radio,
ShieldCheck,
} from 'lucide-react'
import { fetchPrinters } from '../services/printerService'
import type { Printer } from '../types/printer'
const PAGE_SIZE = 20
type SortField = 'name' | 'printer_type' | 'manufacturer' | 'model'
type SortDir = 'asc' | 'desc'
type ConnStatus = 'online' | 'offline' | 'unknown'
export default function PrintersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<ConnStatus | ''>('')
const [typeFilter, setTypeFilter] = useState('')
const [sortBy, setSortBy] = useState<SortField>('name')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [page, setPage] = useState(0)
const [deleteId, setDeleteId] = useState<number | null>(null)
const {
data: printers,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['printers'],
queryFn: fetchPrinters,
})
// Derive connection status from backend is_active for now
// TODO: replace with real status endpoint / SSE when available
const printersWithStatus = useMemo(() => {
return (printers ?? []).map((p) => {
const status: ConnStatus = p.is_active ? 'online' : 'offline'
return { ...p, connection_status: status }
})
}, [printers])
// Client-side filter + sort
const filtered = useMemo(() => {
let list = [...printersWithStatus]
if (search.trim()) {
const q = search.toLowerCase()
list = list.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.manufacturer?.toLowerCase().includes(q) ?? false) ||
(p.model?.toLowerCase().includes(q) ?? false)
)
}
if (statusFilter) {
list = list.filter((p) => p.connection_status === statusFilter)
}
if (typeFilter) {
list = list.filter((p) => p.printer_type?.name === typeFilter)
}
list.sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name) * dir
case 'printer_type':
return (a.printer_type?.name ?? '').localeCompare(b.printer_type?.name ?? '') * dir
case 'manufacturer':
return (a.manufacturer ?? '').localeCompare(b.manufacturer ?? '') * dir
case 'model':
return (a.model ?? '').localeCompare(b.model ?? '') * dir
default:
return 0
}
})
return list
}, [printersWithStatus, search, statusFilter, typeFilter, sortBy, sortDir])
const total = filtered.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const uniqueTypes = useMemo(() => {
const s = new Set<string>()
;(printers ?? []).forEach((p) => {
if (p.printer_type?.name) s.add(p.printer_type.name)
})
return Array.from(s).sort()
}, [printers])
const handleSort = (field: SortField) => {
if (sortBy === field) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortBy(field)
setSortDir('asc')
}
setPage(0)
}
const handleDelete = async (_id: number) => {
// await deletePrinter(id)
setDeleteId(null)
refetch()
}
const SortIndicator = ({ field }: { field: SortField }) => {
if (sortBy !== field) return <span className="text-slate-600 ml-1"></span>
return <span className="text-emerald-400 ml-1">{sortDir === 'asc' ? '↑' : '↓'}</span>
}
const StatusBadge = ({ status }: { status: ConnStatus }) => {
if (status === 'online') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-900/30 border border-emerald-700 px-2.5 py-1 text-xs font-medium text-emerald-300">
<Wifi size={12} /> Online
</span>
)
}
if (status === 'offline') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-900/30 border border-red-700 px-2.5 py-1 text-xs font-medium text-red-300">
<WifiOff size={12} /> Offline
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-slate-700/50 border border-slate-600 px-2.5 py-1 text-xs font-medium text-slate-400">
<HelpCircle size={12} /> Unknown
</span>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-slate-100">Printers</h2>
<p className="text-sm text-slate-400">{total} printer(s)</p>
</div>
<button className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-colors">
<Plus size={16} /> Add Printer
</button>
</div>
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search by name, manufacturer, or model…"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
className="w-full rounded-lg bg-slate-800 border border-slate-700 pl-9 pr-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as ConnStatus | '')
setPage(0)
}}
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
</select>
{/* Type filter */}
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value)
setPage(0)
}}
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">All Types</option>
{uniqueTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* Loading / Error */}
{isLoading && <div className="text-center py-12 text-slate-400">Loading printers</div>}
{error && (
<div className="text-center py-12 text-red-400">
Failed to load printers.
<button onClick={() => refetch()} className="ml-2 underline hover:text-red-300">
Retry
</button>
</div>
)}
{/* Desktop Table */}
{!isLoading && !error && (
<>
<div className="hidden md:block overflow-x-auto rounded-lg border border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-800 text-slate-300">
<tr>
<th
className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100"
onClick={() => handleSort('name')}
>
Name <SortIndicator field="name" />
</th>
<th
className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100"
onClick={() => handleSort('printer_type')}
>
Type <SortIndicator field="printer_type" />
</th>
<th
className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100"
onClick={() => handleSort('manufacturer')}
>
Manufacturer <SortIndicator field="manufacturer" />
</th>
<th
className="px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100"
onClick={() => handleSort('model')}
>
Model <SortIndicator field="model" />
</th>
<th className="px-4 py-3 text-left font-semibold">Connection</th>
<th className="px-4 py-3 text-left font-semibold">Moonraker</th>
<th className="px-4 py-3 text-left font-semibold">MQTT</th>
<th className="px-4 py-3 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{pageItems.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
No printers found.
</td>
</tr>
)}
{pageItems.map((p: Printer & { connection_status: ConnStatus }) => (
<tr key={p.id} className="bg-slate-800/50 hover:bg-slate-700/50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-100">{p.name}</td>
<td className="px-4 py-3 text-slate-300">
<span className="inline-flex items-center gap-1.5 rounded-md bg-slate-700/50 px-2 py-0.5 text-xs">
{p.printer_type?.name ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-slate-300">{p.manufacturer ?? '—'}</td>
<td className="px-4 py-3 text-slate-300">{p.model ?? '—'}</td>
<td className="px-4 py-3">
<StatusBadge status={p.connection_status} />
</td>
<td className="px-4 py-3 text-slate-300">
{p.moonraker_url ? (
<span className="inline-flex items-center gap-1.5 text-xs text-blue-300">
<Server size={12} />
<a
href={p.moonraker_url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline truncate max-w-[160px] inline-block"
title={p.moonraker_url}
>
{p.moonraker_url}
</a>
</span>
) : (
<span className="text-slate-500 text-xs"></span>
)}
</td>
<td className="px-4 py-3">
{p.mqtt_broker_host ? (
<span className="inline-flex items-center gap-1 text-xs text-slate-300">
<Radio size={12} className="text-emerald-400" />
{p.mqtt_topic_prefix ? `${p.mqtt_topic_prefix} @ ` : ''}
{p.mqtt_broker_host}
{p.mqtt_tls_enabled && (
<span title="TLS enabled">
<ShieldCheck size={12} className="text-emerald-400" />
</span>
)}
</span>
) : (
<span className="text-slate-500 text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
className="p-1.5 rounded hover:bg-slate-600 text-slate-400 hover:text-blue-400 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteId(p.id)}
className="p-1.5 rounded hover:bg-slate-600 text-slate-400 hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden space-y-3">
{pageItems.length === 0 && (
<div className="text-center py-12 text-slate-500">No printers found.</div>
)}
{pageItems.map((p: Printer & { connection_status: ConnStatus }) => (
<div
key={p.id}
className="rounded-lg border border-slate-700 bg-slate-800 p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div>
<div className="font-semibold text-slate-100">{p.name}</div>
<div className="text-xs text-slate-400 mt-0.5">
{p.printer_type?.name ?? '—'} · {p.manufacturer ?? '—'} {p.model ?? ''}
</div>
</div>
<StatusBadge status={p.connection_status} />
</div>
{/* Moonraker */}
{p.moonraker_url && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<Server size={14} className="text-blue-400 shrink-0" />
<a
href={p.moonraker_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-300 hover:underline truncate"
>
{p.moonraker_url}
</a>
</div>
)}
{/* MQTT */}
{p.mqtt_broker_host && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<Radio size={14} className="text-emerald-400 shrink-0" />
<span className="truncate">
{p.mqtt_topic_prefix ? `${p.mqtt_topic_prefix} @ ` : ''}
{p.mqtt_broker_host}
{p.mqtt_tls_enabled && (
<span title="TLS enabled">
<ShieldCheck size={14} className="text-emerald-400 inline ml-1" />
</span>
)}
</span>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1">
<button className="flex items-center gap-1 rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-600">
<Pencil size={12} /> Edit
</button>
<button
onClick={() => setDeleteId(p.id)}
className="flex items-center gap-1 rounded-md bg-red-900/30 border border-red-700 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-900/50"
>
<Trash2 size={12} /> Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-slate-400">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm text-slate-300 tabular-nums">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</>
)}
{/* Delete confirmation modal */}
{deleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-xl bg-slate-800 border border-slate-700 p-6 shadow-2xl space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-900/30">
<AlertTriangle size={20} className="text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-100">Delete Printer?</h3>
<p className="text-sm text-slate-400">This action cannot be undone.</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteId(null)}
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}