Add custom items per page dropdowns

This commit is contained in:
headlessdev 2025-04-25 23:34:52 +02:00
parent d81d8c04ad
commit 677d1c5a58
6 changed files with 470 additions and 34 deletions

View File

@ -40,7 +40,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
applications: applicationsWithServers,
servers: servers_all,
maxPage
maxPage,
totalItems: totalCount
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
interface RequestBody {
timespan?: number;
page?: number;
itemsPerPage?: number;
}
@ -100,8 +101,7 @@ const getIntervalKey = (date: Date, timespan: number) => {
export async function POST(request: NextRequest) {
try {
const { timespan = 1, page = 1 }: RequestBody = await request.json();
const itemsPerPage = 5;
const { timespan = 1, page = 1, itemsPerPage = 5 }: RequestBody = await request.json();
const skip = (page - 1) * itemsPerPage;
// Get paginated and sorted applications

View File

@ -233,8 +233,9 @@ export async function POST(request: NextRequest) {
// Only calculate maxPage when not requesting a specific server
let maxPage = 1;
let totalHosts = 0;
if (!serverId) {
const totalHosts = await prisma.server.count({
totalHosts = await prisma.server.count({
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
});
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
@ -242,7 +243,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
servers: hostsWithVms,
maxPage
maxPage,
totalItems: totalHosts
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });

View File

@ -65,7 +65,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import axios from "axios";
import {
Tooltip,
@ -98,6 +98,7 @@ interface ApplicationsResponse {
applications: Application[];
servers: Server[];
maxPage: number;
totalItems?: number;
}
export default function Dashboard() {
@ -126,11 +127,15 @@ export default function Dashboard() {
const [isSearching, setIsSearching] = useState<boolean>(false);
const savedLayout = Cookies.get("layoutPreference-app");
const savedItemsPerPage = Cookies.get("itemsPerPage-app");
const initialIsGridLayout = savedLayout === "grid";
const initialItemsPerPage = initialIsGridLayout ? 15 : 5;
const defaultItemsPerPage = initialIsGridLayout ? 15 : 5;
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
const [isGridLayout, setIsGridLayout] = useState<boolean>(initialIsGridLayout);
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
const customInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const toggleLayout = () => {
const newLayout = !isGridLayout;
@ -140,7 +145,35 @@ export default function Dashboard() {
path: "/",
sameSite: "strict",
});
setItemsPerPage(newLayout ? 15 : 5);
// Don't automatically change itemsPerPage when layout changes
};
const handleItemsPerPageChange = (value: string) => {
// Clear any existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set a new timer
debounceTimerRef.current = setTimeout(() => {
const newItemsPerPage = parseInt(value);
// Ensure the value is within the valid range
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
toast.error("Please enter a number between 1 and 100");
return;
}
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1); // Reset to first page when changing items per page
Cookies.set("itemsPerPage-app", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
}, 300); // 300ms delay
};
const add = async () => {
@ -171,6 +204,9 @@ export default function Dashboard() {
setApplications(response.data.applications);
setServers(response.data.servers);
setMaxPage(response.data.maxPage);
if (response.data.totalItems !== undefined) {
setTotalItems(response.data.totalItems);
}
setLoading(false);
} catch (error: any) {
console.log(error.response?.data);
@ -178,6 +214,11 @@ export default function Dashboard() {
}
};
// Calculate current range of items being displayed
const [totalItems, setTotalItems] = useState<number>(0);
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
useEffect(() => {
getApplications();
}, [currentPage, itemsPerPage]);
@ -308,6 +349,91 @@ export default function Dashboard() {
<LayoutGrid className="h-4 w-4" />
)}
</Button>
<Select
value={String(itemsPerPage)}
onValueChange={handleItemsPerPageChange}
onOpenChange={(open) => {
if (open && customInputRef.current) {
customInputRef.current.value = String(itemsPerPage);
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
<SelectItem value={String(itemsPerPage)}>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'} (custom)
</SelectItem>
) : null}
<SelectItem value="5">5 items</SelectItem>
<SelectItem value="10">10 items</SelectItem>
<SelectItem value="15">15 items</SelectItem>
<SelectItem value="20">20 items</SelectItem>
<SelectItem value="25">25 items</SelectItem>
<div className="p-2 border-t mt-1">
<Label htmlFor="custom-items" className="text-xs font-medium">Custom (1-100)</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="custom-items"
ref={customInputRef}
type="number"
min="1"
max="100"
className="h-8"
defaultValue={itemsPerPage}
onChange={(e) => {
// Don't immediately apply the change while typing
// Just validate the input for visual feedback
const value = parseInt(e.target.value);
if (isNaN(value) || value < 1 || value > 100) {
e.target.classList.add("border-red-500");
} else {
e.target.classList.remove("border-red-500");
}
}}
onBlur={(e) => {
// Apply the change when the input loses focus
const value = parseInt(e.target.value);
if (value >= 1 && value <= 100) {
handleItemsPerPageChange(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// Clear any existing debounce timer to apply immediately
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const value = parseInt((e.target as HTMLInputElement).value);
if (value >= 1 && value <= 100) {
// Apply change immediately on Enter
const validatedValue = Math.min(Math.max(value, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1);
Cookies.set("itemsPerPage-app", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Close the dropdown
document.body.click();
}
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">items</span>
</div>
</div>
</SelectContent>
</Select>
{servers.length === 0 ? (
<p className="text-muted-foreground">
You must first add a server.
@ -680,6 +806,11 @@ export default function Dashboard() {
</div>
)}
<div className="pt-4 pb-4">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-muted-foreground">
{totalItems > 0 ? `Showing ${startItem}-${endItem} of ${totalItems} applications` : "No applications found"}
</div>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>

View File

@ -103,6 +103,7 @@ interface Server {
interface GetServersResponse {
servers: Server[]
maxPage: number
totalItems: number
}
interface MonitoringData {
@ -136,6 +137,7 @@ export default function Dashboard() {
const [maxPage, setMaxPage] = useState<number>(1)
const [servers, setServers] = useState<Server[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [totalItems, setTotalItems] = useState<number>(0)
const [editId, setEditId] = useState<number | null>(null)
const [editHost, setEditHost] = useState<boolean>(false)
@ -161,11 +163,15 @@ export default function Dashboard() {
const [monitoringInterval, setMonitoringInterval] = useState<NodeJS.Timeout | null>(null);
const savedLayout = Cookies.get("layoutPreference-servers");
const savedItemsPerPage = Cookies.get("itemsPerPage-servers");
const initialIsGridLayout = savedLayout === "grid";
const initialItemsPerPage = initialIsGridLayout ? 6 : 4;
const defaultItemsPerPage = initialIsGridLayout ? 6 : 4;
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
const [isGridLayout, setIsGridLayout] = useState<boolean>(initialIsGridLayout);
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
const customInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const toggleLayout = () => {
const newLayout = !isGridLayout;
@ -175,7 +181,6 @@ export default function Dashboard() {
path: "/",
sameSite: "strict",
});
setItemsPerPage(newLayout ? 6 : 4); // Update itemsPerPage based on new layout
};
const add = async () => {
@ -231,6 +236,7 @@ export default function Dashboard() {
setServers(response.data.servers)
console.log(response.data.servers)
setMaxPage(response.data.maxPage)
setTotalItems(response.data.totalItems)
setLoading(false)
} catch (error: any) {
console.log(error.response)
@ -451,6 +457,61 @@ export default function Dashboard() {
};
}, []);
// Handler für benutzerdefinierte Zahleneingaben mit Verzögerung
const handleItemsPerPageChange = (value: string) => {
// Bestehenden Timer löschen
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Neuen Timer setzen
debounceTimerRef.current = setTimeout(() => {
const newItemsPerPage = parseInt(value);
// Sicherstellen, dass der Wert im gültigen Bereich liegt
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
toast.error("Bitte eine Zahl zwischen 1 und 100 eingeben");
return;
}
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1); // Zurück zur ersten Seite
Cookies.set("itemsPerPage-servers", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Daten mit neuer Paginierung abrufen
getServers();
}, 600); // 600ms Verzögerung für bessere Eingabe mehrziffriger Zahlen
};
// Handler für voreingestellte Werte aus dem Dropdown
const handlePresetItemsPerPageChange = (value: string) => {
// Für voreingestellte Werte sofort anwenden
const newItemsPerPage = parseInt(value);
// Nur Standardwerte hier verarbeiten
if ([4, 6, 10, 15, 20, 25].includes(newItemsPerPage)) {
setItemsPerPage(newItemsPerPage);
setCurrentPage(1); // Zurück zur ersten Seite
Cookies.set("itemsPerPage-servers", String(newItemsPerPage), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Daten mit neuer Paginierung abrufen
getServers();
} else {
// Für benutzerdefinierte Werte den verzögerten Handler verwenden
handleItemsPerPageChange(value);
}
};
return (
<SidebarProvider>
<AppSidebar />
@ -491,6 +552,99 @@ export default function Dashboard() {
<TooltipContent>{isGridLayout ? "Switch to list view" : "Switch to grid view"}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select
value={String(itemsPerPage)}
onValueChange={handlePresetItemsPerPageChange}
onOpenChange={(open) => {
if (open && customInputRef.current) {
customInputRef.current.value = String(itemsPerPage);
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{![4, 6, 10, 15, 20, 25].includes(itemsPerPage) ? (
<SelectItem value={String(itemsPerPage)}>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'} (custom)
</SelectItem>
) : null}
<SelectItem value="4">4 items</SelectItem>
<SelectItem value="6">6 items</SelectItem>
<SelectItem value="10">10 items</SelectItem>
<SelectItem value="15">15 items</SelectItem>
<SelectItem value="20">20 items</SelectItem>
<SelectItem value="25">25 items</SelectItem>
<div className="p-2 border-t mt-1">
<Label htmlFor="custom-items" className="text-xs font-medium">Custom (1-100)</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="custom-items"
ref={customInputRef}
type="number"
min="1"
max="100"
className="h-8"
defaultValue={itemsPerPage}
onChange={(e) => {
// Änderung nicht sofort anwenden während des Tippens
// Nur visuelles Feedback für die Validierung
const value = parseInt(e.target.value);
if (isNaN(value) || value < 1 || value > 100) {
e.target.classList.add("border-red-500");
} else {
e.target.classList.remove("border-red-500");
}
}}
onBlur={(e) => {
// Änderung anwenden, wenn das Input den Fokus verliert
const value = parseInt(e.target.value);
if (value >= 1 && value <= 100) {
handleItemsPerPageChange(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// Bestehenden Debounce-Timer löschen, um sofort anzuwenden
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const value = parseInt((e.target as HTMLInputElement).value);
if (value >= 1 && value <= 100) {
// Änderung sofort bei Enter anwenden
const validatedValue = Math.min(Math.max(value, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1);
Cookies.set("itemsPerPage-servers", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Kurze Verzögerung hinzufügen für bessere Reaktionsfähigkeit
setTimeout(() => {
getServers();
// Dropdown schließen
document.body.click();
}, 50);
}
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">items</span>
</div>
</div>
</SelectContent>
</Select>
<AlertDialog onOpenChange={setIsAddDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon">
@ -1862,6 +2016,13 @@ export default function Dashboard() {
</div>
)}
<div className="pt-4 pb-4">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-muted-foreground">
{totalItems > 0
? `Showing ${((currentPage - 1) * itemsPerPage) + 1}-${Math.min(currentPage * itemsPerPage, totalItems)} of ${totalItems} servers`
: "No servers found"}
</div>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>

View File

@ -13,7 +13,6 @@ import {
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader } from "@/components/ui/card";
import * as Tooltip from "@radix-ui/react-tooltip";
@ -26,6 +25,12 @@ import {
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import { useState, useEffect, useRef } from "react";
import Cookies from "js-cookie";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
const timeFormats = {
1: (timestamp: string) =>
@ -84,8 +89,16 @@ export default function Uptime() {
totalItems: 0
});
const [isLoading, setIsLoading] = useState(false);
const savedItemsPerPage = Cookies.get("itemsPerPage-uptime");
const defaultItemsPerPage = 5;
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
const customInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const getData = async (selectedTimespan: number, page: number) => {
const getData = async (selectedTimespan: number, page: number, itemsPerPage: number) => {
setIsLoading(true);
try {
const response = await axios.post<{
@ -93,7 +106,8 @@ export default function Uptime() {
pagination: PaginationData;
}>("/api/applications/uptime", {
timespan: selectedTimespan,
page
page,
itemsPerPage
});
setData(response.data.data);
@ -114,17 +128,48 @@ export default function Uptime() {
const handlePrevious = () => {
const newPage = Math.max(1, pagination.currentPage - 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
getData(timespan, newPage, itemsPerPage);
};
const handleNext = () => {
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
getData(timespan, newPage, itemsPerPage);
};
const handleItemsPerPageChange = (value: string) => {
// Clear any existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set a new timer
debounceTimerRef.current = setTimeout(() => {
const newItemsPerPage = parseInt(value);
// Ensure the value is within the valid range
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
toast.error("Please enter a number between 1 and 100");
return;
}
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
setItemsPerPage(validatedValue);
setPagination(prev => ({...prev, currentPage: 1})); // Reset to first page
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Fetch data with new pagination
getData(timespan, 1, validatedValue);
}, 300); // 300ms delay
};
useEffect(() => {
getData(timespan, 1);
getData(timespan, 1, itemsPerPage);
}, [timespan]);
return (
@ -152,27 +197,116 @@ export default function Uptime() {
</Breadcrumb>
</div>
</header>
<Toaster />
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-3xl font-bold">Uptime</span>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3 | 4);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 1 hour</SelectItem>
<SelectItem value="2">Last 1 day</SelectItem>
<SelectItem value="3">Last 7 days</SelectItem>
<SelectItem value="4">Last 30 days</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select
value={String(itemsPerPage)}
onValueChange={handleItemsPerPageChange}
onOpenChange={(open) => {
if (open && customInputRef.current) {
customInputRef.current.value = String(itemsPerPage);
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
<SelectItem value={String(itemsPerPage)}>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'} (custom)
</SelectItem>
) : null}
<SelectItem value="5">5 items</SelectItem>
<SelectItem value="10">10 items</SelectItem>
<SelectItem value="15">15 items</SelectItem>
<SelectItem value="20">20 items</SelectItem>
<SelectItem value="25">25 items</SelectItem>
<div className="p-2 border-t mt-1">
<Label htmlFor="custom-items" className="text-xs font-medium">Custom (1-100)</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="custom-items"
ref={customInputRef}
type="number"
min="1"
max="100"
className="h-8"
defaultValue={itemsPerPage}
onChange={(e) => {
// Don't immediately apply the change while typing
// Just validate the input for visual feedback
const value = parseInt(e.target.value);
if (isNaN(value) || value < 1 || value > 100) {
e.target.classList.add("border-red-500");
} else {
e.target.classList.remove("border-red-500");
}
}}
onBlur={(e) => {
// Apply the change when the input loses focus
const value = parseInt(e.target.value);
if (value >= 1 && value <= 100) {
handleItemsPerPageChange(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// Clear any existing debounce timer to apply immediately
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const value = parseInt((e.target as HTMLInputElement).value);
if (value >= 1 && value <= 100) {
// Apply change immediately on Enter
const validatedValue = Math.min(Math.max(value, 1), 100);
setItemsPerPage(validatedValue);
setPagination(prev => ({...prev, currentPage: 1}));
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
getData(timespan, 1, validatedValue);
// Close the dropdown
document.body.click();
}
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">items</span>
</div>
</div>
</SelectContent>
</Select>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3 | 4);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 1 hour</SelectItem>
<SelectItem value="2">Last 1 day</SelectItem>
<SelectItem value="3">Last 7 days</SelectItem>
<SelectItem value="4">Last 30 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-4 space-y-4">
@ -260,6 +394,13 @@ export default function Uptime() {
{pagination.totalItems > 0 && !isLoading && (
<div className="pt-4 pb-4">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-muted-foreground">
{pagination.totalItems > 0
? `Showing ${((pagination.currentPage - 1) * itemsPerPage) + 1}-${Math.min(pagination.currentPage * itemsPerPage, pagination.totalItems)} of ${pagination.totalItems} items`
: "No items found"}
</div>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>