diff --git a/src/App.tsx b/src/App.tsx index 48b0e45..fc0eb0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ function App() { <>
+ diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index 4ef1622..7a6b936 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -1,92 +1,108 @@ -import React, { useState, useEffect } from 'react'; -import { Link, useLocation } from 'wouter'; -import { Button } from '../ui/button'; -import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; +import React, { useState, useEffect } from "react"; +import { Link, useLocation } from "wouter"; +import { Button } from "../ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '../ui/dropdown-menu'; -import { Dialog, DialogContent, DialogTrigger } from '../ui/dialog'; -import { Input } from '../ui/input'; -import { Label } from '../ui/label'; -import { AlertCircle } from 'lucide-react'; -import { Alert, AlertDescription } from '../ui/alert'; +} from "../ui/dropdown-menu"; +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription } from "../ui/alert"; const Header: React.FC = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [, setLocation] = useLocation(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); const checkLoginStatus = () => { - const ptCookie = document.cookie.split('; ').find(row => row.startsWith('pt=')); + const ptCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("pt=")); setIsLoggedIn(!!ptCookie); }; useEffect(() => { checkLoginStatus(); - window.addEventListener('focus', checkLoginStatus); + window.addEventListener("focus", checkLoginStatus); return () => { - window.removeEventListener('focus', checkLoginStatus); + window.removeEventListener("focus", checkLoginStatus); }; }, []); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - setError(''); + setError(""); try { const formData = new FormData(); - formData.append('username', username); - formData.append('password', password); + formData.append("username", username); + formData.append("password", password); - const response = await fetch('/api/login', { - method: 'POST', - body: formData, - credentials: 'include', - }); + const response = await fetch("/api/login", { + method: "POST", + body: formData, + credentials: "include", + }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Login failed'); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Login failed"); + } checkLoginStatus(); setIsDialogOpen(false); - setUsername(''); - setPassword(''); + setUsername(""); + setPassword(""); } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); + setError( + err instanceof Error ? err.message : "Login failed. Please try again." + ); } }; const handleLogout = () => { - document.cookie = 'pt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = "pt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; setIsLoggedIn(false); - setLocation('/'); + setLocation("/"); }; return (
- PikaOS Logo + + PikaOS Logo +
{isLoggedIn ? ( @@ -98,18 +114,18 @@ const Header: React.FC = () => { - setLocation('/profile')}> + setLocation("/profile")}> Profile - - Logout - + Logout ) : ( - +
@@ -117,8 +133,8 @@ const Header: React.FC = () => { - setUsername(e.target.value)} className="bg-background text-foreground border-border" @@ -128,21 +144,26 @@ const Header: React.FC = () => { - setPassword(e.target.value)} className="bg-background text-foreground border-border" /> {error && ( - + {error} )} - +
@@ -151,4 +172,4 @@ const Header: React.FC = () => { ); }; -export default Header; \ No newline at end of file +export default Header; diff --git a/src/components/pages/queue.tsx b/src/components/pages/queue.tsx new file mode 100644 index 0000000..857fc9a --- /dev/null +++ b/src/components/pages/queue.tsx @@ -0,0 +1,291 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useMediaQuery } from "react-responsive"; +import useSearchParams from "../../hooks/useSearchParams"; +import useQueueData from "../../hooks/useQueueData"; +import { Input } from "../ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../ui/pagination"; + +const Queue: React.FC = () => { + const { getParam, setParam } = useSearchParams(); + const { packages, totalCount, loading, fetchPackages } = useQueueData(); + const [currentPage, setCurrentPage] = useState( + parseInt(getParam("page") || "1", 10) + ); + const isMobile = useMediaQuery({ maxWidth: 767 }); + const [pageSize, setPageSize] = useState(isMobile ? 100 : 250); + const [search, setSearch] = useState(getParam("search") || ""); + const [filter, setFilter] = useState(getParam("filter") || "All"); + + const isInitialMount = useRef(true); + const prevDependencies = useRef({ currentPage, pageSize, search, filter }); + + useEffect(() => { + setPageSize(isMobile ? 100 : 250); + }, [isMobile]); + + const loadPackages = useCallback(() => { + fetchPackages(currentPage, pageSize, search, filter); + setParam("page", currentPage.toString()); + setParam("search", search); + setParam("filter", filter); + }, [currentPage, pageSize, search, filter, fetchPackages, setParam]); + + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + loadPackages(); + } else { + const { + currentPage: prevPage, + pageSize: prevSize, + search: prevSearch, + filter: prevFilter, + } = prevDependencies.current; + if ( + currentPage !== prevPage || + pageSize !== prevSize || + search !== prevSearch || + filter !== prevFilter + ) { + loadPackages(); + prevDependencies.current = { currentPage, pageSize, search, filter }; + } + } + }, [loadPackages, currentPage, pageSize, search, filter]); + + const handleFilterChange = (value: string) => { + const newFilter = value === "All" ? "" : value; + setFilter(newFilter); + setCurrentPage(1); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearch(e.target.value); + setCurrentPage(1); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( +
+ {/* Sticky Header */} +
+
+
+ + +
+
+
+ + {/* Table */} +
+
+
+ + + + Name + Version + Status + + + + {loading ? ( + + + Loading... + + + ) : Object.keys(packages).length === 0 ? ( + + + No packages found. + + + ) : ( + Object.entries(packages).map(([pkgName, pkgInfo]) => { + return ( + + +
+
+ + {pkgName} - {pkgInfo.Type} + + + {pkgInfo.Status} + +
+
+ + {pkgInfo.BuildVersion} + +
+
+
+ +
+ + {pkgInfo.BuildVersion} + +
+
+ + {pkgInfo.Status} + +
+ ); + }) + )} +
+
+
+
+
+ + {/* Sticky Footer with Pagination */} +
+
+ + + + handlePageChange(Math.max(currentPage - 1, 1))} + /> + + + {currentPage > 2 && ( + + handlePageChange(1)} + > + 1 + + + )} + + {currentPage > 3 && ( + + + + )} + + {currentPage > 1 && ( + + handlePageChange(currentPage - 1)} + > + {currentPage - 1} + + + )} + + + + {currentPage} + + + + {currentPage < totalPages && ( + + handlePageChange(currentPage + 1)} + > + {currentPage + 1} + + + )} + + {currentPage < totalPages - 2 && ( + + + + )} + + {currentPage < totalPages - 1 && ( + + handlePageChange(totalPages)} + > + {totalPages} + + + )} + + + + handlePageChange(Math.min(currentPage + 1, totalPages)) + } + /> + + + +
+
+
+ ); +}; + +export default Queue; diff --git a/src/hooks/useQueueData.ts b/src/hooks/useQueueData.ts new file mode 100644 index 0000000..62b24e3 --- /dev/null +++ b/src/hooks/useQueueData.ts @@ -0,0 +1,95 @@ +import { useState, useCallback, useRef } from 'react'; + +interface PackageStats { + stale: number; + missing: number; + built: number; + error: number; + queued: number; + building: number; +} + +interface CountResponse { + lastUpdateTime: string; + counts: PackageStats; +} + +interface QueueData { + Source: PackageData; + Type: string; + BuildVersion: string; + Status: string; +} + +interface PackageData { + Name: string; + Packages: { + [key: string]: { + Version: string; + NewVersion: string; + Status: string; + }; + }; +} + +interface PackagesResponse { + total: number; + packages: Record; +} + +const usePackageData = () => { + const [stats, setStats] = useState(null); + const [lastUpdated, setLastUpdated] = useState(''); + const [packages, setQueuePackages] = useState>({}); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const fetchStatsAttempted = useRef(false); + + const fetchStats = useCallback(async () => { + if (fetchStatsAttempted.current || stats) return; + fetchStatsAttempted.current = true; + setLoading(true); + try { + const response = await fetch('/api/counts'); + const data: CountResponse = await response.json(); + setStats(data.counts); + const dt = new Date(data.lastUpdateTime); + setLastUpdated(dt.toLocaleString()); + } catch (error) { + console.error('Failed to fetch stats:', error); + } finally { + setLoading(false); + } + }, [stats]); + + const fetchPackages = useCallback(async (page: number, pageSize: number, search: string, filter: string) => { + setLoading(true); + try { + const filterParam = filter === "All" ? "" : filter; + const response = await fetch( + `/api/queue?page=${page}&pageSize=${pageSize}&search=${search}&filter=${filterParam}` + ); + const data: PackagesResponse = await response.json(); + setTotalCount(data.total); + setQueuePackages(data.packages); + } catch (error) { + console.error("Error fetching packages:", error); + setQueuePackages({}); + setTotalCount(0); + } finally { + setLoading(false); + } + }, []); + + return { + stats, + lastUpdated, + fetchStats, + packages, + totalCount, + loading, + fetchPackages, + }; + }; + + export default usePackageData; \ No newline at end of file