Skip to content

Commit

Permalink
Merge pull request #17 from timhabermaas/move-records-page-to-nextjs
Browse files Browse the repository at this point in the history
Move records page to nextjs
  • Loading branch information
timhabermaas authored Dec 14, 2021
2 parents 958a45e + 0f3582c commit 6fd5a3b
Show file tree
Hide file tree
Showing 25 changed files with 1,097 additions and 95 deletions.
54 changes: 54 additions & 0 deletions frontend/commons/paginator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const WINDOW_SIZE = 3;

/**
* Returns the page numbers to display in the pagination component.
*
* @param current the currently selected page
* @param total the total number of pages
* @returns An array of consecutive page numbers with `null` representing gaps
*/
export function pages(current: number, total: number): (number[] | null)[] {
if (total <= WINDOW_SIZE) {
return [new Array(total).fill(null).map((_, i) => i + 1)];
}

let start = current - WINDOW_SIZE;
let end = current + WINDOW_SIZE;

if (start < 1) {
start = 1;
}

if (end > total) {
end = total;
}

let middleWindow = new Array(end - start + 1)
.fill(null)
.map((_, i) => i + start);

let startWindow = [1, 2];
let endWindow = [total - 1, total];

let result: (number[] | null)[];

if (startWindow[startWindow.length - 1] + 1 >= middleWindow[0]) {
if (endWindow[0] - 1 <= middleWindow[middleWindow.length - 1]) {
result = [makeUnique(startWindow.concat(middleWindow).concat(endWindow))];
} else {
result = [makeUnique(startWindow.concat(middleWindow)), null, endWindow];
}
} else {
if (endWindow[0] - 1 <= middleWindow[middleWindow.length - 1]) {
result = [startWindow, null, makeUnique(middleWindow.concat(endWindow))];
} else {
result = [startWindow, null, middleWindow, null, endWindow];
}
}

return result;
}

function makeUnique<T>(list: T[]): T[] {
return Array.from(new Set(list));
}
15 changes: 15 additions & 0 deletions frontend/commons/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ export const homePath: string = "/";
export function postPath(postId: number): string {
return `/posts/${postId}#comments`;
}

export function recordsPath(
puzzleSlug: string,
page?: number,
type?: string
): string {
const queryParam = new URLSearchParams({});
if (page !== undefined) {
queryParam.append("page", page.toString());
}
if (type !== undefined) {
queryParam.append("type", type);
}
return `/puzzles/${puzzleSlug}/records?${queryParam}`;
}
34 changes: 34 additions & 0 deletions frontend/commons/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Formats a time given in ISO8601 format as a long day.
*
* e.g. "October 16, 2019"
*
* @param time the time given in ISO8601
*/
export function formatDate(time: string): string {
return new Date(time).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}

/** Formats a given duration in ms to a duration using min and s. Doesn't display
* the minutes if they are 0.
*
* 10000 => 10.00s
* 60005 => 1:00.01s
*
* @param duration an integer containing the duration in ms
* @returns a formatted duration, e.g. 2:03.52min
*/
export function formatDuration(duration: number): string {
let seconds = Math.round(duration / 10) / 100;
if (seconds < 60) {
return `${seconds.toFixed(2)}s`;
} else {
const minutes = Math.floor(seconds / 60);
seconds -= minutes * 60;
const s = seconds < 10 ? "0" : "";
return `${minutes}:${s}${seconds.toFixed(2)}min`;
}
}
7 changes: 7 additions & 0 deletions frontend/commons/types/PaginatedResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
items: Array<T>;
page: number;
next_page: number | null;
total_item_count: number;
max_items_per_page: number;
}
2 changes: 2 additions & 0 deletions frontend/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { assertExhausted } from "../commons/types/assertions";

interface LayoutProps {
jwtToken?: string;
puzzleNav?: JSX.Element;
page: Page;
}

