Merge pull request 'feature/adds-admin-add-article' (#1) from feature/adds-admin-add-article into main
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 1m54s

Reviewed-on: http://gitea.hideyoshi.com.br/HideyoshiNakazone/hideyoshi-blog/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-11 04:30:41 +00:00
45 changed files with 6886 additions and 83 deletions

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# 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

@@ -12,14 +12,16 @@
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/ui/components",
"ui": "@/ui/components/shadcn",
"lib": "@/ui/components/shadcn/lib",
"utils": "@/ui/components/shadcn/lib/utils",
"hooks": "@/ui/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"aliases": {
"components": "@/ui/components",
"utils": "@/ui/components/shadcn/lib/utils",
"ui": "@/ui/components/shadcn",
"lib": "@/ui/components/shadcn/lib",
"hooks": "@/ui/hooks"
},
"registries": {
"@diceui": "https://diceui.com/r/{name}.json"
}
}

View File

@@ -1,17 +0,0 @@
name: hideyoshi-blog
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: local_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
ports:
- '5332:5432'
volumes:
db_data:

37
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
name: hideyoshi-blog
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: local_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
ports:
- '5332:5432'
networks:
- internal
storage:
image: rustfs/rustfs:latest
restart: always
environment:
RUSTFS_ACCESS_KE: rustfsadmin
RUSTFS_SECRET_KE: rustfsadmin
ports:
- '9000:9000'
- '9001:9001'
volumes:
- storage_data:/data
networks:
- internal
volumes:
db_data:
storage_data:
networks:
internal:

27
docker/garage.toml Normal file
View File

@@ -0,0 +1,27 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
# idk why rcp_ is needed without replication but ok
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
compression_level = 2
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = "api.s3.su6.nl"
[s3_web]
bind_addr = "[::]:3902"
root_domain = "web.s3.su6.nl"
index = "index.html"
[admin]
api_bind_addr = "0.0.0.0:3903"
metrics_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
admin_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"

View File

@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
},
];
},
cacheComponents: true,
};
export default nextConfig;

