start backend work

This commit is contained in:
Lee
2024-10-08 15:32:02 +01:00
parent 04ce91b459
commit aa0a0c4c16
445 changed files with 367 additions and 11413 deletions

View File

@ -0,0 +1,192 @@
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
import {
Pagination as ShadCnPagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../ui/pagination";
type PaginationItemWrapperProps = {
/**
* Whether a page is currently loading.
*/
isLoadingPage: boolean;
/**
* The children to render.
*/
children: React.ReactNode;
};
function PaginationItemWrapper({ isLoadingPage, children }: PaginationItemWrapperProps) {
return (
<PaginationItem
className={clsx(isLoadingPage ? "cursor-not-allowed" : "cursor-pointer")}
aria-disabled={isLoadingPage}
tabIndex={isLoadingPage ? -1 : undefined}
>
{children}
</PaginationItem>
);
}
type Props = {
/**
* If true, the pagination will be rendered as a mobile-friendly pagination.
*/
mobilePagination: boolean;
/**
* The current page.
*/
page: number;
/**
* The total number of pages.
*/
totalPages: number;
/**
* The page to show a loading icon on.
*/
loadingPage: number | undefined;
/**
* Callback function that is called when the user clicks on a page number.
*/
onPageChange: (page: number) => void;
/**
* Optional callback to generate the URL for each page.
*/
generatePageUrl?: (page: number) => string;
};
export default function Pagination({
mobilePagination,
page,
totalPages,
loadingPage,
onPageChange,
generatePageUrl,
}: Props) {
totalPages = Math.round(totalPages);
const isLoading = loadingPage !== undefined;
const [currentPage, setCurrentPage] = useState(page);
useEffect(() => {
setCurrentPage(page);
}, [page]);
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return;
}
setCurrentPage(newPage);
onPageChange(newPage);
};
const handleLinkClick = (newPage: number, event: React.MouseEvent) => {
event.preventDefault(); // Prevent default navigation behavior
// Check if the new page is valid
if (newPage < 1 || newPage > totalPages || newPage === currentPage || isLoading) {
return;
}
handlePageChange(newPage);
};
const renderPageNumbers = () => {
const pageNumbers = [];
const maxPagesToShow = mobilePagination ? 3 : 4;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
const endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
if (startPage > 1 && !mobilePagination) {
pageNumbers.push(
<>
<PaginationItemWrapper key="start" isLoadingPage={isLoading}>
<PaginationLink href={generatePageUrl ? generatePageUrl(1) : ""} onClick={e => handleLinkClick(1, e)}>
1
</PaginationLink>
</PaginationItemWrapper>
{startPage > 2 && (
<PaginationItemWrapper key="ellipsis-start" isLoadingPage={isLoading}>
<PaginationEllipsis />
</PaginationItemWrapper>
)}
</>
);
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(
<PaginationItemWrapper key={i} isLoadingPage={isLoading}>
<PaginationLink
isActive={i === currentPage}
href={generatePageUrl ? generatePageUrl(i) : ""}
onClick={e => handleLinkClick(i, e)}
>
{loadingPage === i ? <ArrowPathIcon className="w-4 h-4 animate-spin" /> : i}
</PaginationLink>
</PaginationItemWrapper>
);
}
return pageNumbers;
};
return (
<ShadCnPagination className="select-none">
<PaginationContent>
{/* Previous button - disabled on the first page */}
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationPrevious
href={currentPage > 1 && generatePageUrl ? generatePageUrl(currentPage - 1) : ""}
onClick={e => handleLinkClick(currentPage - 1, e)}
aria-disabled={currentPage === 1}
className={clsx(currentPage === 1 && "cursor-not-allowed")}
/>
</PaginationItemWrapper>
{renderPageNumbers()}
{!mobilePagination && currentPage < totalPages && totalPages - currentPage > 2 && (
<>
<PaginationItemWrapper key="ellipsis-end" isLoadingPage={isLoading}>
<PaginationEllipsis className="cursor-default" />
</PaginationItemWrapper>
<PaginationItemWrapper key="end" isLoadingPage={isLoading}>
<PaginationLink
href={generatePageUrl ? generatePageUrl(totalPages) : ""}
onClick={e => handleLinkClick(totalPages, e)}
>
{totalPages}
</PaginationLink>
</PaginationItemWrapper>
</>
)}
{/* Next button - disabled on the last page */}
<PaginationItemWrapper isLoadingPage={isLoading}>
<PaginationNext
href={currentPage < totalPages && generatePageUrl ? generatePageUrl(currentPage + 1) : ""}
onClick={e => handleLinkClick(currentPage + 1, e)}
aria-disabled={currentPage === totalPages}
className={clsx(currentPage === totalPages && "cursor-not-allowed")}
/>
</PaginationItemWrapper>
</PaginationContent>
</ShadCnPagination>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import { scoresaberService } from "@/common/service/impl/scoresaber";
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
import { formatNumberWithCommas } from "@/common/number-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { Button } from "../ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel } from "../ui/form";
import { Input } from "../ui/input";
import { ScrollArea } from "../ui/scroll-area";
const formSchema = z.object({
username: z.string().min(3).max(50),
});
export default function SearchPlayer() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
});
const [results, setResults] = useState<ScoreSaberPlayerToken[] | undefined>();
const [loading, setLoading] = useState(false);
async function onSubmit({ username }: z.infer<typeof formSchema>) {
setLoading(true);
setResults(undefined); // Reset results
const results = await scoresaberService.searchPlayers(username);
setResults(results?.players);
setLoading(false);
}
return (
<div className="flex flex-col gap-3">
{/* Search */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex items-end gap-2">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input className="w-full sm:w-72 text-sm" placeholder="Query..." {...field} />
</FormControl>
</FormItem>
)}
/>
<Button type="submit">Search</Button>
</form>
</Form>
{/* Results */}
{loading == true && (
<div className="flex items-center justify-center">
<p>Loading...</p>
</div>
)}
{results !== undefined && (
<ScrollArea>
<div className="flex flex-col gap-1 max-h-60">
{results?.map(player => {
return (
<Link
href={`/player/${player.id}`}
key={player.id}
className="bg-secondary p-2 rounded-md flex gap-2 items-center hover:brightness-75 transition-all transform-gpu"
>
<Avatar>
<AvatarImage src={player.profilePicture} />
<AvatarFallback>{player.name.at(0)}</AvatarFallback>
</Avatar>
<div>
<p>{player.name}</p>
<p className="text-gray-400 text-sm">#{formatNumberWithCommas(player.rank)}</p>
</div>
</Link>
);
})}
</div>
</ScrollArea>
)}
</div>
);
}