feat: add createdAt and updatedAt fields to Article model and implement article pagination with new components
This commit is contained in:
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal 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_*`.
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
66
src/ui/components/internal/article-card.tsx
Normal file
66
src/ui/components/internal/article-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
src/ui/components/internal/article-list-pagination.tsx
Normal file
86
src/ui/components/internal/article-list-pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
src/utils/types/pagination.ts
Normal file
10
src/utils/types/pagination.ts
Normal 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(),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user