feat: implement ArticleList component with pagination and loading skeleton
This commit is contained in:
@@ -1,94 +1,18 @@
|
|||||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
import { ArticleList } from '@/ui/components/internal/article/article-list';
|
||||||
import { ArticleCard } from '@/ui/components/internal/article-card';
|
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
||||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
|
||||||
import { FileTextIcon } from 'lucide-react';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const PAGE_SIZE = 9;
|
|
||||||
|
|
||||||
const ArticleCardSkeleton = () => (
|
|
||||||
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
|
|
||||||
<div className='aspect-video w-full animate-pulse bg-muted' />
|
|
||||||
<div className='flex flex-1 flex-col gap-3 p-5'>
|
|
||||||
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='space-y-1.5'>
|
|
||||||
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 space-y-1.5'>
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ArticleListSkeleton = () => (
|
|
||||||
<>
|
|
||||||
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
|
||||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
|
||||||
<ArticleCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
type ArticleListProps = {
|
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ArticleList = async ({ searchParams }: ArticleListProps) => {
|
|
||||||
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
|
||||||
const pageSize = Number(pageSizeParam) || PAGE_SIZE;
|
|
||||||
|
|
||||||
const paginationResult = await getArticlesPaginated(page, pageSize);
|
|
||||||
if (!paginationResult.ok) throw paginationResult.error;
|
|
||||||
const { data: articles, totalPages, total } = paginationResult.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className='mb-10 text-muted-foreground'>
|
|
||||||
{total === 0
|
|
||||||
? 'No articles published yet.'
|
|
||||||
: `${total} article${total === 1 ? '' : 's'} published`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{articles.length === 0 ? (
|
|
||||||
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
|
||||||
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
No articles yet. Check back soon!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
|
||||||
{articles.map((article) => (
|
|
||||||
<ArticleCard
|
|
||||||
key={article.externalId}
|
|
||||||
article={article}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className='mt-12 flex items-center justify-between gap-4'>
|
|
||||||
<p className='text-sm text-muted-foreground'>
|
const PAGE_SIZE = 4;
|
||||||
Page {page} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<ArticleListPagination
|
|
||||||
currentPage={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type HomeProps = {
|
type HomeProps = {
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||||
@@ -105,8 +29,13 @@ const Home = async ({ searchParams }: HomeProps) => {
|
|||||||
Latest Articles
|
Latest Articles
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={<ArticleListSkeleton />}>
|
<Suspense
|
||||||
<ArticleList searchParams={searchParams} />
|
fallback={<ArticleListSkeleton skeletonSize={PAGE_SIZE} />}
|
||||||
|
>
|
||||||
|
<ArticleList
|
||||||
|
searchParams={searchParams}
|
||||||
|
defaultPageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const getArticlesPaginated: (
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
pageSize: number = 10
|
pageSize: number = 10
|
||||||
): Promise<PaginatedArticlesResult> => {
|
): Promise<PaginatedArticlesResult> => {
|
||||||
|
// await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
const result = await service.getArticlesPaginated(page, pageSize);
|
const result = await service.getArticlesPaginated(page, pageSize);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
return result.value;
|
return result.value;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { TypedResult, wrap } from '@/utils/types/results';
|
|||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
ObjectCannedACL,
|
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ export const getPublicUrl = async (
|
|||||||
if (!storageProvider) {
|
if (!storageProvider) {
|
||||||
storageProvider = storage;
|
storageProvider = storage;
|
||||||
}
|
}
|
||||||
const session = await getSessionData();
|
|
||||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
|
||||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
|
||||||
}
|
|
||||||
return await storageProvider.get(key);
|
return await storageProvider.get(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { getSessionData } from '@/lib/session/session-storage';
|
|||||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const isPublic = createRouteMatcher([
|
const isPublic = createRouteMatcher([
|
||||||
'/home(.*)?',
|
'/home(.*)?',
|
||||||
'/about(.*)?',
|
'/about(.*)?',
|
||||||
|
'/article(.*)?',
|
||||||
'/api/user(.*)?',
|
'/api/user(.*)?',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const ArticleCardSkeleton = () => (
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
|
||||||
|
<div className='aspect-video w-full animate-pulse bg-muted' />
|
||||||
|
<div className='flex flex-1 flex-col gap-3 p-5'>
|
||||||
|
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 space-y-1.5'>
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArticleListSkeleton = ({
|
||||||
|
skeletonSize,
|
||||||
|
}: {
|
||||||
|
skeletonSize: number;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
{Array.from({ length: skeletonSize }).map((_, i) => (
|
||||||
|
<ArticleCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
87
src/ui/components/internal/article/article-list.tsx
Normal file
87
src/ui/components/internal/article/article-list.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// 'use client';
|
||||||
|
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||||
|
import { ArticleCard } from '@/ui/components/internal/article-card';
|
||||||
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type ArticleListProps = {
|
||||||
|
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||||
|
defaultPageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleList = async ({
|
||||||
|
searchParams,
|
||||||
|
defaultPageSize,
|
||||||
|
}: ArticleListProps) => {
|
||||||
|
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
const pageSize = Number(pageSizeParam) || defaultPageSize;
|
||||||
|
|
||||||
|
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||||
|
|
||||||
|
if (!paginationResult.ok) {
|
||||||
|
return (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Failed to load articles. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data: articles, totalPages, total } = paginationResult.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className='mb-10 text-muted-foreground'>
|
||||||
|
{total === 0
|
||||||
|
? 'No articles published yet.'
|
||||||
|
: `${total} article${total === 1 ? '' : 's'} published`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{articles.length === 0 ? (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
No articles yet. Check back soon!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
{articles.map((article) => (
|
||||||
|
<ArticleCard
|
||||||
|
key={article.externalId}
|
||||||
|
article={article}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='mt-12 flex items-center justify-between gap-4'>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<ArticleListPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user