3314
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,11 @@
"test": "jest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1028.0",
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
@@ -31,7 +35,11 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.72.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.1",
"slugify": "^1.6.9",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -46,6 +54,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"aws-sdk-client-mock": "^4.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-prettier": "^10.1.8",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#4B5563" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 16 4.586-4.586a2 2 0 0 1 2.828 0L16 16m-2-2 1.586-1.586a2 2 0 0 1 2.828 0L20 14m-6-6h.01M6 20h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2" class="dark:stroke-[#9CA3AF]"/></svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#4B5563" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 0 0 2-2V9.414a1 1 0 0 0-.293-.707l-5.414-5.414A1 1 0 0 0 12.586 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2" class="dark:stroke-[#9CA3AF]"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -1,36 +1,78 @@
import { siteConfig } from '@/site.config';
import { ExternalLinkIcon } from 'lucide-react';
import Link from 'next/link';
const AboutPage = async () => {
return (
<div className='max-w-3xl mx-auto px-4 py-10'>
<p className='mb-4'>Hi, Im Vitor Hideyoshi.</p>
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
<div className='mb-10 border-b border-border pb-8'>
<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'>
About
</h1>
</div>
<p className='mb-4'>
Im a software developer and Ive always enjoyed programming.
Over the years, Ive worked on all kinds of projects, from
infrastructure tools and data modeling systems to computational
physics simulations and agent-based AI tools.
</p>
<div className='max-w-2xl'>
<p className='mb-8 text-xl leading-snug'>
Hi, I&apos;m{' '}
<span className='font-semibold'>
{siteConfig.author.name}
</span>
.
</p>
<p className='mb-4'>
For me, programming is more than work. Its something I like to
explore, improve, and keep experimenting with.
</p>
<div className='space-y-4 leading-relaxed text-muted-foreground'>
<p>
I&apos;m a software developer and I&apos;ve always
enjoyed programming. Over the years, I&apos;ve worked on
all kinds of projects from infrastructure tools and
data modeling systems to computational physics
simulations and agent-based AI tools.
</p>
<p>
For me, programming is more than work. It&apos;s
something I like to explore, improve, and keep
experimenting with.
</p>
<p>
I&apos;m especially drawn to building things that are
simple, practical, and useful. I care about continuous
improvement, both in the systems I build and in the way
I approach problems.
</p>
<p>
This blog is where I share what I learn along the way
ideas, experiments, and lessons from real projects. Hope
we can learn and grow together.
</p>
</div>
<p className='mb-4'>
Im especially drawn to building things that are simple,
practical, and useful. I care about continuous improvement, both
in the systems I build and in the way I approach problems.
</p>
<p className='mb-4'>
This blog is where I share what I learn along the way, including
ideas, experiments, and lessons from real projects.
</p>
<p>
Feel free to explore my posts and check out my work on GitHub.
<br />
Hope we can learn and grow together.
</p>
<div className='mt-10 flex items-center gap-5 border-t border-border pt-8'>
<span className='text-sm text-muted-foreground'>
Find me on
</span>
<Link
href={siteConfig.author.links.github}
target='_blank'
rel='noreferrer'
className='flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-primary'
>
<ExternalLinkIcon className='size-3.5' />
GitHub
</Link>
<Link
href={siteConfig.author.links.twitter}
target='_blank'
rel='noreferrer'
className='flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-primary'
>
<ExternalLinkIcon className='size-3.5' />
Twitter
</Link>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,14 @@
import { siteConfig } from '@/site.config';
import CreateArticleForm from '@/ui/components/internal/create-article-form';
const Home = async () => {
const AdminPage = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>Admin</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
<div className='container mx-auto px-4 py-10 min-h-3/4'>
<div className='rounded-lg border border-border p-6'>
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
<CreateArticleForm />
</div>
</div>
);
};
export default Home;
export default AdminPage;

View 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;

View File

@@ -0,0 +1,153 @@
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 { Suspense } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type ArticlePageProps = {
params: Promise<{ slug: string }>;
};
type ArticleContentProps = {
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 ArticleContentSkeleton = () => (
<article className='w-full'>
<div className='h-64 w-full animate-pulse bg-muted md:h-96' />
<div className='container mx-auto max-w-3xl px-4'>
<div className='-mt-16 relative z-10'>
<div className='mb-6 h-4 w-24 animate-pulse rounded bg-muted' />
<header className='mb-10'>
<div className='mb-4 space-y-2'>
<div className='h-8 w-full animate-pulse rounded bg-muted' />
<div className='h-8 w-3/4 animate-pulse rounded bg-muted' />
</div>
<div className='mb-5 space-y-2'>
<div className='h-5 w-full animate-pulse rounded bg-muted' />
<div className='h-5 w-2/3 animate-pulse rounded bg-muted' />
</div>
<div className='flex gap-4'>
<div className='h-3 w-28 animate-pulse rounded bg-muted' />
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
</div>
</header>
<hr className='mb-10 border-border' />
<div className='space-y-3 pb-16'>
{[...Array(4)].map((_, i) => (
<div key={i} className='space-y-2'>
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-5/6 animate-pulse rounded bg-muted' />
</div>
))}
</div>
</div>
</div>
</article>
);
const ArticleContent = async ({ params }: ArticleContentProps) => {
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>
);
};
const ArticlePage = ({ params }: ArticlePageProps) => {
return (
<Suspense fallback={<ArticleContentSkeleton />}>
<ArticleContent params={params} />
</Suspense>
);
};
export default ArticlePage;

View File

@@ -1,10 +1,115 @@
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';
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 {
data: articles,
totalPages,
total,
} = await getArticlesPaginated(page, pageSize);
const Home = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>Home</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
<>
<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>
)}
</>
);
};
type HomeProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>;
};
const Home = async ({ searchParams }: HomeProps) => {
return (
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
<div className='mb-10 border-b border-border pb-8'>
<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>
</div>
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList searchParams={searchParams} />
</Suspense>
</div>
);
};

View File

