From 792627b0f03f6e5a4ae15c236283ec28d83d184f Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 10 Apr 2026 23:19:14 -0300 Subject: [PATCH] feat: add createdAt and updatedAt fields to Article model and implement article pagination with new components --- CLAUDE.md | 69 +++++++++++++++ src/app/(pages)/home/page.tsx | 62 +++++++++++-- src/lib/feature/article/article.model.ts | 2 + src/lib/feature/article/article.service.ts | 2 + src/ui/components/internal/article-card.tsx | 66 ++++++++++++++ .../internal/article-list-pagination.tsx | 86 +++++++++++++++++++ src/utils/types/pagination.ts | 10 +++ 7 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/ui/components/internal/article-card.tsx create mode 100644 src/ui/components/internal/article-list-pagination.tsx create mode 100644 src/utils/types/pagination.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad610e4 --- /dev/null +++ b/CLAUDE.md @@ -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= # 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//` 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_*`. diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index 03efc8f..ddaa34d 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -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 ( -
-

Home

-

Welcome {siteConfig.name}!

+
+
+

+ Dev Blog +

+

+ Latest Articles +

+

+ {total === 0 + ? 'No articles published yet.' + : `${total} article${total === 1 ? '' : 's'} published`} +

+
+ + {articles.length === 0 ? ( +
+ +

+ No articles yet. Check back soon! +

+
+ ) : ( +
+ {articles.map((article) => ( + + ))} +
+ )} + + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+ +
+ )}
); }; diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts index 6207293..7851c97 100644 --- a/src/lib/feature/article/article.model.ts +++ b/src/lib/feature/article/article.model.ts @@ -29,6 +29,8 @@ export const ArticleModel = z.object({ content: z.string(), authorId: z.string(), externalId: z.uuid(), + createdAt: z.date(), + updatedAt: z.date(), }); export type ArticleModel = z.infer; diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 78d61f8..008138d 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -20,6 +20,8 @@ export const articleEntityToModel = ( content: articleEntity.content, authorId: articleEntity.authorId, externalId: articleEntity.externalId, + createdAt: articleEntity.createdAt, + updatedAt: articleEntity.updatedAt, }; }; diff --git a/src/ui/components/internal/article-card.tsx b/src/ui/components/internal/article-card.tsx new file mode 100644 index 0000000..2b22bb0 --- /dev/null +++ b/src/ui/components/internal/article-card.tsx @@ -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 ( + +
+ {article.coverImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {article.title} + ) : ( +
+ + {'{ }'} + +
+ )} +
+ +
+
+ + +
+ +

+ {article.title} +

+ +

+ {article.description} +

+ + + Read more → + +
+ + ); +}; diff --git a/src/ui/components/internal/article-list-pagination.tsx b/src/ui/components/internal/article-list-pagination.tsx new file mode 100644 index 0000000..cf75767 --- /dev/null +++ b/src/ui/components/internal/article-list-pagination.tsx @@ -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 ( + + ); +}; diff --git a/src/utils/types/pagination.ts b/src/utils/types/pagination.ts new file mode 100644 index 0000000..b7563e1 --- /dev/null +++ b/src/utils/types/pagination.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const Pagination = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + total: z.number(), + page: z.number(), + pageSize: z.number(), + totalPages: z.number(), + });