Skip to content

Commit

Permalink
feat: pagination on the item page for comments + time-ago filter (#257)
Browse files Browse the repository at this point in the history
* adding time-ago filter for items

* simplify code for item page

* creating CommentInput element

* pagination for comments in item page

* extract page selector

* better

* time ago selector to the top

---------

Co-authored-by: Asher Gomez <[email protected]>
  • Loading branch information
brunocorrea23 and iuioiua authored Jun 19, 2023
1 parent 5e19532 commit 0a80be0
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 70 deletions.
28 changes: 28 additions & 0 deletions components/PageSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.

export default function PageSelector(
props: { currentPage: number; lastPage: number; timeSelector?: string },
) {
return (
<div class="flex justify-center py-4 mx-auto">
<form class="inline-flex items-center gap-x-2">
{props.timeSelector &&
<input type="hidden" name="time-ago" value={props.timeSelector} />}
<input
id="current_page"
class={`bg-transparent rounded rounded-lg outline-none w-full border-1 border-gray-500 hover:border-black transition duration-300 disabled:(opacity-50 cursor-not-allowed) rounded-md px-2 py-1 dark:(hover:border-white)`}
type="number"
name="page"
min="1"
max={props.lastPage}
value={props.currentPage}
// @ts-ignore: this is valid HTML
onchange="this.form.submit()"
/>
<label for="current_page" class="whitespace-nowrap align-middle">
of {props.lastPage}
</label>
</form>
</div>
);
}
47 changes: 18 additions & 29 deletions routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,42 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import type { Handlers, PageProps } from "$fresh/server.ts";
import { SITE_WIDTH_STYLES } from "@/utils/constants.ts";
import { calcLastPage, calcPageNum, PAGE_LENGTH } from "@/utils/pagination.ts";
import Layout from "@/components/Layout.tsx";
import Head from "@/components/Head.tsx";
import type { State } from "./_middleware.ts";
import ItemSummary from "@/components/ItemSummary.tsx";
import PageSelector from "@/components/PageSelector.tsx";
import {
compareScore,
getAllItemsInPastWeek,
getAllItemsInTimeAgo,
getAreVotedBySessionId,
getManyUsers,
incrementAnalyticsMetricPerDay,
type Item,
type User,
} from "@/utils/db.ts";

const PAGE_LENGTH = 10;

interface HomePageData extends State {
itemsUsers: User[];
items: Item[];
lastPage: number;
areVoted: boolean[];
}

function calcPageNum(url: URL) {
return parseInt(url.searchParams.get("page") || "1");
}

function calcLastPage(total = 0, pageLength = PAGE_LENGTH): number {
return Math.ceil(total / pageLength);
function calcTimeAgoFilter(url: URL) {
return url.searchParams.get("time-ago") || "";
}

export const handler: Handlers<HomePageData, State> = {
async GET(req, ctx) {
await incrementAnalyticsMetricPerDay("visits_count", new Date());

const pageNum = calcPageNum(new URL(req.url));
const allItems = await getAllItemsInPastWeek();
const url = new URL(req.url);
const timeAgo = calcTimeAgoFilter(url);
const pageNum = calcPageNum(url);
const allItems = await getAllItemsInTimeAgo(timeAgo);

const items = allItems
.toSorted(compareScore)
.slice((pageNum - 1) * PAGE_LENGTH, pageNum * PAGE_LENGTH);
Expand All @@ -54,25 +53,13 @@ export const handler: Handlers<HomePageData, State> = {
},
};

function PageSelector(props: { currentPage: number; lastPage: number }) {
function TimeSelector() {
return (
<div class="flex justify-center py-4 mx-auto">
<form class="inline-flex items-center gap-x-2">
<input
id="current_page"
class={`bg-transparent rounded rounded-lg outline-none w-full border-1 border-gray-500 hover:border-black transition duration-300 disabled:(opacity-50 cursor-not-allowed) rounded-md px-2 py-1 dark:(hover:border-white)`}
type="number"
name="page"
min="1"
max={props.lastPage}
value={props.currentPage}
// @ts-ignore: this is valid HTML
onchange="this.form.submit()"
/>
<label for="current_page" class="whitespace-nowrap align-middle">
of {props.lastPage}
</label>
</form>
<div class="flex justify-center">
{/* These links do not preserve current URL queries. E.g. if ?page=2, that'll be removed once one of these links is clicked */}
<a class="hover:underline mr-4" href="/?time-ago=week">Last Week</a>
<a class="hover:underline mr-4" href="/?time-ago=month">Last Month</a>
<a class="hover:underline mr-4" href="/?time-ago=all">All time</a>
</div>
);
}
Expand All @@ -83,6 +70,7 @@ export default function HomePage(props: PageProps<HomePageData>) {
<Head href={props.url.href} />
<Layout session={props.data.sessionId}>
<div class={`${SITE_WIDTH_STYLES} flex-1 px-4`}>
<TimeSelector />
{props.data.items.map((item, index) => (
<ItemSummary
item={item}
Expand All @@ -94,6 +82,7 @@ export default function HomePage(props: PageProps<HomePageData>) {
<PageSelector
currentPage={calcPageNum(props.url)}
lastPage={props.data.lastPage}
timeSelector={calcTimeAgoFilter(props.url)}
/>
)}
</div>
Expand Down
94 changes: 59 additions & 35 deletions routes/item/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@ import type { State } from "@/routes/_middleware.ts";
import Layout from "@/components/Layout.tsx";
import Head from "@/components/Head.tsx";
import ItemSummary from "@/components/ItemSummary.tsx";
import PageSelector from "@/components/PageSelector.tsx";
import {
BUTTON_STYLES,
INPUT_STYLES,
SITE_WIDTH_STYLES,
} from "@/utils/constants.ts";
import { calcLastPage, calcPageNum, PAGE_LENGTH } from "@/utils/pagination.ts";
import {
type Comment,
createComment,
getAreVotedBySessionId,
getCommentsByItem,
getItemById,
getManyUsers,
getUserById,
getUserBySessionId,
getVotedItemsByUser,
type Item,
type User,
} from "@/utils/db.ts";
Expand All @@ -32,34 +34,37 @@ interface ItemPageData extends State {
comments: Comment[];
commentsUsers: User[];
isVoted: boolean;
lastPage: number;
}

export const handler: Handlers<ItemPageData, State> = {
async GET(_req, ctx) {
async GET(req, ctx) {
const { id } = ctx.params;

const url = new URL(req.url);
const pageNum = calcPageNum(url);

const item = await getItemById(id);
if (item === null) {
return ctx.renderNotFound();
}

const comments = await getCommentsByItem(id);
const allComments = await getCommentsByItem(id);
const comments = allComments
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice((pageNum - 1) * PAGE_LENGTH, pageNum * PAGE_LENGTH);

const commentsUsers = await getManyUsers(
comments.map((comment) => comment.userId),
);
const user = await getUserById(item.userId);

let votedItemIds: string[] = [];
if (ctx.state.sessionId) {
const sessionUser = await getUserBySessionId(ctx.state.sessionId);

if (sessionUser) {
const votedItems = await getVotedItemsByUser(sessionUser?.id);
votedItemIds = votedItems.map((item) => item.id);
}
}
const [isVoted] = await getAreVotedBySessionId(
[item],
ctx.state.sessionId,
);

const isVoted = votedItemIds.includes(id);
const lastPage = calcLastPage(allComments.length, PAGE_LENGTH);

return ctx.render({
...ctx.state,
Expand All @@ -68,6 +73,7 @@ export const handler: Handlers<ItemPageData, State> = {
user: user!,
commentsUsers,
isVoted,
lastPage,
});
},
async POST(req, ctx) {
Expand All @@ -94,6 +100,34 @@ export const handler: Handlers<ItemPageData, State> = {
},
};

function CommentInput() {
return (
<form method="post">
<textarea
class={INPUT_STYLES}
type="text"
name="text"
required
/>
<button type="submit" class={BUTTON_STYLES}>Comment</button>
</form>
);
}

function CommentSummary(
props: { user: User; comment: Comment },
) {
return (
<div class="py-4">
<UserPostedAt
user={props.user}
createdAt={props.comment.createdAt}
/>
<p>{props.comment.text}</p>
</div>
);
}

export default function ItemPage(props: PageProps<ItemPageData>) {
return (
<>
Expand All @@ -105,31 +139,21 @@ export default function ItemPage(props: PageProps<ItemPageData>) {
isVoted={props.data.isVoted}
user={props.data.user}
/>
<form method="post">
<textarea
class={INPUT_STYLES}
type="text"
name="text"
required
/>
<button type="submit" class={BUTTON_STYLES}>Comment</button>
</form>
<CommentInput />
<div>
<h2 class="font-bold">
{pluralize(props.data.comments.length, "comment")}
</h2>
{props.data.comments.sort((a, b) =>
b.createdAt.getTime() - a.createdAt.getTime()
).map((comment, index) => (
<div class="py-4">
<UserPostedAt
user={props.data.commentsUsers[index]}
createdAt={comment.createdAt}
/>
<p>{comment.text}</p>
</div>
{props.data.comments.map((comment, index) => (
<CommentSummary
user={props.data.commentsUsers[index]}
comment={comment}
/>
))}
</div>
{props.data.lastPage > 1 && (
<PageSelector
currentPage={calcPageNum(props.url)}
lastPage={props.data.lastPage}
/>
)}
</div>
</Layout>
</>
Expand Down
24 changes: 18 additions & 6 deletions utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { WEEK } from "std/datetime/constants.ts";
import { DAY, WEEK } from "std/datetime/constants.ts";

export const kv = await Deno.openKv();

Expand Down Expand Up @@ -63,11 +63,23 @@ export async function createItem(initItem: InitItem) {
return item;
}

export async function getAllItemsInPastWeek() {
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - WEEK],
});
export async function getAllItemsInTimeAgo(timeAgo: string) {
switch (timeAgo) {
case "month":
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - DAY * 30],
});
case "all":
return await getValues<Item>({
prefix: ["items_by_time"],
});
default:
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - WEEK],
});
}
}

export async function getItemById(id: string) {
Expand Down
12 changes: 12 additions & 0 deletions utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.

// This should not exceed 10 since denoKV Kv.getMany can't handle as input an array with more than 10 elements
export const PAGE_LENGTH = 10;

export function calcPageNum(url: URL) {
return parseInt(url.searchParams.get("page") || "1");
}

export function calcLastPage(total = 0, pageLength = PAGE_LENGTH): number {
return Math.ceil(total / pageLength);
}
23 changes: 23 additions & 0 deletions utils/pagination_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { calcLastPage, calcPageNum } from "./pagination.ts";
import { assertEquals } from "std/testing/asserts.ts";

Deno.test("[pagination] calcPageNum()", () => {
assertEquals(calcPageNum(new URL("https://saaskit.deno.dev/")), 1);
assertEquals(calcPageNum(new URL("https://saaskit.deno.dev/?page=2")), 2);
assertEquals(
calcPageNum(new URL("https://saaskit.deno.dev/?time-ago=month")),
1,
);
assertEquals(
calcPageNum(new URL("https://saaskit.deno.dev/?time-ago=month&page=3")),
3,
);
});

Deno.test("[pagination] calcLastPage()", () => {
assertEquals(calcLastPage(1, 10), 1);
assertEquals(calcLastPage(15, 10), 2);
assertEquals(calcLastPage(11, 20), 1);
assertEquals(calcLastPage(50, 20), 3);
});

0 comments on commit 0a80be0

Please sign in to comment.