- {Array.isArray(tags) && tags.map(tag => )}
+ {Array.isArray(tags) && tags.map(tag => )}
{children}
diff --git a/apps/changelog/src/client/components/date.tsx b/apps/changelog/src/client/components/date.tsx
index 458a23ba4d449..75f59824ddc50 100644
--- a/apps/changelog/src/client/components/date.tsx
+++ b/apps/changelog/src/client/components/date.tsx
@@ -1,11 +1,10 @@
const formatDate = (date: string | Date) => {
- const options: Intl.DateTimeFormatOptions = {
+ const now = new Date(date).toLocaleDateString('en-EN', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC'
- };
- const now = new Date(date).toLocaleDateString('en-EN', options);
+ });
return now;
};
diff --git a/apps/changelog/src/client/components/list.tsx b/apps/changelog/src/client/components/list.tsx
index d8d44d6673225..c981f577a49b5 100644
--- a/apps/changelog/src/client/components/list.tsx
+++ b/apps/changelog/src/client/components/list.tsx
@@ -1,73 +1,108 @@
'use client';
-import {useEffect, useState} from 'react';
-import {type Category, type Changelog} from '@prisma/client';
-import Link from 'next/link';
-import {usePathname, useRouter, useSearchParams} from 'next/navigation';
+import type {Category} from '@prisma/client';
import {MDXRemote, MDXRemoteSerializeResult} from 'next-mdx-remote';
+import Link from 'next/link';
+import {parseAsArrayOf, parseAsInteger, parseAsString, useQueryState} from 'nuqs';
+import {Fragment} from 'react';
import Article from './article';
-import Tag from './tag';
-import Pagination from './pagination';
+import {Pagination} from './pagination';
+import {CategoryTag} from './tag';
const ENTRIES_PER_PAGE = 10;
-type EnhancedChangelog = Changelog & {
+export type ChangelogEntry = {
+ id: string;
+ title: string;
+ slug: string;
+ summary?: string;
+ image?: string | null | undefined;
+ publishedAt: string; // Dates are passed to client components serialized as strings
categories: Category[];
mdxSummary: MDXRemoteSerializeResult;
};
-export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[]}) {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
+/**
+ * Turns a date into this format: "August 2024"
+ * Which is the format we use in query params and to filter.
+ */
+function changelogEntryPublishDateToAddressableTag(date: Date) {
+ return date.toLocaleString('en-EN', {
+ month: 'long',
+ year: 'numeric',
+ timeZone: 'UTC',
+ });
+}
+
+export function ChangelogList({changelogs}: {changelogs: ChangelogEntry[]}) {
+ const [searchValue, setSearchValue] = useQueryState('search', parseAsString);
- const [pageNumber, setPageNumber] = useState(1);
- const [searchValue, setSearchValue] = useState('');
- const [selectedCategoriesIds, setSelectedCategoriesIds] = useState
(
- searchParams?.get('categories')?.split(',') || []
+ const [monthAndYearParam, setMonthParam] = useQueryState('month');
+ const [selectedCategoriesIds, setSelectedCategoriesIds] = useQueryState(
+ 'categories',
+ parseAsArrayOf(parseAsString).withDefault([]).withOptions({clearOnDefault: true})
);
- const [selectedMonth, setSelectedMonth] = useState(
- searchParams?.get('month') || null
+ const [pageParam, setPageParam] = useQueryState(
+ 'page',
+ parseAsInteger.withDefault(1).withOptions({clearOnDefault: true})
);
- useEffect(() => {
- const params: string[] = [];
- if (selectedCategoriesIds.length > 0) {
- params.push(`categories=${selectedCategoriesIds.join(',')}`);
- }
- if (selectedMonth) {
- params.push(`month=${selectedMonth}`);
- }
- router.push(pathname + '?' + params.join('&'));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedCategoriesIds, selectedMonth]);
+ const selectedPage = pageParam === null ? 1 : pageParam;
- const filtered = selectedCategoriesIds.length || searchValue || selectedMonth;
+ const someFilterIsActive = Boolean(
+ selectedCategoriesIds.length > 0 || searchValue || monthAndYearParam
+ );
+
+ const filteredChangelogsWithoutMonthFilter = changelogs
+ // First filter by categories
+ .filter(changelog => {
+ if (selectedCategoriesIds.length === 0) {
+ // If no categories are selected we don't filter anything
+ return true;
+ }
+
+ return changelog.categories.some(changelogCategory =>
+ selectedCategoriesIds.includes(changelogCategory.id)
+ );
+ })
+
+ .filter(changelog => {
+ if (searchValue === null) {
+ return true;
+ }
+
+ const addressableDate = changelogEntryPublishDateToAddressableTag(
+ new Date(changelog.publishedAt)
+ );
- const filteredChangelogs = changelogs
- .filter((changelog: EnhancedChangelog) => {
// map all categories to a string
- const categories = changelog.categories
+ const concatenatedCategories = changelog.categories
.map((category: Category) => category.name)
.join(' ');
- const postDate = new Date(changelog.publishedAt || '');
- const postMonthYear = postDate.toLocaleString('default', {
- month: 'long',
- year: 'numeric',
- });
+ const searchableContent =
+ changelog.title + changelog.summary + concatenatedCategories + addressableDate;
- const searchContent = changelog.title + changelog.summary + categories;
- return (
- searchContent.toLowerCase().includes(searchValue.toLowerCase()) &&
- (!selectedCategoriesIds.length ||
- selectedCategoriesIds.some(catId =>
- changelog.categories.some(changelogCategory => changelogCategory.id === catId)
- )) &&
- (!selectedMonth || selectedMonth === postMonthYear)
- );
+ return searchableContent.toLowerCase().includes(searchValue.toLowerCase());
})
- .slice(ENTRIES_PER_PAGE * (pageNumber - 1), ENTRIES_PER_PAGE * pageNumber);
+ .sort((a, b) => {
+ return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
+ });
+
+ const filteredChangelogs = filteredChangelogsWithoutMonthFilter
+ // Filter by selected date
+ .filter(changelog => {
+ if (monthAndYearParam == null) {
+ // If no date was selected we don't filter anything
+ return true;
+ }
+
+ const addressableDate = changelogEntryPublishDateToAddressableTag(
+ new Date(changelog.publishedAt)
+ );
+
+ return monthAndYearParam === addressableDate;
+ });
const allChangelogCategories: Record = {};
changelogs.forEach(changelog => {
@@ -76,62 +111,62 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[
});
});
- // iterate over all posts and create a list of months & years
- const months = changelogs.reduce((allMonths, post) => {
- const date = new Date(post.publishedAt || '');
- const year = date.getFullYear();
- const month = date.toLocaleString('default', {
- month: 'long',
- });
- const dateMonthYear = `${month} ${year}`;
- return [...new Set([...allMonths, dateMonthYear])];
- }, [] as string[]);
+ // contains dates in the format "August 2024"
+ const datesGroupedByMonthAndYear = new Set();
+ changelogs.forEach(changelog => {
+ if (changelog.publishedAt === null) {
+ throw new Error('invariant');
+ }
- const monthsCopy = [...months];
+ datesGroupedByMonthAndYear.add(
+ changelogEntryPublishDateToAddressableTag(new Date(changelog.publishedAt))
+ );
+ });
- const showChangelogs = filteredChangelogs.map(changelog => {
- const monthYear = new Date(changelog.publishedAt || '').toLocaleString('default', {
- month: 'long',
- year: 'numeric',
- });
+ const sortedDatesGroupedByMonthAndYear = [...datesGroupedByMonthAndYear].sort(
+ (a, b) => new Date(b).getTime() - new Date(a).getTime()
+ );
- const monthYearIndex = months.indexOf(monthYear);
- if (monthYearIndex > -1) {
- // remove first entry from months array
- months.splice(monthYearIndex, 1);
- }
+ const paginatedChangelogs = filteredChangelogs
+ .slice(ENTRIES_PER_PAGE * (selectedPage - 1), ENTRIES_PER_PAGE * selectedPage)
+ .map((changelog, i, arr) => {
+ const monthYear = changelogEntryPublishDateToAddressableTag(
+ new Date(changelog.publishedAt)
+ );
- const monthSeparator = (
-
- );
+ const prevChangelog: ChangelogEntry | undefined = arr[i - 1];
+ const prevChangelogHasDifferentMonth =
+ !prevChangelog ||
+ changelogEntryPublishDateToAddressableTag(new Date(prevChangelog.publishedAt)) !==
+ changelogEntryPublishDateToAddressableTag(new Date(changelog.publishedAt));
- return (
-
- {monthYearIndex > -1 && monthSeparator}
- category.name)}
- image={changelog.image}
- >
-
-
-
- );
- });
+ return (
+
+ {prevChangelogHasDifferentMonth && (
+
+ )}
+
+ category.name)}
+ image={changelog.image}
+ >
+
+
+
+
+ );
+ });
- const pagination = {
- totalPages: Math.ceil(
- (filtered ? filteredChangelogs.length : changelogs.length) / ENTRIES_PER_PAGE
- ),
- };
+ const numberOfPages = Math.ceil(filteredChangelogs.length / ENTRIES_PER_PAGE);
return (
@@ -140,24 +175,25 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[
@@ -168,18 +204,22 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[
setSearchValue(e.target.value)}
+ value={searchValue ?? ''}
+ onChange={e => {
+ setPageParam(null);
+ setSearchValue(e.target.value ? e.target.value : null);
+ }}
placeholder="Search..."
className="form-input flex-1 rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500"
/>
- {showChangelogs}
+ {paginatedChangelogs}
- {pagination.totalPages > 1 && (
+ {numberOfPages > 1 && (
{
+ setPageParam(pageNumber, {history: 'push'});
+ }}
+ search={searchValue}
+ selectedMonth={monthAndYearParam}
+ selectedCategoriesIds={selectedCategoriesIds}
/>
)}
- {!filteredChangelogs.length && (
+ {paginatedChangelogs.length === 0 && (
No posts found.
@@ -209,23 +254,35 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[
diff --git a/apps/changelog/src/client/components/pagination.tsx b/apps/changelog/src/client/components/pagination.tsx
index 22da8932d408d..a5d8a261b5ad2 100644
--- a/apps/changelog/src/client/components/pagination.tsx
+++ b/apps/changelog/src/client/components/pagination.tsx
@@ -1,17 +1,31 @@
'use client';
-import {Fragment} from 'react';
+import Link from 'next/link';
+import {createSerializer, parseAsArrayOf, parseAsInteger, parseAsString} from 'nuqs';
-export default function Pagination({
+const serialize = createSerializer({
+ month: parseAsString,
+ categories: parseAsArrayOf(parseAsString),
+ page: parseAsInteger.withDefault(1).withOptions({clearOnDefault: true}),
+ search: parseAsString,
+});
+
+export function Pagination({
totalPages,
currentPage,
- setPageNumber,
+ onPageNumberChange,
+ selectedMonth,
+ search,
+ selectedCategoriesIds,
}: {
totalPages: number;
currentPage: number;
- setPageNumber: (pageNumber: number) => void;
+ selectedCategoriesIds: string[];
+ selectedMonth: string | null;
+ search: string | null;
+ onPageNumberChange: (pageNumber: number) => void;
}) {
- const prevPage = currentPage - 1 > 0;
- const nextPage = currentPage + 1 <= parseInt(totalPages.toString(), 10);
+ const navigationToPrevPageAllowed = currentPage - 1 > 0;
+ const navigationToNextPageAllowed = currentPage + 1 <= totalPages;
const pages: Array = [];
let pushedMiddle = false;
@@ -27,12 +41,20 @@ export default function Pagination({
return (
setPageNumber(Math.max(currentPage - 1, 1))}
- condition={prevPage}
+ href={serialize({
+ month: selectedMonth,
+ categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds,
+ page: Math.max(currentPage - 1, 1),
+ search,
+ })}
+ onClick={(e: MouseEvent) => {
+ e.preventDefault();
+ onPageNumberChange(Math.max(currentPage - 1, 1));
+ }}
+ condition={navigationToPrevPageAllowed}
>
setPageNumber(currentPage + 1)}
- condition={nextPage}
+ href={serialize({
+ month: selectedMonth,
+ categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds,
+ page: currentPage + 1,
+ search,
+ })}
+ onClick={(e: MouseEvent) => {
+ e.preventDefault();
+ onPageNumberChange(currentPage + 1);
+ }}
+ condition={navigationToNextPageAllowed}
>