@@ -1,9 +1,11 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@plugin '@tailwindcss/typography';
html {
font-family: var(--font-source-code-pro), sans-serif;
font-size: 15px; /* 15px base instead of 16px */
}
h1,

View File

@@ -43,7 +43,7 @@ export default async function RootLayout({
className={`${montserrat.className} ${sourceCodePro.className}`}
suppressHydrationWarning
>
<body className={'relative h-screen bg-background antialiased'}>
<body className={'relative min-h-screen bg-background antialiased'}>
<Provider
attribute='class'
defaultTheme='system'

View File

@@ -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<typeof ArticleModel>;

View File

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

View File

@@ -0,0 +1,116 @@
import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult, wrap } from '@/utils/types/results';
import {
DeleteObjectCommand,
HeadObjectCommand,
ObjectCannedACL,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod';
/**
* Configuration for S3 storage adapter
*/
export const S3StorageConfig = z.object({
endpoint: z.string(),
bucket: z.string(),
region: z.string(),
accessKey: z.string(),
secretKey: z.string(),
});
export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
/**
* AWS S3 storage adapter
* Uploads files to S3 and returns public URLs
*/
export class S3StorageAdapter implements StorageProvider {
private readonly s3Client: S3Client;
private readonly endpoint: string;
private readonly bucketName: string;
readonly get: (
...args: Parameters<StorageProvider['get']>
) => Promise<TypedResult<string>>;
readonly put: (
...args: Parameters<StorageProvider['put']>
) => Promise<TypedResult<string>>;
readonly exists: (
...args: Parameters<StorageProvider['exists']>
) => Promise<boolean>;
readonly delete: (
...args: Parameters<StorageProvider['delete']>
) => Promise<TypedResult<void>>;
constructor(config: S3StorageConfig, s3Client?: S3Client) {
this.endpoint = config.endpoint;
this.bucketName = config.bucket;
this.s3Client =
s3Client ||
new S3Client({
endpoint: config.endpoint,
region: config.region,
forcePathStyle: true,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
});
this.get = wrap(this._get.bind(this));
this.put = wrap(this._put.bind(this));
this.delete = wrap(this._delete.bind(this));
this.exists = this._exists.bind(this);
}
private async _get(key: string): Promise<string> {
return `${this.endpoint}/${this.bucketName}/${key}`;
}
private async _put(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
ContentType: contentType,
ACL: ObjectCannedACL.public_read,
});
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
}
private async _exists(key: string): Promise<boolean> {
try {
await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
return true;
} catch {
return false;
}
}
private async _delete(key: string): Promise<void> {
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
}
}
export const new_s3_storage_adapter = (): S3StorageAdapter => {
const config = S3StorageConfig.parse({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET_NAME,
region: process.env.S3_REGION,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
});
return new S3StorageAdapter(config);
};

View File

@@ -0,0 +1,48 @@
'use server';
import { createStorageProvider } from '@/lib/storage/storage.factory';
import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult } from '@/utils/types/results';
const storage: StorageProvider = createStorageProvider();
export const getSignedUrl = async (
key: string,
storageProvider?: StorageProvider
): Promise<TypedResult<string>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.get(key);
};
export const checkExists = async (
key: string,
storageProvider?: StorageProvider
): Promise<boolean> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.exists(key);
};
export const getPutUrl = async (
key: string,
contentType: string,
storageProvider?: StorageProvider
): Promise<TypedResult<string>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.put(key, contentType);
};
export const deleteByKey = async (
key: string,
storageProvider?: StorageProvider
): Promise<TypedResult<void>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.delete(key);
};

View File

@@ -0,0 +1,13 @@
import { new_s3_storage_adapter } from '@/lib/storage/storage.adapter';
import { StorageProvider } from '@/lib/storage/storage.interface';
/**
* Factory function to create the appropriate storage provider based on environment
*/
export function createStorageProvider(): StorageProvider {
const storage_provider = new_s3_storage_adapter();
if (!storage_provider) {
throw new Error('Failed to create storage provider');
}
return storage_provider;
}

View File

