feat: add createdAt and updatedAt fields to Article model and implement article pagination with new components

This commit is contained in:
2026-04-10 23:19:14 -03:00
parent 4b1bd056fc
commit 792627b0f0
7 changed files with 292 additions and 5 deletions

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
npm run dev # Start dev server on :3000
npm run build # Production build
npm run start # Start production server
# Code quality
npm run lint # ESLint check
npm run lint:fix # Auto-fix ESLint issues
npm run format # Prettier check
npm run format:fix # Auto-format with Prettier
# Testing
npm test # Run all Jest tests
npm test -- --testPathPattern=user.service # Run a single test file
# Database migrations (TypeORM)
npm run typeorm:migration # Run pending migrations
npm run typeorm:migration:down # Revert last migration
npm run typeorm:create --name=<migration-name> # Create a new migration file
# Local infrastructure (PostgreSQL + S3-compatible storage)
docker compose -f docker/docker-compose.yml up -d
```
## Architecture
This is a **Next.js 16 App Router** full-stack blog with TypeScript. The structure separates concerns into three main layers:
### Frontend (`src/app/` + `src/ui/`)
- **Pages** live in `src/app/(pages)/` using route groups
- **API routes** live in `src/app/api/` (Next.js Route Handlers)
- **Components** in `src/ui/components/internal/` (custom) and `src/ui/components/shadcn/` (shadcn/ui generated)
- **Providers** in `src/ui/providers/` wrap the app with React Query, Jotai, and Clerk
- Root layout at `src/app/layout.tsx` mounts all providers; `/` redirects to `/home` via `next.config.ts`
### Backend (`src/lib/`)
Domain-driven feature modules — each feature lives in `src/lib/feature/<feature>/` with:
- `*.entity.ts` — TypeORM entity definition
- `*.model.ts` — DTOs and interfaces (Zod schemas live here too)
- `*.service.ts` — Business logic (CRUD, validation, no HTTP concerns)
- `*.external.ts` — External integrations (Clerk, etc.)
Current features: `user/`, `article/`
### Key Integrations
- **Auth:** Clerk (`@clerk/nextjs`) handles auth. On sign-in, `/api/user/sync` syncs the Clerk user into PostgreSQL via `user.service.ts`. Sessions are stored server-side via `iron-session` (`src/lib/session/`).
- **Database:** PostgreSQL 16 via TypeORM. `src/lib/db/data-source.ts` is used at runtime; `src/lib/db/data-source.cli.ts` is used by the TypeORM CLI. Entities are exported from `src/lib/db/entities.ts`.
- **Storage:** `src/lib/storage/` provides a `StorageProvider` interface with an `S3StorageAdapter` implementation (AWS SDK v3). The factory in `storage.factory.ts` selects the provider; `storage.ts` exports the singleton. `put()` returns a presigned URL.
- **Migrations:** SQL migrations in `migrations/` directory. Run via TypeORM CLI, not auto-run.
### State management
- **Server state:** React Query (`@tanstack/react-query`) for async data fetching
- **Client state:** Jotai atoms for local UI state
- **Forms:** React Hook Form + Zod validation
### Testing
- Jest with `ts-jest`; tests in `tests/` mirroring `src/lib/` structure
- PostgreSQL integration tests use `@testcontainers/postgresql` (requires Docker)
- S3 tests use `aws-sdk-client-mock`
### Local environment
Docker Compose starts PostgreSQL on port **5332** and Rustfs (S3-compatible) on ports **9000/9001**. Environment variables are in `.env`; key ones: `DATABASE_URL`, `SESSION_SECRET`, `CLERK_*`, `S3_*`.

View File

@@ -1,10 +1,62 @@
import { siteConfig } from '@/site.config'; 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';
const PAGE_SIZE = 9;
interface HomeProps {
searchParams: Promise<{ page?: string }>;
}
const Home = async ({ searchParams }: HomeProps) => {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const { data: articles, totalPages, total } = await getArticlesPaginated(page, PAGE_SIZE);
const Home = async () => {
return ( return (
<div className='flex flex-col items-center justify-center'> <div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
<h1 className='mb-4 text-4xl font-bold'>Home</h1> <div className='mb-10 border-b border-border pb-8'>
<p className='text-lg'>Welcome {siteConfig.name}!</p> <p className='mb-1 font-mono text-xs font-medium uppercase tracking-widest text-muted-foreground'>
Dev Blog
</p>
<h1 className='text-3xl font-bold tracking-tight md:text-4xl'>
Latest Articles
</h1>
<p className='mt-2 text-muted-foreground'>
{total === 0
? 'No articles published yet.'
: `${total} article${total === 1 ? '' : 's'} published`}
</p>
</div>
{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>
)}
</div> </div>
); );
}; };

View File

@@ -29,6 +29,8 @@ export const ArticleModel = z.object({
content: z.string(), content: z.string(),
authorId: z.string(), authorId: z.string(),
externalId: z.uuid(), externalId: z.uuid(),
createdAt: z.date(),
updatedAt: z.date(),
}); });
export type ArticleModel = z.infer<typeof ArticleModel>; export type ArticleModel = z.infer<typeof ArticleModel>;

View File

@@ -20,6 +20,8 @@ export const articleEntityToModel = (
content: articleEntity.content, content: articleEntity.content,
authorId: articleEntity.authorId, authorId: articleEntity.authorId,
externalId: articleEntity.externalId, externalId: articleEntity.externalId,
createdAt: articleEntity.createdAt,
updatedAt: articleEntity.updatedAt,
}; };
}; };

View File

@@ -0,0 +1,66 @@
import { ArticleModel } from '@/lib/feature/article/article.model';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { CalendarIcon } from 'lucide-react';
import Link from 'next/link';
interface ArticleCardProps {
article: ArticleModel;
className?: string;
}
const formatDate = (date: Date) =>
new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
export const ArticleCard = ({ article, className }: ArticleCardProps) => {
return (
<Link
href={`/article/${article.slug}`}
className={cn(
'group flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground transition-all duration-200 hover:border-foreground/20 hover:shadow-md',
className
)}
>
<div className='relative aspect-video w-full overflow-hidden bg-muted'>
{article.coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.coverImageUrl}
alt={article.title}
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-105'
/>
) : (
<div className='flex h-full items-center justify-center bg-gradient-to-br from-muted to-muted/60'>
<span className='font-mono text-5xl font-bold text-muted-foreground/20'>
{'{ }'}
</span>
</div>
)}
</div>
<div className='flex flex-1 flex-col gap-3 p-5'>
<div className='flex items-center gap-1.5 text-xs text-muted-foreground'>
<CalendarIcon className='size-3 shrink-0' />
<time dateTime={article.createdAt.toISOString()}>
{formatDate(article.createdAt)}
</time>
</div>
<h2 className='line-clamp-2 text-base font-bold leading-snug tracking-tight transition-colors group-hover:text-primary'>
{article.title}
</h2>
<p className='line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground'>
{article.description}
</p>
<span className='mt-1 text-xs font-medium text-foreground/60 underline-offset-4 group-hover:text-primary group-hover:underline'>
Read more
</span>
</div>
</Link>
);
};

View File

@@ -0,0 +1,86 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
interface ArticleListPaginationProps {
currentPage: number;
totalPages: number;
baseUrl?: string;
}
function getPageNumbers(current: number, total: number): (number | '...')[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
if (current <= 4) {
return [1, 2, 3, 4, 5, '...', total];
}
if (current >= total - 3) {
return [1, '...', total - 4, total - 3, total - 2, total - 1, total];
}
return [1, '...', current - 1, current, current + 1, '...', total];
}
export const ArticleListPagination = ({
currentPage,
totalPages,
baseUrl = '/home',
}: ArticleListPaginationProps) => {
if (totalPages <= 1) return null;
const buildUrl = (page: number) => `${baseUrl}?page=${page}`;
const pages = getPageNumbers(currentPage, totalPages);
return (
<nav className='flex items-center gap-1' aria-label='Article pagination'>
<Link
href={buildUrl(currentPage - 1)}
aria-disabled={currentPage <= 1}
aria-label='Previous page'
className={cn(
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
currentPage <= 1 && 'pointer-events-none opacity-40',
)}
>
<ChevronLeftIcon className='size-4' />
</Link>
{pages.map((page, i) =>
page === '...' ? (
<span
key={`ellipsis-${i}`}
className='flex size-9 items-center justify-center text-sm text-muted-foreground'
>
</span>
) : (
<Link
key={page}
href={buildUrl(page as number)}
aria-current={page === currentPage ? 'page' : undefined}
className={cn(
'flex size-9 items-center justify-center rounded-lg border text-sm font-medium transition-colors',
page === currentPage
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card hover:bg-muted',
)}
>
{page}
</Link>
),
)}
<Link
href={buildUrl(currentPage + 1)}
aria-disabled={currentPage >= totalPages}
aria-label='Next page'
className={cn(
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
currentPage >= totalPages && 'pointer-events-none opacity-40',
)}
>
<ChevronRightIcon className='size-4' />
</Link>
</nav>
);
};

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const Pagination = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
data: z.array(itemSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
});