Expand Down Expand Up @@ -37,6 +38,7 @@ export const Layout: React.FC<LayoutProps> = (props) => {
<title>{pageTitle(props.page)}</title>
</Head>
<Header jwtToken={props.jwtToken} page={props.page} />
{props.puzzleNav}
<Flash />
<section id="content">
<div className="center">{props.children}</div>
Expand Down
103 changes: 103 additions & 0 deletions frontend/components/PuzzleNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Link from "next/link";
import React, { useEffect, useRef, useState } from "react";
import { usePuzzleFromUrl } from "../hooks/usePuzzleFromUrl";
import { Kind, usePuzzles } from "../hooks/usePuzzles";

function movePuzzles(puzzleEl: HTMLUListElement, index: number) {
puzzleEl.setAttribute("style", `left: ${-100 * index}%; width: 400%;`);
}

function findPuzzleIndex(kind: Kind[], puzzleSlug: string): number {
const result = kind.findIndex((k) => {
return k.puzzles.find((p) => p.slug === puzzleSlug) !== undefined;
});

return result < 0 ? 0 : result;
}

export function PuzzleNav() {
const { data } = usePuzzles();
const currentPuzzleSlug = usePuzzleFromUrl();

const [currentKind, setCurrentKind] = useState<number>(
findPuzzleIndex(data ?? [], currentPuzzleSlug)
);

const puzzlesRef = useRef<HTMLUListElement>(null);

useEffect(() => {
if (!data) {
return;
}

let kind = findPuzzleIndex(data, currentPuzzleSlug);
setCurrentKind(kind);

if (puzzlesRef.current) {
movePuzzles(puzzlesRef.current, kind);
}
}, [data, currentPuzzleSlug]);

const onKindClick = (event: React.MouseEvent, i: number) => {
event.preventDefault();

if (puzzlesRef.current) {
movePuzzles(puzzlesRef.current, i);
setCurrentKind(i);
}
};

if (data === undefined) {
return null;
}

return (
<nav id="subnavigation">
<div id="puzzles">
<ul ref={puzzlesRef} style={{ width: "400%", left: "0%" }}>
{data.map((k) => (
<li key={k.id} style={{ width: `${100 / data.length}%` }}>
<ul className="puzzles">
{k.puzzles.map((p) => (
<li
key={p.id}
className={p.slug === currentPuzzleSlug ? "checked" : ""}
>
<Link href={`/puzzles/${p.slug}/records`}>
<a>
<span className={`puzzle pos${p.css_position}`}>
<span className={`kind pos${k.css_position}`}></span>
</span>
<span className="name">{p.name}</span>
</a>
</Link>{" "}
</li>
))}
</ul>
</li>
))}
</ul>
</div>
<div id="kinds">
<ul className="center">
{data.map((kind, i) => (
<li
key={kind.id}
style={{ width: `${100 / data.length}%` }}
className={i == currentKind ? "checked" : ""}
>
<a
href="#"
onClick={(e) => {
onKindClick(e, i);
}}
>
{kind.name}
</a>
</li>
))}
</ul>
</div>
</nav>
);
}
24 changes: 24 additions & 0 deletions frontend/hooks/useBlockUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from "react-query";

export function useBlockUser(jwtToken?: string) {
const queryClient = useQueryClient();

return useMutation(
async (slug: string) => {
const headers = new Headers();
if (jwtToken) {
headers.set("Authorization", `Bearer ${jwtToken}`);
}
const r = await fetch(`/api/users/${slug}/block`, {
headers,
method: "PUT",
});
return await r.json();
},
{
onSuccess: () => {
queryClient.invalidateQueries("records");
},
}
);
}
38 changes: 27 additions & 11 deletions frontend/hooks/useMe.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import { useQuery } from "react-query";
import { useQuery, useQueryClient } from "react-query";

export type Role = "admin" | "moderator" | "user";

interface MeResponse {
current_user?: { name: string; slug: string };
current_user?: {
name: string;
slug: string;
role: Role;
};
}

export function useMe(jwtToken?: string) {
return useQuery<MeResponse, Error>(["me"], async () => {
const headers = new Headers();
if (jwtToken) {
headers.set("Authorization", `Bearer ${jwtToken}`);
const queryClient = useQueryClient();

return useQuery<MeResponse, Error>(
["me"],
async () => {
const headers = new Headers();
if (jwtToken) {
headers.set("Authorization", `Bearer ${jwtToken}`);
}
const r = await fetch("/api/me", {
headers,
});
return await r.json();
},
{
onSuccess: () => {
queryClient.invalidateQueries("records");
},
}
const r = await fetch("/api/me", {
headers,
});
return await r.json();
});
);
}
7 changes: 7 additions & 0 deletions frontend/hooks/useMyRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Role, useMe } from "./useMe";

export function useMyRole(jwtToken?: string): Role | undefined {
const { data } = useMe(jwtToken);

return data && data.current_user?.role;
}
10 changes: 10 additions & 0 deletions frontend/hooks/usePuzzleFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useSingleQueryParam } from "./useSingleQueryParam";

export function usePuzzleFromUrl(): string {
const q = useSingleQueryParam("puzzleId");
if (q === null) {
throw new Error("routing error");
} else {
return q;
}
}
26 changes: 26 additions & 0 deletions frontend/hooks/usePuzzles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useQuery } from "react-query";

export interface Puzzle {
css_position: number;
id: number;
name: string;
slug: string;
}

export interface Kind {
id: number;
name: string;
css_position: number;
puzzles: Puzzle[];
}

export function usePuzzles() {
return useQuery<Kind[]>(
"allPuzzles",
async () => {
const response = await fetch("/api/puzzles");
return await response.json();
},
{ staleTime: Infinity }
);
}
6 changes: 6 additions & 0 deletions frontend/hooks/useRecordTypeFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { RecordTypes } from "../models/recordTypes";
import { useSingleQueryParam } from "./useSingleQueryParam";

export function useRecordTypeFromUrl(): string {
return useSingleQueryParam("type") ?? RecordTypes[1].short;
}
29 changes: 29 additions & 0 deletions frontend/hooks/useRecords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useQuery } from "react-query";
import { PaginatedResponse } from "../commons/types/PaginatedResponse";

export interface Record {
id: number;
rank: number;
time: number;
comment: string;
set_at: string;
user_name: string;
user_slug: string;
}

interface RecordResponse {
records: PaginatedResponse<Record>;
}

export function useRecords(type: string, page: number, puzzleSlug: string) {
return useQuery<RecordResponse>(
["records", puzzleSlug, type, page],
async () => {
let response = await fetch(
`/api/records?type=${type}&page=${page}&puzzle_slug=${puzzleSlug}`
);
return await response.json();
},
{ keepPreviousData: true }
);
}
Loading

0 comments on commit 6fd5a3b

Please sign in to comment.