@@ -0,0 +1,43 @@
import { TypedResult } from '@/utils/types/results';
/**
* Result returned from storage operations
*/
export interface StorageResult {
type: 'local' | 's3';
provider: string | null;
key: string;
publicUrl: string;
}
/**
* Storage provider interface for abstracting different storage implementations
*/
export interface StorageProvider {
/**
* Gets a presigned url for the requested file
* @param key - The unique key/path to store the file under
*/
get(key: string): Promise<TypedResult<string>>;
/**
* Uploads a file to storage
* @param key - The unique key/path to store the file under
* @param contentType - The MIME type of the file being uploaded
* @returns Promise<string> - The public URL of the uploaded file
*/
put(key: string, contentType: string): Promise<TypedResult<string>>;
/**
* Checks whether a file exists in storage
* @param key - The unique key/path of the file to check
*/
exists(key: string): Promise<boolean>;
/**
* Deletes a file from storage
* @param key - The unique key/path of the file to delete
* @returns Promise<void>
*/
delete(key: string): Promise<TypedResult<void>>;
}

View File

@@ -0,0 +1,9 @@
import { createStorageProvider } from '@/lib/storage/storage.factory';
/**
* Singleton instance of the configured storage provider
*/
export const storage: ReturnType<typeof createStorageProvider> =
createStorageProvider();
export type { StorageResult } from '@/lib/storage/storage.interface';

View File

