diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5a8c3c..9f39c43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,29 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom' +import { Box, Printer } from 'lucide-react' import InventoryPage from './pages/InventoryPage' +import PrintersPage from './pages/PrintersPage' const queryClient = new QueryClient() +function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ElementType; label: string }) { + return ( + + `flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ + isActive + ? 'bg-emerald-600/20 text-emerald-300' + : 'text-slate-300 hover:bg-slate-700/50 hover:text-slate-100' + }` + } + > + + {label} + + ) +} + export default function App() { return ( @@ -12,10 +32,20 @@ export default function App() {
E

Extrudex

+
+ {/* Mobile nav */} +
} /> + } />
diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx new file mode 100644 index 0000000..ec684d3 --- /dev/null +++ b/frontend/src/pages/PrintersPage.tsx @@ -0,0 +1,468 @@ +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('') + const [typeFilter, setTypeFilter] = useState('') + const [sortBy, setSortBy] = useState('name') + const [sortDir, setSortDir] = useState('asc') + const [page, setPage] = useState(0) + const [deleteId, setDeleteId] = useState(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() + ;(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 + return {sortDir === 'asc' ? '↑' : '↓'} + } + + const StatusBadge = ({ status }: { status: ConnStatus }) => { + if (status === 'online') { + return ( + + Online + + ) + } + if (status === 'offline') { + return ( + + Offline + + ) + } + return ( + + Unknown + + ) + } + + return ( +
+ {/* Header */} +
+
+

Printers

+

{total} printer(s)

+
+ +
+ + {/* Filters */} +
+ {/* Search */} +
+ + { + 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" + /> +
+ + {/* Status filter */} + + + {/* Type filter */} + +
+ + {/* Loading / Error */} + {isLoading &&
Loading printers…
} + {error && ( +
+ Failed to load printers. + +
+ )} + + {/* Desktop Table */} + {!isLoading && !error && ( + <> +
+ + + + + + + + + + + + + + + {pageItems.length === 0 && ( + + + + )} + {pageItems.map((p: Printer & { connection_status: ConnStatus }) => ( + + + + + + + + + + + ))} + +
handleSort('name')} + > + Name + handleSort('printer_type')} + > + Type + handleSort('manufacturer')} + > + Manufacturer + handleSort('model')} + > + Model + ConnectionMoonrakerMQTTActions
+ No printers found. +
{p.name} + + {p.printer_type?.name ?? '—'} + + {p.manufacturer ?? '—'}{p.model ?? '—'} + + + {p.moonraker_url ? ( + + + + {p.moonraker_url} + + + ) : ( + + )} + + {p.mqtt_broker_host ? ( + + + {p.mqtt_topic_prefix ? `${p.mqtt_topic_prefix} @ ` : ''} + {p.mqtt_broker_host} + {p.mqtt_tls_enabled && ( + + + + )} + + ) : ( + + )} + +
+ + +
+
+
+ + {/* Mobile Cards */} +
+ {pageItems.length === 0 && ( +
No printers found.
+ )} + {pageItems.map((p: Printer & { connection_status: ConnStatus }) => ( +
+
+
+
{p.name}
+
+ {p.printer_type?.name ?? '—'} · {p.manufacturer ?? '—'} {p.model ?? ''} +
+
+ +
+ + {/* Moonraker */} + {p.moonraker_url && ( + + )} + + {/* MQTT */} + {p.mqtt_broker_host && ( +
+ + + {p.mqtt_topic_prefix ? `${p.mqtt_topic_prefix} @ ` : ''} + {p.mqtt_broker_host} + {p.mqtt_tls_enabled && ( + + + + )} + +
+ )} + + {/* Actions */} +
+ + +
+
+ ))} +
+ + {/* Pagination */} +
+ + Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total} + +
+ + + {page + 1} / {totalPages} + + +
+
+ + )} + + {/* Delete confirmation modal */} + {deleteId !== null && ( +
+
+
+
+ +
+
+

Delete Printer?

+

This action cannot be undone.

+
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/services/printerService.ts b/frontend/src/services/printerService.ts new file mode 100644 index 0000000..9b81f8d --- /dev/null +++ b/frontend/src/services/printerService.ts @@ -0,0 +1,13 @@ +import axios from 'axios' +import type { Printer } from '../types/printer' + +const API_BASE = '/api' + +export async function fetchPrinters(): Promise { + const res = await axios.get<{ data: Printer[] }>(`${API_BASE}/printers`) + return res.data.data +} + +export async function deletePrinter(id: number): Promise { + await axios.delete(`${API_BASE}/printers/${id}`) +} diff --git a/frontend/src/types/printer.ts b/frontend/src/types/printer.ts new file mode 100644 index 0000000..01ef871 --- /dev/null +++ b/frontend/src/types/printer.ts @@ -0,0 +1,33 @@ +// Extrudex printer domain types + +export interface PrinterType { + id: number + name: string + created_at: string + updated_at: string +} + +export interface Printer { + id: number + name: string + printer_type_id: number + printer_type?: PrinterType + manufacturer?: string + model?: string + moonraker_url?: string + moonraker_api_key?: string + mqtt_broker_host?: string + mqtt_topic_prefix?: string + mqtt_tls_enabled: boolean + is_active: boolean + connection_status?: 'online' | 'offline' | 'unknown' + created_at: string + updated_at: string +} + +export interface ListResponse { + data: T[] + total: number + limit: number + offset: number +}