feat: add article page and not found component with markdown rendering
This commit is contained in:
@@ -34,6 +34,7 @@ docker compose -f docker/docker-compose.yml up -d
|
|||||||
This is a **Next.js 16 App Router** full-stack blog with TypeScript. The structure separates concerns into three main layers:
|
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/`)
|
### Frontend (`src/app/` + `src/ui/`)
|
||||||
|
|
||||||
- **Pages** live in `src/app/(pages)/` using route groups
|
- **Pages** live in `src/app/(pages)/` using route groups
|
||||||
- **API routes** live in `src/app/api/` (Next.js Route Handlers)
|
- **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)
|
- **Components** in `src/ui/components/internal/` (custom) and `src/ui/components/shadcn/` (shadcn/ui generated)
|
||||||
@@ -41,7 +42,9 @@ This is a **Next.js 16 App Router** full-stack blog with TypeScript. The structu
|
|||||||
- Root layout at `src/app/layout.tsx` mounts all providers; `/` redirects to `/home` via `next.config.ts`
|
- Root layout at `src/app/layout.tsx` mounts all providers; `/` redirects to `/home` via `next.config.ts`
|
||||||
|
|
||||||
### Backend (`src/lib/`)
|
### Backend (`src/lib/`)
|
||||||
|
|
||||||
Domain-driven feature modules — each feature lives in `src/lib/feature/<feature>/` with:
|
Domain-driven feature modules — each feature lives in `src/lib/feature/<feature>/` with:
|
||||||
|
|
||||||
- `*.entity.ts` — TypeORM entity definition
|
- `*.entity.ts` — TypeORM entity definition
|
||||||
- `*.model.ts` — DTOs and interfaces (Zod schemas live here too)
|
- `*.model.ts` — DTOs and interfaces (Zod schemas live here too)
|
||||||
- `*.service.ts` — Business logic (CRUD, validation, no HTTP concerns)
|
- `*.service.ts` — Business logic (CRUD, validation, no HTTP concerns)
|
||||||
@@ -50,20 +53,24 @@ Domain-driven feature modules — each feature lives in `src/lib/feature/<featur
|
|||||||
Current features: `user/`, `article/`
|
Current features: `user/`, `article/`
|
||||||
|
|
||||||
### Key Integrations
|
### 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/`).
|
- **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`.
|
- **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.
|
- **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.
|
- **Migrations:** SQL migrations in `migrations/` directory. Run via TypeORM CLI, not auto-run.
|
||||||
|
|
||||||
### State management
|
### State management
|
||||||
|
|
||||||
- **Server state:** React Query (`@tanstack/react-query`) for async data fetching
|
- **Server state:** React Query (`@tanstack/react-query`) for async data fetching
|
||||||
- **Client state:** Jotai atoms for local UI state
|
- **Client state:** Jotai atoms for local UI state
|
||||||
- **Forms:** React Hook Form + Zod validation
|
- **Forms:** React Hook Form + Zod validation
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Jest with `ts-jest`; tests in `tests/` mirroring `src/lib/` structure
|
- Jest with `ts-jest`; tests in `tests/` mirroring `src/lib/` structure
|
||||||
- PostgreSQL integration tests use `@testcontainers/postgresql` (requires Docker)
|
- PostgreSQL integration tests use `@testcontainers/postgresql` (requires Docker)
|
||||||
- S3 tests use `aws-sdk-client-mock`
|
- S3 tests use `aws-sdk-client-mock`
|
||||||
|
|
||||||
### Local environment
|
### 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_*`.
|
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_*`.
|
||||||
|
|||||||
1473
package-lock.json
generated
1473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||||
"@clerk/nextjs": "^7.0.7",
|
"@clerk/nextjs": "^7.0.7",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.1.1",
|
"shadcn": "^4.1.1",
|
||||||
"slugify": "^1.6.9",
|
"slugify": "^1.6.9",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
22
src/app/(pages)/article/[slug]/not-found.tsx
Normal file
22
src/app/(pages)/article/[slug]/not-found.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import { FileXIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const ArticleNotFound = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center justify-center gap-4 py-24 text-center'>
|
||||||
|
<FileXIcon className='size-12 text-muted-foreground/40' />
|
||||||
|
<div>
|
||||||
|
<h1 className='text-2xl font-bold'>Article not found</h1>
|
||||||
|
<p className='mt-1 text-muted-foreground'>
|
||||||
|
This article may have been moved or deleted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href='/home'>Back to all articles</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArticleNotFound;
|
||||||
99
src/app/(pages)/article/[slug]/page.tsx
Normal file
99
src/app/(pages)/article/[slug]/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { getArticleBySlug } from '@/lib/feature/article/article.external';
|
||||||
|
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface ArticlePageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readingTime(text: string): number {
|
||||||
|
const words = text.trim().split(/\s+/).length;
|
||||||
|
return Math.max(1, Math.ceil(words / 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticlePage = async ({ params }: ArticlePageProps) => {
|
||||||
|
const { slug } = await params;
|
||||||
|
const article = await getArticleBySlug(slug);
|
||||||
|
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
const minutes = readingTime(article.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className='w-full'>
|
||||||
|
{article.coverImageUrl && (
|
||||||
|
<div className='relative h-64 w-full overflow-hidden md:h-96'>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={article.coverImageUrl}
|
||||||
|
alt={article.title}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
<div className='absolute inset-0 bg-linear-to-t from-background via-background/40 to-transparent' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='container mx-auto max-w-3xl px-4'>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
article.coverImageUrl ? '-mt-16 relative z-10' : 'pt-12'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href='/home'
|
||||||
|
className='mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground'
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className='size-3.5' />
|
||||||
|
All articles
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className='mb-10'>
|
||||||
|
<h1 className='mb-4 text-3xl font-bold leading-tight tracking-tight md:text-4xl'>
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className='mb-5 text-lg leading-relaxed text-muted-foreground'>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground'>
|
||||||
|
<span className='flex items-center gap-1.5'>
|
||||||
|
<CalendarIcon className='size-3.5 shrink-0' />
|
||||||
|
<time
|
||||||
|
dateTime={article.createdAt.toISOString()}
|
||||||
|
>
|
||||||
|
{formatDate(article.createdAt)}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
<span className='flex items-center gap-1.5'>
|
||||||
|
<ClockIcon className='size-3.5 shrink-0' />
|
||||||
|
{minutes} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr className='mb-10 border-border' />
|
||||||
|
|
||||||
|
<div className='prose prose-neutral dark:prose-invert max-w-none pb-16'>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{article.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArticlePage;
|
||||||
@@ -13,7 +13,11 @@ const Home = async ({ searchParams }: HomeProps) => {
|
|||||||
const { page: pageParam } = await searchParams;
|
const { page: pageParam } = await searchParams;
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
|
||||||
const { data: articles, totalPages, total } = await getArticlesPaginated(page, PAGE_SIZE);
|
const {
|
||||||
|
data: articles,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
} = await getArticlesPaginated(page, PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
|
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
|
||||||
@@ -41,7 +45,10 @@ const Home = async ({ searchParams }: HomeProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<ArticleCard key={article.externalId} article={article} />
|
<ArticleCard
|
||||||
|
key={article.externalId}
|
||||||
|
article={article}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import 'shadcn/tailwind.css';
|
@import 'shadcn/tailwind.css';
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: var(--font-source-code-pro), sans-serif;
|
font-family: var(--font-source-code-pro), sans-serif;
|
||||||
|
font-size: 15px; /* 15px base instead of 16px */
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
|||||||
@@ -32,14 +32,17 @@ export const ArticleListPagination = ({
|
|||||||
const pages = getPageNumbers(currentPage, totalPages);
|
const pages = getPageNumbers(currentPage, totalPages);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className='flex items-center gap-1' aria-label='Article pagination'>
|
<nav
|
||||||
|
className='flex items-center gap-1'
|
||||||
|
aria-label='Article pagination'
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={buildUrl(currentPage - 1)}
|
href={buildUrl(currentPage - 1)}
|
||||||
aria-disabled={currentPage <= 1}
|
aria-disabled={currentPage <= 1}
|
||||||
aria-label='Previous page'
|
aria-label='Previous page'
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
|
'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',
|
currentPage <= 1 && 'pointer-events-none opacity-40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className='size-4' />
|
<ChevronLeftIcon className='size-4' />
|
||||||
@@ -62,12 +65,12 @@ export const ArticleListPagination = ({
|
|||||||
'flex size-9 items-center justify-center rounded-lg border text-sm font-medium transition-colors',
|
'flex size-9 items-center justify-center rounded-lg border text-sm font-medium transition-colors',
|
||||||
page === currentPage
|
page === currentPage
|
||||||
? 'border-foreground bg-foreground text-background'
|
? 'border-foreground bg-foreground text-background'
|
||||||
: 'border-border bg-card hover:bg-muted',
|
: 'border-border bg-card hover:bg-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -76,7 +79,8 @@ export const ArticleListPagination = ({
|
|||||||
aria-label='Next page'
|
aria-label='Next page'
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
|
'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',
|
currentPage >= totalPages &&
|
||||||
|
'pointer-events-none opacity-40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className='size-4' />
|
<ChevronRightIcon className='size-4' />
|
||||||
|
|||||||
Reference in New Issue
Block a user