@@ -0,0 +1,56 @@
'use client';
import * as storage from '@/lib/storage/storage.external';
import { StorageProvider } from '@/lib/storage/storage.interface';
import { wrap } from '@/utils/types/results';
import { z } from 'zod';
export const FileUploadResp = z.object({
signedUrl: z.string(),
key: z.string(),
});
export type FileUploadResp = z.infer<StorageProvider>;
async function hashFile(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const ext = file.name.split('.').pop();
return ext ? `${hex}.${ext}` : hex;
}
export const uploadFile = wrap(async (file: File) => {
const fileKey = await hashFile(file);
const existsResult = await storage.checkExists(fileKey);
if (existsResult) {
const presignedUrl = await storage.getSignedUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
return { signedUrl: presignedUrl.value, key: fileKey };
}
const result = await storage.getPutUrl(fileKey, file.type);
if (!result.ok) {
throw new Error('File upload failed');
}
const response = await fetch(result.value, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!response.ok) {
throw new Error('Failed to upload file');
}
const presignedUrl = await storage.getSignedUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
return { signedUrl: presignedUrl.value, key: fileKey };
});

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,90 @@
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,304 @@
'use client';
import { saveArticle } from '@/lib/feature/article/article.external';
import { uploadFile } from '@/lib/storage/storage.utils';
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
import { Button } from '@/ui/components/shadcn/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/ui/components/shadcn/field';
import { Input } from '@/ui/components/shadcn/input';
import {
InputGroup,
InputGroupTextarea,
} from '@/ui/components/shadcn/input-group';
import { zodResolver } from '@hookform/resolvers/zod';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import slugify from 'slugify';
import { toast } from 'sonner';
import { z } from 'zod';
import ImageLogo from '~/public/img/icons/cover-image.svg';
import MarkdownLogo from '~/public/img/icons/markdown-content.svg';
function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
function isContentFile(file: File): boolean {
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
return (
file.type === 'text/markdown' ||
file.type === 'text/plain' ||
extension === 'md' ||
extension === 'markdown' ||
extension === 'txt'
);
}
function validateImageFile(file: File): string | null {
return isImageFile(file)
? null
: 'Only image files are allowed for cover image';
}
function validateContentFile(file: File): string | null {
return isContentFile(file)
? null
: 'Only markdown or text files are allowed';
}
export const CreateArticleForm = () => {
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
const [coverImageUploading, setCoverImageUploading] = useState(false);
const [contentFile, setContentFile] = useState<File | null>(null);
const coverImageUrlRef = useRef<string | null>(null);
const formSchema = z.object({
title: z.string().min(3).max(255),
slug: z.string().min(3),
description: z.string().min(10),
coverImageUrl: z.url('Cover image URL must be a valid URL'),
content: z
.string()
.min(10, 'Article content must have at least 10 characters'),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
slug: '',
description: '',
coverImageUrl: '',
content: '',
},
});
const title = useWatch({ control: form.control, name: 'title' });
const coverImageUrl = useWatch({
control: form.control,
name: 'coverImageUrl',
});
useEffect(() => {
if (!title) return;
form.setValue('slug', slugify(title).toLowerCase());
}, [form, title]);
const resetFiles = useCallback(() => {
if (coverImageUrlRef.current) {
URL.revokeObjectURL(coverImageUrlRef.current);
coverImageUrlRef.current = null;
}
setCoverImageFile(null);
setCoverImageUploading(false);
setContentFile(null);
}, []);
const handleFormSubmit = useCallback(
async (data: z.infer<typeof formSchema>) => {
try {
const result = await saveArticle({ ...data });
toast.success('Article created successfully!', {
description: `Article "${result.title}" has been created.`,
position: 'bottom-right',
});
form.reset();
resetFiles();
} catch (error) {
toast.error('Failed to create article', {
description:
error instanceof Error
? error.message
: 'An error occurred',
position: 'bottom-right',
});
}
},
[form, resetFiles]
);
const handleCoverImageFileChange = useCallback(
async (file: File | null) => {
if (coverImageUrlRef.current) {
URL.revokeObjectURL(coverImageUrlRef.current);
coverImageUrlRef.current = null;
}
setCoverImageFile(file);
if (!file) {
setCoverImageFile(null);
setCoverImageUploading(false);
form.setValue('coverImageUrl', '');
return;
}
setCoverImageUploading(true);
const fileMetadataResult = await uploadFile(file);
setCoverImageUploading(false);
if (!fileMetadataResult.ok) {
setCoverImageFile(null);
form.setValue('coverImageUrl', '');
toast((fileMetadataResult.error as Error).message);
return;
}
const fileMetadata = fileMetadataResult.value;
coverImageUrlRef.current = fileMetadata.signedUrl;
form.setValue('coverImageUrl', fileMetadata.signedUrl);
},
[form]
);
const handleContentFileChange = useCallback(
async (file: File | null) => {
setContentFile(file);
if (file) {
const content = await file.text();
form.setValue('content', content);
} else {
form.setValue('content', '');
}
},
[form]
);
const handleCoverImageReject = useCallback(
(_file: File, message: string) => {
toast.error(`Cover image rejected: ${message}`);
},
[]
);
const handleContentFileReject = useCallback(
(_file: File, message: string) => {
toast.error(`Content file rejected: ${message}`);
},
[]
);
return (
<form
id='form-create-article'
// eslint-disable-next-line react-hooks/refs
onSubmit={form.handleSubmit(handleFormSubmit)}
>
<FieldGroup className='gap-7'>
<Controller
name='title'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-title'>
Title
</FieldLabel>
<Input
{...field}
id='form-create-article-title'
aria-invalid={fieldState.invalid}
placeholder='Article title'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='slug'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-slug'>
Slug
</FieldLabel>
<Input
{...field}
id='form-create-article-slug'
aria-invalid={fieldState.invalid}
placeholder='article-slug'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-description'>
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-create-article-description'
placeholder='A simple but nice description of the article here.'
rows={3}
className='min-h-24 resize-none'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FileUploadField
file={coverImageFile}
onFileChange={handleCoverImageFileChange}
accept='image/*'
validate={validateImageFile}
onFileReject={handleCoverImageReject}
label='Cover image'
description='PNG, JPG, GIF, WebP accepted'
error={form.formState.errors.coverImageUrl?.message}
previewUrl={coverImageUrl || undefined}
isUploading={coverImageUploading}
icon={
<Image
src={ImageLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
<FileUploadField
file={contentFile}
onFileChange={handleContentFileChange}
accept='.md,.markdown,.txt'
validate={validateContentFile}
onFileReject={handleContentFileReject}
label='Markdown content'
description='.md / .markdown / .txt accepted'
error={form.formState.errors.content?.message}
icon={
<Image
src={MarkdownLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
</div>
</FieldGroup>
<div className='flex w-full justify-end'>
<Button type='submit' className='mt-6'>
Create Article
</Button>
</div>
</form>
);
};
export default CreateArticleForm;

View File

@@ -0,0 +1,136 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import {
Field,
FieldDescription,
FieldError,
} from '@/ui/components/shadcn/field';
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from '@/ui/components/shadcn/file-upload';
import { Spinner } from '@/ui/components/shadcn/spinner';
import { X } from 'lucide-react';
import React, { useCallback } from 'react';
export interface FileUploadFieldProps {
file: File | null;
onFileChange: (file: File | null) => Promise<void>;
accept?: string;
validate?: (file: File) => string | null;
onFileReject?: (file: File, message: string) => void;
label?: string;
description?: string;
error?: string;
icon?: React.ReactNode;
previewUrl?: string;
isUploading?: boolean;
}
export const FileUploadField: React.FC<FileUploadFieldProps> = ({
file,
onFileChange,
accept,
validate,
onFileReject,
label = 'File',
description,
error,
icon,
previewUrl,
isUploading,
}) => {
const handleAccept = useCallback(
(files: File[]) => {
const accepted = files[0];
if (!accepted) return;
onFileChange(accepted).then(() => {});
},
[onFileChange]
);
const handleValueChange = useCallback(
(files: File[]) => {
if (files.length === 0) onFileChange(null);
},
[onFileChange]
);
return (
<Field data-invalid={!!error}>
<FileUpload
value={file ? [file] : []}
onValueChange={handleValueChange}
onAccept={handleAccept}
onFileReject={onFileReject}
onFileValidate={validate}
accept={accept}
maxFiles={1}
multiple={false}
label={label}
className='min-w-0'
>
<FileUploadDropzone className='p-3'>
{icon}
<div className='flex flex-col gap-0.5 text-center'>
<p className='text-xs font-medium text-foreground'>
{label}
</p>
<p className='text-xs text-muted-foreground'>
Drag & drop or{' '}
<FileUploadTrigger className='cursor-pointer font-medium text-primary underline-offset-4 hover:underline'>
browse
</FileUploadTrigger>
</p>
</div>
</FileUploadDropzone>
<FileUploadList>
{file && (
<FileUploadItem value={file}>
<FileUploadItemPreview
render={
isUploading
? () => (
<Spinner className='size-5 text-muted-foreground' />
)
: previewUrl
? () => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt='Uploaded image'
className='size-full object-cover'
/>
)
: undefined
}
/>
<FileUploadItemMetadata size='sm' />
<FileUploadItemDelete asChild>
<Button
type='button'
variant='ghost'
size='icon'
className='ml-auto size-7 shrink-0'
>
<X className='size-3.5' />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
)}
</FileUploadList>
</FileUpload>
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError errors={[new Error(error)]} />}
</Field>
);
};

View File

@@ -6,6 +6,7 @@ export type HeaderLinksProps = {
href: string;
label: string;
condition: boolean;
active?: boolean;
}>;
};
@@ -26,7 +27,7 @@ export const BaseDesktopHeader = ({
<Link
key={link.href}
href={link.href}
className='text-xl font-normal transition-colors hover:font-bold hover:text-primary'
className={`text-xl font-normal transition-colors hover:font-bold hover:text-primary${link.active ? ' underline underline-offset-4' : ''}`}
>
{link.label}
</Link>

View File

@@ -4,8 +4,11 @@ import { getSessionData } from '@/lib/session/session-storage';
import { BaseDesktopHeader } from '@/ui/components/internal/header/desktop-header/base-desktop-header';
import { UserButton } from '@/ui/components/internal/user-profile/user-profile-button';
import { useQuery } from '@tanstack/react-query';
import { usePathname } from 'next/navigation';
export const DynamicDesktopHeader = () => {
const pathname = usePathname();
const { data: sessionData } = useQuery({
queryKey: ['sessionData'],
queryFn: async () => {
@@ -15,9 +18,24 @@ export const DynamicDesktopHeader = () => {
const user = sessionData?.user;
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
{ href: '/admin', label: 'Admin', condition: !!user },
{
href: '/home',
label: 'Home',
condition: true,
active: pathname === '/home',
},
{
href: '/about',
label: 'About',
condition: true,
active: pathname === '/about',
},
{
href: '/admin',
label: 'Admin',
condition: !!user,
active: pathname === '/admin',
},
];
const userButton = !!user ? <UserButton user={user} /> : <div />;

View File

@@ -1,13 +1,8 @@
import { siteConfig } from '@/site.config';
export function SiteFooter() {
const buildCopyrightYear = (): string => {
if (siteConfig.copyright.initialYear == new Date().getFullYear()) {
return `(${new Date().getFullYear()})`;
}
return `(${siteConfig.copyright.initialYear}-${new Date().getFullYear()})`;
};
const copyrightHolder =
siteConfig.copyright.company || siteConfig.author.name;
return (
<footer className='w-full'>
<div className='footer-height container mx-auto flex flex-col items-center justify-between gap-4 md:flex-row'>
@@ -21,7 +16,7 @@ export function SiteFooter() {
>
{siteConfig.author.name}
</a>
. ©{buildCopyrightYear()} {siteConfig.copyright.company}.
. © {siteConfig.copyright.initialYear} {copyrightHolder}.
All rights reserved.
</p>
</div>

View File

@@ -24,7 +24,7 @@ const buttonVariants = cva(
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",

View File

@@ -0,0 +1,237 @@
'use client';
import { Label } from '@/ui/components/shadcn/label';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Separator } from '@/ui/components/shadcn/separator';
import { cva, type VariantProps } from 'class-variance-authority';
import { useMemo } from 'react';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
'group/field flex w-full gap-2 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
}
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-content'
className={cn(
'group/field-content flex flex-1 flex-col gap-0.5 leading-snug',
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='field-description'
className={cn(
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='relative mx-auto block w-fit bg-background px-2 text-muted-foreground'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className='ml-4 flex list-disc flex-col gap-1'>
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role='alert'
data-slot='field-error'
className={cn('text-sm font-normal text-destructive', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import { Input } from '@/ui/components/shadcn/input';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Textarea } from '@/ui/components/shadcn/textarea';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-group'
role='group'
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
}
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role='group'
data-slot='input-group-addon'
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
}
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot='input-group-control'
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot='input-group-control'
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,18 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,23 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Label as LabelPrimitive } from 'radix-ui';
import * as React from 'react';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,17 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,14 @@
import { useIsomorphicLayoutEffect } from '@/ui/hooks/use-isomorphic-layout-effect';
import * as React from 'react';
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };

View File

@@ -0,0 +1,6 @@
import * as React from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };

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(),
});

View File

@@ -0,0 +1,37 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type TypedResult<T> = Result<T, Error>;
export function wrapBlocking<
F extends (...args: never[]) => unknown,
E = unknown,
>(
fn: F,
mapError: (e: unknown) => E = (e) => e as E
): (...args: Parameters<F>) => Result<ReturnType<F>, E> {
return (...args) => {
try {
return { ok: true, value: fn(...args) as ReturnType<F> };
} catch (e) {
return { ok: false, error: mapError(e) };
}
};
}
export function wrap<
F extends (...args: never[]) => Promise<unknown>,
E = unknown,
>(
fn: F,
mapError: (e: unknown) => E = (e) => e as E
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
return async (...args) => {
try {
return {
ok: true,
value: (await fn(...args)) as Awaited<ReturnType<F>>,
};
} catch (e) {
return { ok: false, error: mapError(e) };
}
};
}

View File

@@ -0,0 +1,165 @@
import {
S3StorageAdapter,
S3StorageConfig,
} from '@/lib/storage/storage.adapter';
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as presigner from '@aws-sdk/s3-request-presigner';
import { mockClient } from 'aws-sdk-client-mock';
jest.mock('@aws-sdk/s3-request-presigner');
describe('S3StorageAdapter', () => {
let s3Mock: ReturnType<typeof mockClient>;
let mockS3Client: S3Client;
let adapter: S3StorageAdapter;
const config: S3StorageConfig = {
endpoint: 'http://localhost:9000',
bucket: 'test-bucket',
region: 'us-east-1',
accessKey: 'test-access-key',
secretKey: 'test-secret-key',
};
beforeEach(() => {
s3Mock = mockClient(S3Client);
mockS3Client = new S3Client({ region: 'us-east-1' });
adapter = new S3StorageAdapter(config, mockS3Client);
});
afterEach(() => {
s3Mock.restore();
jest.clearAllMocks();
});
describe('get', () => {
it('should return public URL for key', async () => {
const key = 'test-image.jpg';
const result = await adapter.get(key);
expect(result).toEqual({
ok: true,
value: `http://localhost:9000/test-bucket/${key}`,
});
});
it('should handle nested keys', async () => {
const key = 'articles/2026/04/image.jpg';
const result = await adapter.get(key);
expect(result).toEqual({
ok: true,
value: `http://localhost:9000/test-bucket/${key}`,
});
});
});
describe('put', () => {
it('should call presigner with correct command parameters', async () => {
const key = 'test-image.jpg';
jest.mocked(presigner.getSignedUrl).mockResolvedValue(
'https://presigned-url.example.com'
);
await adapter.put(key, 'image/jpeg');
const [, command] = jest.mocked(presigner.getSignedUrl).mock
.calls[0];
expect(command).toBeInstanceOf(PutObjectCommand);
expect((command as PutObjectCommand).input.Bucket).toBe(
'test-bucket'
);
expect((command as PutObjectCommand).input.Key).toBe(key);
expect((command as PutObjectCommand).input.ContentType).toBe(
'image/jpeg'
);
});
it('should use 3600 second expiry', async () => {
jest.mocked(presigner.getSignedUrl).mockResolvedValue(
'https://presigned-url.example.com'
);
await adapter.put('test-image.jpg', 'image/jpeg');
const [, , options] = jest.mocked(presigner.getSignedUrl).mock
.calls[0];
expect(options).toEqual({ expiresIn: 3600 });
});
it('should return ok result with the presigned URL', async () => {
const presignedUrl = 'https://presigned-url.example.com';
jest.mocked(presigner.getSignedUrl).mockResolvedValue(presignedUrl);
const result = await adapter.put('test-image.jpg', 'image/jpeg');
expect(result).toEqual({ ok: true, value: presignedUrl });
});
it('should return correct presigned URL for different content types', async () => {
const presignedUrl =
'https://presigned-url.example.com/my-image.png';
jest.mocked(presigner.getSignedUrl).mockResolvedValue(presignedUrl);
const result = await adapter.put('my-image.png', 'image/png');
expect(result).toEqual({ ok: true, value: presignedUrl });
});
it('should return error result on presigner failure', async () => {
jest.mocked(presigner.getSignedUrl).mockRejectedValue(
new Error('Presigner error')
);
const result = await adapter.put('test-image.jpg', 'image/jpeg');
expect(result.ok).toBe(false);
});
});
describe('delete', () => {
it('should delete object from S3 with correct parameters', async () => {
const key = 'test-image.jpg';
s3Mock.on(DeleteObjectCommand).resolves({});
await adapter.delete(key);
expect(s3Mock.call(0).args[0]).toBeInstanceOf(DeleteObjectCommand);
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
expect(command.input.Bucket).toBe('test-bucket');
expect(command.input.Key).toBe(key);
});
it('should return ok result on success', async () => {
s3Mock.on(DeleteObjectCommand).resolves({});
const result = await adapter.delete('test-image.jpg');
expect(result).toEqual({ ok: true, value: undefined });
});
it('should return error result on failure', async () => {
s3Mock.on(DeleteObjectCommand).rejects(new Error('S3 error'));
const result = await adapter.delete('test-image.jpg');
expect(result.ok).toBe(false);
});
it('should handle nested keys', async () => {
const key = 'articles/2026/04/image.jpg';
s3Mock.on(DeleteObjectCommand).resolves({});
await adapter.delete(key);
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
expect(command.input.Key).toBe(key);
});
});
});