Compare commits
54 Commits
878fc1094b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 98fb2bb530 | |||
|
57a4451e15
|
|||
| 44f072efd2 | |||
|
15578c7801
|
|||
| 7e75ae57d9 | |||
|
ad28d2e04b
|
|||
| 1bd7e909de | |||
|
cca07a91cd
|
|||
| a63c3ad00b | |||
|
433dc0759c
|
|||
| 85e08e8819 | |||
|
3abbba5fbb
|
|||
| b9a07ef6d3 | |||
|
d3d9059d1e
|
|||
| ff8ac894ce | |||
|
e666ddf4c9
|
|||
| 46bcacaf9d | |||
|
2c058020f3
|
|||
| 386affeac3 | |||
|
e2960027f2
|
|||
|
93d66315a1
|
|||
|
26bdb65346
|
|||
|
b9e34e590d
|
|||
|
873e372bad
|
|||
|
4e61e4bff5
|
|||
|
c938974d2b
|
|||
|
79e6fae0f9
|
|||
|
56a5d77c6c
|
|||
| 9d2ce50b26 | |||
|
3addd38bba
|
|||
|
3ea1112369
|
|||
|
af17b6dc5a
|
|||
| 94e8058880 | |||
|
fc9d96f242
|
|||
|
12f2976837
|
|||
|
f62e1c4180
|
|||
|
34a662fb30
|
|||
|
452470161d
|
|||
|
d7036557d0
|
|||
|
dbb459a3a5
|
|||
|
e1c6e9d923
|
|||
|
c4a22a7583
|
|||
|
d99ab44ec2
|
|||
|
7943527106
|
|||
|
242395a739
|
|||
|
4980395bdf
|
|||
|
87a5d82c74
|
|||
|
792627b0f0
|
|||
|
4b1bd056fc
|
|||
|
98515550ca
|
|||
|
fb8c07d32e
|
|||
|
534d206f0e
|
|||
|
ff8df41d4b
|
|||
|
11acc19e64
|
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
FRONTEND_PATH=
|
||||
PROJECT_ORG_SLUG=
|
||||
|
||||
SESSION_SECRET=
|
||||
|
||||
DATABASE_URL=
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
S3_ENDPOINT=
|
||||
S3_BUCKET_NAME=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_PUBLIC_URL=
|
||||
43
.github/workflows/deploy-preview.yml
vendored
Normal file
43
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Vercel Preview Deployment
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'skip-ci')"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment Preview URL on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `🔍 **Preview deployment ready!**\n\n${{ steps.deploy.outputs.url }}`
|
||||
})
|
||||
|
||||
30
.github/workflows/deploy.yml
vendored
Normal file
30
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Vercel Preview Deployment
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'skip-ci')"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
76
CLAUDE.md
Normal file
76
CLAUDE.md
Normal 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_*`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
37
docker/docker-compose.yml
Normal 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
27
docker/garage.toml
Normal 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"
|
||||
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
cacheComponents: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
3446
package-lock.json
generated
3446
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -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",
|
||||
@@ -25,13 +29,17 @@
|
||||
"iron-session": "^8.0.4",
|
||||
"jotai": "^2.19.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.1",
|
||||
"next": "^16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.20.0",
|
||||
"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",
|
||||
|
||||
1
public/img/icons/cover-image.svg
Normal file
1
public/img/icons/cover-image.svg
Normal 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 |
1
public/img/icons/markdown-content.svg
Normal file
1
public/img/icons/markdown-content.svg
Normal 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 |
@@ -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, I’m 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'>
|
||||
I’m a software developer and I’ve always enjoyed programming.
|
||||
Over the years, I’ve 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'm{' '}
|
||||
<span className='font-semibold'>
|
||||
{siteConfig.author.name}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className='mb-4'>
|
||||
For me, programming is more than work. It’s something I like to
|
||||
explore, improve, and keep experimenting with.
|
||||
</p>
|
||||
<div className='space-y-4 leading-relaxed text-muted-foreground'>
|
||||
<p>
|
||||
I'm a software developer and I've always
|
||||
enjoyed programming. Over the years, I'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's
|
||||
something I like to explore, improve, and keep
|
||||
experimenting with.
|
||||
</p>
|
||||
<p>
|
||||
I'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'>
|
||||
I’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 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>
|
||||
);
|
||||
};
|
||||
|
||||
50
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal file
50
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||
import { UpdateArticleForm } from '@/ui/components/internal/update-article-form';
|
||||
import { UUIDv4 } from '@/utils/types/uuid';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
interface UpdateArticlePageProps {
|
||||
params: Promise<{ externalId: string }>;
|
||||
}
|
||||
|
||||
const ArticleFormContent = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ externalId: string }>;
|
||||
}) => {
|
||||
const { externalId } = await params;
|
||||
const result = await getArticleByExternalId(externalId as UUIDv4);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
const article = result.value;
|
||||
if (!article) notFound();
|
||||
|
||||
return <UpdateArticleForm article={article} />;
|
||||
};
|
||||
|
||||
const UpdateArticlePage = ({ params }: UpdateArticlePageProps) => {
|
||||
return (
|
||||
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||
<div className='mb-6'>
|
||||
<Link
|
||||
href='/admin'
|
||||
className='inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
<ArrowLeftIcon className='size-4' />
|
||||
Back to articles
|
||||
</Link>
|
||||
</div>
|
||||
<div className='rounded-lg border border-border p-6'>
|
||||
<h2 className='mb-6 text-2xl font-bold'>Edit Article</h2>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ArticleFormContent params={params} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateArticlePage;
|
||||
14
src/app/(pages)/admin/article/create/page.tsx
Normal file
14
src/app/(pages)/admin/article/create/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CreateArticleForm } from '@/ui/components/internal/create-article-form';
|
||||
|
||||
const CreateArticlePage = () => {
|
||||
return (
|
||||
<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 CreateArticlePage;
|
||||
@@ -1,12 +1,38 @@
|
||||
import { siteConfig } from '@/site.config';
|
||||
import { AdminArticleList } from '@/ui/components/internal/article/admin-article-list';
|
||||
import { AdminArticleListSkeleton } from '@/ui/components/internal/article/admin-article-list-skeleton';
|
||||
import { Button } from '@/ui/components/shadcn/button';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Home = async () => {
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
interface AdminPageProps {
|
||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||
}
|
||||
|
||||
const AdminPage = async ({ searchParams }: AdminPageProps) => {
|
||||
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='mb-8 flex items-center justify-between border-b border-border pb-6'>
|
||||
<h1 className='text-2xl font-bold'>Articles</h1>
|
||||
<Button asChild>
|
||||
<Link href='/admin/article/create'>
|
||||
<PlusIcon className='size-4' />
|
||||
Create Article
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={<AdminArticleListSkeleton skeletonSize={PAGE_SIZE} />}
|
||||
>
|
||||
<AdminArticleList
|
||||
searchParams={searchParams}
|
||||
defaultPageSize={PAGE_SIZE}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
export default AdminPage;
|
||||
|
||||
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;
|
||||
160
src/app/(pages)/article/[slug]/page.tsx
Normal file
160
src/app/(pages)/article/[slug]/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { getArticleBySlug } from '@/lib/feature/article/article.external';
|
||||
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
||||
import { cacheTag } from 'next/cache';
|
||||
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 }>;
|
||||
};
|
||||
|
||||
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 ({ slug }: { slug: string }) => {
|
||||
'use cache';
|
||||
|
||||
cacheTag(`article:slug:${slug}`);
|
||||
|
||||
const articleResult = await getArticleBySlug(slug);
|
||||
if (!articleResult.ok) throw articleResult.error;
|
||||
const article = articleResult.value;
|
||||
|
||||
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 ArticleContentWrapper = async ({ params }: ArticlePageProps) => {
|
||||
const { slug } = await params;
|
||||
return <ArticleContent slug={slug} />;
|
||||
};
|
||||
|
||||
const ArticlePage = (props: ArticlePageProps) => {
|
||||
return (
|
||||
<Suspense fallback={<ArticleContentSkeleton />}>
|
||||
<ArticleContentWrapper {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticlePage;
|
||||
@@ -1,10 +1,40 @@
|
||||
import { siteConfig } from '@/site.config';
|
||||
import { ArticleList } from '@/ui/components/internal/article/article-list';
|
||||
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Home = async () => {
|
||||
const DEFAULT_PAGE_SIZE = 4;
|
||||
|
||||
type HomeProps = {
|
||||
searchParams?: Promise<{ page?: string; pageSize?: string }>;
|
||||
};
|
||||
|
||||
const ArticleListWrapper = async ({ searchParams }: HomeProps) => {
|
||||
const params = await searchParams;
|
||||
|
||||
const page = Number(params?.page) || 1;
|
||||
const pageSize = Number(params?.pageSize) || DEFAULT_PAGE_SIZE;
|
||||
|
||||
return <ArticleList page={page} pageSize={pageSize} />;
|
||||
};
|
||||
|
||||
const Home = async (props: HomeProps) => {
|
||||
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>
|
||||
<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 skeletonSize={DEFAULT_PAGE_SIZE} />
|
||||
}
|
||||
>
|
||||
<ArticleListWrapper {...props} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,8 +15,12 @@ export async function GET() {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const syncedUser = await syncUser(parsedClaims);
|
||||
const syncResult = await syncUser(parsedClaims);
|
||||
if (!syncResult.ok) {
|
||||
console.error(syncResult.error);
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
await setSessionData('user', syncedUser);
|
||||
await setSessionData('user', syncResult.value);
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -8,54 +8,115 @@ import {
|
||||
} from '@/lib/feature/article/article.model';
|
||||
import * as service from '@/lib/feature/article/article.service';
|
||||
import { getSessionData } from '@/lib/session/session-storage';
|
||||
import { TypedResult, wrap } from '@/utils/types/results';
|
||||
import { UUIDv4 } from '@/utils/types/uuid';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
export const getArticleByExternalId = async (
|
||||
export const getArticleByExternalId: (
|
||||
externalId: UUIDv4
|
||||
): Promise<ArticleModel | null> => {
|
||||
return await service.getArticleByExternalId(externalId);
|
||||
};
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<ArticleModel | null> => {
|
||||
const result = await service.getArticleByExternalId(externalId);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
);
|
||||
|
||||
export const getArticleBySlug = async (
|
||||
export const getArticleBySlug: (
|
||||
slug: string
|
||||
): Promise<ArticleModel | null> => {
|
||||
return await service.getArticleBySlug(slug);
|
||||
};
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (slug: string): Promise<ArticleModel | null> => {
|
||||
const result = await service.getArticleBySlug(slug);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
);
|
||||
|
||||
export const getArticlesPaginated = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedArticlesResult> => {
|
||||
return await service.getArticlesPaginated(page, pageSize);
|
||||
};
|
||||
export const getArticlesPaginated: (
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => Promise<TypedResult<PaginatedArticlesResult>> = wrap(
|
||||
async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedArticlesResult> => {
|
||||
const result = await service.getArticlesPaginated(page, pageSize);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
);
|
||||
|
||||
export const saveArticle = async (
|
||||
export const saveArticle: (
|
||||
article: CreateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can save articles.');
|
||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||
async (article: CreateArticleModel): Promise<ArticleModel> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error(
|
||||
'Unauthorized: Only admin users can save articles.'
|
||||
);
|
||||
}
|
||||
article.authorId = session.user.id;
|
||||
|
||||
const result = await service.saveArticle(article);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
revalidateTag('articles', 'max');
|
||||
return result.value;
|
||||
}
|
||||
article.authorId = session.user.id;
|
||||
);
|
||||
|
||||
return await service.saveArticle(article);
|
||||
};
|
||||
|
||||
export const updateArticle = async (
|
||||
articleId: string,
|
||||
export const updateArticleByExternalId: (
|
||||
externalId: UUIDv4,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can save articles.');
|
||||
}
|
||||
return await service.updateArticle(articleId, article);
|
||||
};
|
||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||
async (
|
||||
externalId: UUIDv4,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error(
|
||||
'Unauthorized: Only admin users can save articles.'
|
||||
);
|
||||
}
|
||||
|
||||
export const deleteArticle = async (articleId: string): Promise<void> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||
const result = await service.updateArticleByExternalId(
|
||||
externalId,
|
||||
article
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
revalidateTag('articles', 'max');
|
||||
revalidateTag(`article:${externalId}`, 'max');
|
||||
revalidateTag(`article:slug:${result.value.slug}`, 'max');
|
||||
|
||||
return result.value;
|
||||
}
|
||||
await service.deleteArticle(articleId);
|
||||
};
|
||||
);
|
||||
|
||||
export const deleteArticleByExternalId: (
|
||||
externalId: UUIDv4
|
||||
) => Promise<TypedResult<void>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<void> => {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error(
|
||||
'Unauthorized: Only admin users can delete articles.'
|
||||
);
|
||||
}
|
||||
|
||||
const getResult = await service.getArticleByExternalId(externalId);
|
||||
if (!getResult.ok) throw getResult.error;
|
||||
const article = getResult.value;
|
||||
|
||||
if (!article) throw new Error('Article not found');
|
||||
|
||||
const result = await service.deleteArticleByExternalId(externalId);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
revalidateTag('articles', 'max');
|
||||
revalidateTag(`article:${externalId}`, 'max');
|
||||
revalidateTag(`article:slug:${article.slug}`, 'max');
|
||||
}
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({
|
||||
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
|
||||
|
||||
export const ArticleModel = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string(),
|
||||
@@ -29,6 +28,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>;
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
PaginatedArticlesResult,
|
||||
UpdateArticleModel,
|
||||
} from '@/lib/feature/article/article.model';
|
||||
import { TypedResult, wrap } from '@/utils/types/results';
|
||||
import { UUIDv4 } from '@/utils/types/uuid';
|
||||
|
||||
export const articleEntityToModel = (
|
||||
articleEntity: ArticleEntity
|
||||
): ArticleModel => {
|
||||
return {
|
||||
id: articleEntity.id,
|
||||
title: articleEntity.title,
|
||||
slug: articleEntity.slug,
|
||||
description: articleEntity.description,
|
||||
@@ -20,158 +20,153 @@ export const articleEntityToModel = (
|
||||
content: articleEntity.content,
|
||||
authorId: articleEntity.authorId,
|
||||
externalId: articleEntity.externalId,
|
||||
createdAt: articleEntity.createdAt,
|
||||
updatedAt: articleEntity.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
/** Retrieves an artible by its external ID.
|
||||
* @param externalId - The external ID of the article to retrieve.
|
||||
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
|
||||
* */
|
||||
export const getArticleByExternalId = async (
|
||||
/** Retrieves an article by its external ID. */
|
||||
export const getArticleByExternalId: (
|
||||
externalId: UUIDv4
|
||||
): Promise<ArticleModel | null> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<ArticleModel | null> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const articleEntity = await articleRepository.findOneBy({
|
||||
externalId: externalId,
|
||||
});
|
||||
const articleEntity = await articleRepository.findOneBy({
|
||||
externalId: externalId,
|
||||
});
|
||||
|
||||
if (!articleEntity) {
|
||||
return null;
|
||||
if (!articleEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return articleEntityToModel(articleEntity);
|
||||
}
|
||||
);
|
||||
|
||||
return articleEntityToModel(articleEntity);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves an article by its slug.
|
||||
* @param slug - The slug of the article to retrieve.
|
||||
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
|
||||
*/
|
||||
export const getArticleBySlug = async (
|
||||
/** Retrieves an article by its slug. */
|
||||
export const getArticleBySlug: (
|
||||
slug: string
|
||||
): Promise<ArticleModel | null> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (slug: string): Promise<ArticleModel | null> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const articleEntity = await articleRepository.findOneBy({ slug });
|
||||
const articleEntity = await articleRepository.findOneBy({ slug });
|
||||
|
||||
if (!articleEntity) {
|
||||
return null;
|
||||
if (!articleEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return articleEntityToModel(articleEntity);
|
||||
}
|
||||
);
|
||||
|
||||
return articleEntityToModel(articleEntity);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all articles by a given author ID.
|
||||
* @param authorId - The ID of the author.
|
||||
* @returns {Promise<ArticleModel[]>} A list of article models.
|
||||
*/
|
||||
export const getArticlesByAuthorId = async (
|
||||
/** Retrieves all articles by a given author ID. */
|
||||
export const getArticlesByAuthorId: (
|
||||
authorId: string
|
||||
): Promise<ArticleModel[]> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
) => Promise<TypedResult<ArticleModel[]>> = wrap(
|
||||
async (authorId: string): Promise<ArticleModel[]> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const articleEntities = await articleRepository.findBy({ authorId });
|
||||
const articleEntities = await articleRepository.findBy({ authorId });
|
||||
|
||||
return articleEntities.map(articleEntityToModel);
|
||||
};
|
||||
return articleEntities.map(articleEntityToModel);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of articles ordered by creation date descending.
|
||||
* @param page - The page number (1-based).
|
||||
* @param pageSize - The number of articles per page.
|
||||
* @returns {Promise<PaginatedArticlesResult>} The paginated result.
|
||||
*/
|
||||
export const getArticlesPaginated = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedArticlesResult> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
/** Retrieves a paginated list of articles ordered by creation date descending. */
|
||||
export const getArticlesPaginated: (
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => Promise<TypedResult<PaginatedArticlesResult>> = wrap(
|
||||
async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedArticlesResult> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const [articleEntities, total] = await articleRepository.findAndCount({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
const [articleEntities, total] = await articleRepository.findAndCount({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data: articleEntities.map(articleEntityToModel),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
};
|
||||
return {
|
||||
data: articleEntities.map(articleEntityToModel),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Saves a new article to the database.
|
||||
* @param article - The article data to save.
|
||||
* @returns {Promise<ArticleModel>} The saved article model.
|
||||
* @throws {Error} If an article with the same slug already exists.
|
||||
*/
|
||||
export const saveArticle = async (
|
||||
/** Saves a new article to the database. */
|
||||
export const saveArticle: (
|
||||
article: CreateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||
async (article: CreateArticleModel): Promise<ArticleModel> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
if (!article.authorId) {
|
||||
throw new Error('Author ID is required to save an article');
|
||||
if (!article.authorId) {
|
||||
throw new Error('Author ID is required to save an article');
|
||||
}
|
||||
|
||||
if (!!(await articleRepository.findOneBy({ slug: article.slug }))) {
|
||||
throw new Error(`Article with slug ${article.slug} already exists`);
|
||||
}
|
||||
|
||||
const newArticle = articleRepository.create(article);
|
||||
return articleEntityToModel(await articleRepository.save(newArticle));
|
||||
}
|
||||
);
|
||||
|
||||
if (!!(await articleRepository.findOneBy({ slug: article.slug }))) {
|
||||
throw new Error(`Article with slug ${article.slug} already exists`);
|
||||
}
|
||||
|
||||
const newArticle = articleRepository.create(article);
|
||||
return articleEntityToModel(await articleRepository.save(newArticle));
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing article in the database.
|
||||
* @param articleId - The ID of the article to update.
|
||||
* @param article - The new article data.
|
||||
* @returns {Promise<ArticleModel>} The updated article model.
|
||||
* @throws {Error} If the article with the given ID does not exist.
|
||||
*/
|
||||
export const updateArticle = async (
|
||||
articleId: string,
|
||||
/** Updates an existing article in the database. */
|
||||
export const updateArticleByExternalId: (
|
||||
externalId: string,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||
async (
|
||||
externalId: string,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const existingArticle = await articleRepository.findOneBy({
|
||||
id: articleId,
|
||||
});
|
||||
if (!existingArticle) {
|
||||
throw new Error(`Article with ID ${articleId} not found`);
|
||||
const existingArticle = await articleRepository.findOneBy({
|
||||
externalId: externalId,
|
||||
});
|
||||
if (!existingArticle) {
|
||||
throw new Error(`Article with ID ${externalId} not found`);
|
||||
}
|
||||
|
||||
if (!!article.title) existingArticle.title = article.title;
|
||||
if (!!article.slug) existingArticle.slug = article.slug;
|
||||
if (!!article.description)
|
||||
existingArticle.description = article.description;
|
||||
if (!!article.coverImageUrl)
|
||||
existingArticle.coverImageUrl = article.coverImageUrl;
|
||||
if (!!article.content) existingArticle.content = article.content;
|
||||
|
||||
return articleEntityToModel(
|
||||
await articleRepository.save(existingArticle)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (!!article.title) existingArticle.title = article.title;
|
||||
if (!!article.slug) existingArticle.slug = article.slug;
|
||||
if (!!article.description)
|
||||
existingArticle.description = article.description;
|
||||
if (!!article.coverImageUrl)
|
||||
existingArticle.coverImageUrl = article.coverImageUrl;
|
||||
if (!!article.content) existingArticle.content = article.content;
|
||||
/** Deletes an article from the database. */
|
||||
export const deleteArticleByExternalId: (
|
||||
externalId: UUIDv4
|
||||
) => Promise<TypedResult<void>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<void> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
return articleEntityToModel(await articleRepository.save(existingArticle));
|
||||
};
|
||||
const existingArticle = await articleRepository.findOneBy({
|
||||
externalId: externalId,
|
||||
});
|
||||
if (!existingArticle) {
|
||||
throw new Error(`Article with ExternalID ${externalId} not found`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an article from the database.
|
||||
* @param articleId - The ID of the article to delete.
|
||||
* @throws {Error} If the article with the given ID does not exist.
|
||||
*/
|
||||
export const deleteArticle = async (articleId: string): Promise<void> => {
|
||||
const articleRepository = await getRepository(ArticleEntity);
|
||||
|
||||
const existingArticle = await articleRepository.findOneBy({
|
||||
id: articleId,
|
||||
});
|
||||
if (!existingArticle) {
|
||||
throw new Error(`Article with ID ${articleId} not found`);
|
||||
await articleRepository.remove(existingArticle);
|
||||
}
|
||||
|
||||
await articleRepository.remove(existingArticle);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -2,29 +2,35 @@
|
||||
|
||||
import { getRepository } from '@/lib/db/client';
|
||||
import { UserEntity } from '@/lib/db/entities';
|
||||
import { UserModel } from '@/lib/feature/user/user.model';
|
||||
import { userEntityToModel } from '@/lib/feature/user/user.service';
|
||||
import { getSessionData } from '@/lib/session/session-storage';
|
||||
import { TypedResult, wrap } from '@/utils/types/results';
|
||||
import { UUIDv4 } from '@/utils/types/uuid';
|
||||
|
||||
export const getUserByExternalId = async (externalId: UUIDv4) => {
|
||||
const sessionData = await getSessionData();
|
||||
if (
|
||||
!sessionData ||
|
||||
!sessionData.user ||
|
||||
sessionData.user.externalId !== externalId
|
||||
) {
|
||||
throw new Error('User not permitted to access this data');
|
||||
export const getUserByExternalId: (
|
||||
externalId: UUIDv4
|
||||
) => Promise<TypedResult<UserModel | null>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<UserModel | null> => {
|
||||
const sessionData = await getSessionData();
|
||||
if (
|
||||
!sessionData ||
|
||||
!sessionData.user ||
|
||||
sessionData.user.externalId !== externalId
|
||||
) {
|
||||
throw new Error('User not permitted to access this data');
|
||||
}
|
||||
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
|
||||
const userEntity = await userRepository.findOne({
|
||||
where: { externalId: externalId },
|
||||
});
|
||||
|
||||
if (!userEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userEntityToModel(userEntity);
|
||||
}
|
||||
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
|
||||
const userEntity = await userRepository.findOne({
|
||||
where: { externalId: externalId },
|
||||
});
|
||||
|
||||
if (!userEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userEntityToModel(userEntity);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UpdateUserModel,
|
||||
UserModel,
|
||||
} from '@/lib/feature/user/user.model';
|
||||
import { TypedResult, wrap } from '@/utils/types/results';
|
||||
|
||||
export const userEntityToModel = (userEntity: UserEntity): UserModel => {
|
||||
return {
|
||||
@@ -20,91 +21,101 @@ export const userEntityToModel = (userEntity: UserEntity): UserModel => {
|
||||
/**
|
||||
* Retrieves a user by their email address.
|
||||
* @param email - The email address of the user to retrieve.
|
||||
* @returns {Promise<UserModel | null>} The user model if found, otherwise null.
|
||||
* @returns {Promise<TypedResult<UserModel | null>>} The user model if found, otherwise null.
|
||||
*/
|
||||
export const getUserByEmail = async (
|
||||
email: string
|
||||
): Promise<UserModel | null> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
export const getUserByEmail = wrap(
|
||||
async (email: string): Promise<UserModel | null> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
|
||||
const userEntity = await userRepository.findOneBy({ email });
|
||||
const userEntity = await userRepository.findOneBy({ email });
|
||||
|
||||
if (!userEntity) {
|
||||
return null;
|
||||
if (!userEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userEntityToModel(userEntity);
|
||||
}
|
||||
|
||||
return userEntityToModel(userEntity);
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Saves a new user to the database.
|
||||
* @param user - The user data to save.
|
||||
* @returns {Promise<UserModel>} The saved user model.
|
||||
* @throws {Error} If a user with the same email already exists.
|
||||
* @returns {Promise<TypedResult<UserModel>>} The saved user model.
|
||||
*/
|
||||
export const saveUser = async (user: CreateUserModel): Promise<UserModel> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
export const saveUser = wrap(
|
||||
async (user: CreateUserModel): Promise<UserModel> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
|
||||
if (!!(await userRepository.findOneBy({ email: user.email }))) {
|
||||
throw new Error(`User with email ${user.email} already exists`);
|
||||
if (!!(await userRepository.findOneBy({ email: user.email }))) {
|
||||
throw new Error(`User with email ${user.email} already exists`);
|
||||
}
|
||||
|
||||
const newUser = userRepository.create(user);
|
||||
return userEntityToModel(await userRepository.save(newUser));
|
||||
}
|
||||
|
||||
const newUser = userRepository.create(user);
|
||||
return userEntityToModel(await userRepository.save(newUser));
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates an existing user in the database.
|
||||
* @param userId - The ID of the user to update.
|
||||
* @param user - The new user data.
|
||||
* @returns {Promise<UserModel>} The updated user model.
|
||||
* @throws {Error} If the user with the given ID does not exist.
|
||||
* @returns {Promise<TypedResult<UserModel>>} The updated user model.
|
||||
*/
|
||||
export const updateUser = async (
|
||||
userId: string,
|
||||
user: UpdateUserModel
|
||||
): Promise<UserModel> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
export const updateUser = wrap(
|
||||
async (userId: string, user: UpdateUserModel): Promise<UserModel> => {
|
||||
const userRepository = await getRepository(UserEntity);
|
||||
|
||||
const existingUser = await userRepository.findOneBy({ id: userId });
|
||||
if (!existingUser) {
|
||||
throw new Error(`User with ID ${userId} not found`);
|
||||
const existingUser = await userRepository.findOneBy({ id: userId });
|
||||
if (!existingUser) {
|
||||
throw new Error(`User with ID ${userId} not found`);
|
||||
}
|
||||
|
||||
if (!!user.email) existingUser.email = user.email;
|
||||
if (!!user.name) existingUser.name = user.name;
|
||||
if (!!user.role) existingUser.role = user.role;
|
||||
|
||||
return userEntityToModel(await userRepository.save(existingUser));
|
||||
}
|
||||
|
||||
if (!!user.email) existingUser.email = user.email;
|
||||
if (!!user.name) existingUser.name = user.name;
|
||||
if (!!user.role) existingUser.role = user.role;
|
||||
|
||||
return userEntityToModel(await userRepository.save(existingUser));
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Synchronizes a user with the database.
|
||||
* If the user already exists, it skips saving and returns the existing user.
|
||||
* If the user does not exist, it creates a new user record.
|
||||
* @returns {Promise<UserModel>} The synchronized user model.
|
||||
* @throws {Error} If the user email is not provided or if there is an issue
|
||||
* saving the user.
|
||||
* If the user already exists, updates it; if not, creates a new record.
|
||||
* @param sessionClaims Session Claims from the Auth Provider
|
||||
* @returns {Promise<TypedResult<UserModel>>} The synchronized user model.
|
||||
*/
|
||||
export const syncUser = async (
|
||||
sessionClaims: SessionClaims
|
||||
): Promise<UserModel> => {
|
||||
const { full_name, email } = sessionClaims.user;
|
||||
const role = sessionClaims.user.public_metadata.role;
|
||||
export const syncUser = wrap(
|
||||
async (sessionClaims: SessionClaims): Promise<UserModel> => {
|
||||
const { full_name, email } = sessionClaims.user;
|
||||
const role = sessionClaims.user.public_metadata.role;
|
||||
|
||||
const existingUser = await getUserByEmail(email);
|
||||
if (!existingUser) {
|
||||
return await saveUser({
|
||||
const existingUserResult = await getUserByEmail(email);
|
||||
if (!existingUserResult.ok || !existingUserResult.value) {
|
||||
const saveResult = await saveUser({
|
||||
name: full_name,
|
||||
email: email,
|
||||
role: role,
|
||||
});
|
||||
if (!saveResult.ok) {
|
||||
throw new Error(`User with email ${email} already exists`);
|
||||
}
|
||||
return saveResult.value;
|
||||
}
|
||||
const existingUser = existingUserResult.value;
|
||||
|
||||
const updateResult = await updateUser(existingUser.id, {
|
||||
name: full_name,
|
||||
email: email,
|
||||
email: existingUser.email,
|
||||
role: role,
|
||||
});
|
||||
if (!updateResult.ok) {
|
||||
throw new Error(
|
||||
`Failed to update user with email ${email}: ${updateResult.error}`
|
||||
);
|
||||
}
|
||||
return updateResult.value;
|
||||
}
|
||||
);
|
||||
|
||||
return await updateUser(existingUser.id, {
|
||||
name: full_name,
|
||||
email: existingUser.email,
|
||||
role: role,
|
||||
});
|
||||
};
|
||||
// Explicit re-export for TypeScript consumers who need the result type
|
||||
export type { TypedResult };
|
||||
|
||||
119
src/lib/storage/storage.adapter.ts
Normal file
119
src/lib/storage/storage.adapter.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||
import { TypedResult, wrap } from '@/utils/types/results';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
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(),
|
||||
publicUrl: z.string().optional(),
|
||||
});
|
||||
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 publicUrl: 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.bucketName = config.bucket;
|
||||
this.publicUrl = config.publicUrl || config.endpoint;
|
||||
|
||||
this.s3Client =
|
||||
s3Client ||
|
||||
new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey,
|
||||
},
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
||||
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.publicUrl}/${key}`;
|
||||
}
|
||||
|
||||
private async _put(key: string, contentType: string): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
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,
|
||||
publicUrl: process.env.S3_PUBLIC_URL,
|
||||
});
|
||||
return new S3StorageAdapter(config);
|
||||
};
|
||||
61
src/lib/storage/storage.external.ts
Normal file
61
src/lib/storage/storage.external.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
'use server';
|
||||
|
||||
import { getSessionData } from '@/lib/session/session-storage';
|
||||
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 getPublicUrl = 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;
|
||||
}
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||
}
|
||||
return await storageProvider.exists(key);
|
||||
};
|
||||
|
||||
export const getPutUrl = async (
|
||||
key: string,
|
||||
contentType: string,
|
||||
storageProvider?: StorageProvider
|
||||
): Promise<TypedResult<string>> => {
|
||||
if (!storageProvider) {
|
||||
storageProvider = storage;
|
||||
}
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||
}
|
||||
return await storageProvider.put(key, contentType);
|
||||
};
|
||||
|
||||
export const deleteByKey = async (
|
||||
key: string,
|
||||
storageProvider?: StorageProvider
|
||||
): Promise<TypedResult<void>> => {
|
||||
if (!storageProvider) {
|
||||
storageProvider = storage;
|
||||
}
|
||||
const session = await getSessionData();
|
||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||
}
|
||||
return await storageProvider.delete(key);
|
||||
};
|
||||
13
src/lib/storage/storage.factory.ts
Normal file
13
src/lib/storage/storage.factory.ts
Normal 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;
|
||||
}
|
||||
43
src/lib/storage/storage.interface.ts
Normal file
43
src/lib/storage/storage.interface.ts
Normal 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>>;
|
||||
}
|
||||
9
src/lib/storage/storage.ts
Normal file
9
src/lib/storage/storage.ts
Normal 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';
|
||||
57
src/lib/storage/storage.utils.ts
Normal file
57
src/lib/storage/storage.utils.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
'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.getPublicUrl(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');
|
||||
}
|
||||
console.log(result.value);
|
||||
|
||||
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.getPublicUrl(fileKey);
|
||||
if (!presignedUrl.ok) {
|
||||
throw new Error('Failed to retrieve file URL');
|
||||
}
|
||||
return { signedUrl: presignedUrl.value, key: fileKey };
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server';
|
||||
const isPublic = createRouteMatcher([
|
||||
'/home(.*)?',
|
||||
'/about(.*)?',
|
||||
'/article(.*)?',
|
||||
'/api/user(.*)?',
|
||||
]);
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
90
src/ui/components/internal/article-list-pagination.tsx
Normal file
90
src/ui/components/internal/article-list-pagination.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
src/ui/components/internal/article/admin-article-card.tsx
Normal file
72
src/ui/components/internal/article/admin-article-card.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||
import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
|
||||
import { Button } from '@/ui/components/shadcn/button';
|
||||
import { CalendarIcon, PencilIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface AdminArticleCardProps {
|
||||
article: ArticleModel;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
|
||||
export const AdminArticleCard = ({ article }: AdminArticleCardProps) => {
|
||||
return (
|
||||
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground'>
|
||||
<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'
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<Link
|
||||
href={`/article/${article.slug}`}
|
||||
className='line-clamp-2 text-base font-bold leading-snug tracking-tight hover:text-primary hover:underline underline-offset-4'
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
|
||||
<p className='line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground'>
|
||||
{article.description}
|
||||
</p>
|
||||
|
||||
<div className='mt-2 flex items-center gap-2'>
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<Link href={`/admin/article/${article.externalId}`}>
|
||||
<PencilIcon className='size-4' />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<DeleteArticleButton
|
||||
externalID={article.externalId}
|
||||
title={article.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/ui/components/shadcn/table';
|
||||
|
||||
const AdminArticleRowSkeleton = () => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='size-12 animate-pulse rounded-md bg-muted' />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='h-4 w-40 animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||
<div className='h-3 w-3/4 animate-pulse rounded bg-muted' />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='h-3 w-20 animate-pulse rounded bg-muted' />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<div className='h-7 w-14 animate-pulse rounded-lg bg-muted' />
|
||||
<div className='h-7 w-16 animate-pulse rounded-lg bg-muted' />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
export const AdminArticleListSkeleton = ({
|
||||
skeletonSize,
|
||||
}: {
|
||||
skeletonSize: number;
|
||||
}) => (
|
||||
<>
|
||||
<div className='mb-4 h-4 w-32 animate-pulse rounded bg-muted' />
|
||||
<div className='rounded-lg border border-border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-16'>Cover</TableHead>
|
||||
<TableHead className='w-56'>Title</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className='w-32'>Published</TableHead>
|
||||
<TableHead className='w-32 text-right'>
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: skeletonSize }).map((_, i) => (
|
||||
<AdminArticleRowSkeleton key={i} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
172
src/ui/components/internal/article/admin-article-list.tsx
Normal file
172
src/ui/components/internal/article/admin-article-list.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||
import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
|
||||
import { Button } from '@/ui/components/shadcn/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/ui/components/shadcn/table';
|
||||
import { FileTextIcon, PencilIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type AdminArticleListProps = {
|
||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||
defaultPageSize: number;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
|
||||
export const AdminArticleList = async ({
|
||||
searchParams,
|
||||
defaultPageSize,
|
||||
}: AdminArticleListProps) => {
|
||||
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
const pageSize = Number(pageSizeParam) || defaultPageSize;
|
||||
|
||||
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||
|
||||
if (!paginationResult.ok) {
|
||||
return (
|
||||
<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'>
|
||||
Failed to load articles. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
data: articles,
|
||||
totalPages,
|
||||
total,
|
||||
page: currentPage,
|
||||
} = paginationResult.value;
|
||||
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<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.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className='mb-4 text-sm text-muted-foreground'>
|
||||
{total} article{total === 1 ? '' : 's'} published
|
||||
</p>
|
||||
|
||||
<div className='rounded-lg border border-border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-16'>Cover</TableHead>
|
||||
<TableHead className='w-56'>Title</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className='w-32'>Published</TableHead>
|
||||
<TableHead className='w-32 text-right'>
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{articles.map((article) => (
|
||||
<TableRow key={article.externalId}>
|
||||
<TableCell>
|
||||
<div className='size-12 shrink-0 overflow-hidden rounded-md bg-muted'>
|
||||
{article.coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.coverImageUrl}
|
||||
alt=''
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<span className='font-mono text-xs font-bold text-muted-foreground/30'>
|
||||
{'{}'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className='whitespace-normal'>
|
||||
<Link
|
||||
href={`/article/${article.slug}`}
|
||||
className='font-medium leading-snug hover:text-primary hover:underline underline-offset-4'
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
<p className='mt-0.5 text-xs text-muted-foreground'>
|
||||
/{article.slug}
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className='whitespace-normal'>
|
||||
<p className='line-clamp-2 text-sm text-muted-foreground'>
|
||||
{article.description}
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
<time
|
||||
dateTime={article.createdAt.toISOString()}
|
||||
>
|
||||
{formatDate(article.createdAt)}
|
||||
</time>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Button
|
||||
asChild
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
<Link
|
||||
href={`/admin/article/${article.externalId}`}
|
||||
>
|
||||
<PencilIcon className='size-3.5' />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<DeleteArticleButton
|
||||
externalID={article.externalId}
|
||||
title={article.title}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-6 flex items-center justify-between gap-4'>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<ArticleListPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
baseUrl='/admin'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export 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>
|
||||
);
|
||||
|
||||
export const ArticleListSkeleton = ({
|
||||
skeletonSize,
|
||||
}: {
|
||||
skeletonSize: number;
|
||||
}) => (
|
||||
<>
|
||||
<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: skeletonSize }).map((_, i) => (
|
||||
<ArticleCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
69
src/ui/components/internal/article/article-list.tsx
Normal file
69
src/ui/components/internal/article/article-list.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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 { cacheTag } from 'next/cache';
|
||||
|
||||
type ArticleListProps = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export const ArticleList = async ({ page, pageSize }: ArticleListProps) => {
|
||||
'use cache';
|
||||
|
||||
cacheTag('articles', `articles:page:${page}-${pageSize}`);
|
||||
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||
|
||||
if (!paginationResult.ok) {
|
||||
return (
|
||||
<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'>
|
||||
Failed to load articles. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { data: articles, totalPages, total } = paginationResult.value;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
85
src/ui/components/internal/article/delete-article-button.tsx
Normal file
85
src/ui/components/internal/article/delete-article-button.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { deleteArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/ui/components/shadcn/alert-dialog';
|
||||
import { Button } from '@/ui/components/shadcn/button';
|
||||
import { UUIDv4 } from '@/utils/types/uuid';
|
||||
import { Trash2Icon } from 'lucide-react';
|
||||
import { useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface DeleteArticleButtonProps {
|
||||
externalID: UUIDv4;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const DeleteArticleButton = ({
|
||||
externalID,
|
||||
title,
|
||||
}: DeleteArticleButtonProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = await deleteArticleByExternalId(externalID);
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to delete article', {
|
||||
description: result.error.message,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success('Article deleted', {
|
||||
description: `"${title}" has been removed.`,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='destructive' size='sm' disabled={isPending}>
|
||||
<Trash2Icon className='size-4' />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete article?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete “{title}
|
||||
”. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
301
src/ui/components/internal/create-article-form.tsx
Normal file
301
src/ui/components/internal/create-article-form.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'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>) => {
|
||||
const result = await saveArticle({ ...data });
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to create article', {
|
||||
description: result.error.message,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success('Article created successfully!', {
|
||||
description: `Article "${result.value.title}" has been created.`,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
form.reset();
|
||||
resetFiles();
|
||||
},
|
||||
[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;
|
||||
136
src/ui/components/internal/file-upload-field.tsx
Normal file
136
src/ui/components/internal/file-upload-field.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
298
src/ui/components/internal/update-article-form.tsx
Normal file
298
src/ui/components/internal/update-article-form.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { updateArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||
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';
|
||||
}
|
||||
|
||||
interface UpdateArticleFormProps {
|
||||
article: ArticleModel;
|
||||
}
|
||||
|
||||
export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => {
|
||||
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: article.title,
|
||||
slug: article.slug,
|
||||
description: article.description,
|
||||
coverImageUrl: article.coverImageUrl,
|
||||
content: article.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 handleFormSubmit = useCallback(
|
||||
async (data: z.infer<typeof formSchema>) => {
|
||||
const result = await updateArticleByExternalId(
|
||||
article.externalId,
|
||||
data
|
||||
);
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to update article', {
|
||||
description: result.error.message,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success('Article updated', {
|
||||
description: `"${result.value.title}" has been saved.`,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
},
|
||||
[article.externalId]
|
||||
);
|
||||
|
||||
const handleCoverImageFileChange = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (coverImageUrlRef.current) {
|
||||
URL.revokeObjectURL(coverImageUrlRef.current);
|
||||
coverImageUrlRef.current = null;
|
||||
}
|
||||
setCoverImageFile(file);
|
||||
if (!file) {
|
||||
setCoverImageUploading(false);
|
||||
form.setValue('coverImageUrl', article.coverImageUrl);
|
||||
return;
|
||||
}
|
||||
setCoverImageUploading(true);
|
||||
const fileMetadataResult = await uploadFile(file);
|
||||
setCoverImageUploading(false);
|
||||
if (!fileMetadataResult.ok) {
|
||||
setCoverImageFile(null);
|
||||
form.setValue('coverImageUrl', article.coverImageUrl);
|
||||
toast((fileMetadataResult.error as Error).message);
|
||||
return;
|
||||
}
|
||||
const fileMetadata = fileMetadataResult.value;
|
||||
coverImageUrlRef.current = fileMetadata.signedUrl;
|
||||
form.setValue('coverImageUrl', fileMetadata.signedUrl);
|
||||
},
|
||||
[form, article.coverImageUrl]
|
||||
);
|
||||
|
||||
const handleContentFileChange = useCallback(
|
||||
async (file: File | null) => {
|
||||
setContentFile(file);
|
||||
if (file) {
|
||||
const content = await file.text();
|
||||
form.setValue('content', content);
|
||||
} else {
|
||||
form.setValue('content', article.content);
|
||||
}
|
||||
},
|
||||
[form, article.content]
|
||||
);
|
||||
|
||||
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-update-article'
|
||||
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-update-article-title'>
|
||||
Title
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='form-update-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-update-article-slug'>
|
||||
Slug
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='form-update-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-update-article-description'>
|
||||
Description
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
{...field}
|
||||
id='form-update-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 || article.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'>
|
||||
Update Article
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateArticleForm;
|
||||
163
src/ui/components/shadcn/alert-dialog.tsx
Normal file
163
src/ui/components/shadcn/alert-dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger
|
||||
data-slot='alert-dialog-trigger'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal
|
||||
data-slot='alert-dialog-portal'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot='alert-dialog-overlay'
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot='alert-dialog-content'
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-header'
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-footer'
|
||||
className={cn('flex justify-end gap-2 pt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot='alert-dialog-title'
|
||||
className={cn('text-base font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot='alert-dialog-description'
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot='alert-dialog-action'
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot='alert-dialog-cancel'
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
237
src/ui/components/shadcn/field.tsx
Normal file
237
src/ui/components/shadcn/field.tsx
Normal 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,
|
||||
};
|
||||
1442
src/ui/components/shadcn/file-upload.tsx
Normal file
1442
src/ui/components/shadcn/file-upload.tsx
Normal file
File diff suppressed because it is too large
Load Diff
155
src/ui/components/shadcn/input-group.tsx
Normal file
155
src/ui/components/shadcn/input-group.tsx
Normal 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,
|
||||
};
|
||||
18
src/ui/components/shadcn/input.tsx
Normal file
18
src/ui/components/shadcn/input.tsx
Normal 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 };
|
||||
23
src/ui/components/shadcn/label.tsx
Normal file
23
src/ui/components/shadcn/label.tsx
Normal 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 };
|
||||
115
src/ui/components/shadcn/table.tsx
Normal file
115
src/ui/components/shadcn/table.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='table-container'
|
||||
className='relative w-full overflow-x-auto'
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot='table-header'
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot='table-body'
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot='table-footer'
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot='table-row'
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot='table-head'
|
||||
className={cn(
|
||||
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot='table-cell'
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot='table-caption'
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
17
src/ui/components/shadcn/textarea.tsx
Normal file
17
src/ui/components/shadcn/textarea.tsx
Normal 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 };
|
||||
14
src/ui/hooks/use-as-ref.ts
Normal file
14
src/ui/hooks/use-as-ref.ts
Normal 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 };
|
||||
6
src/ui/hooks/use-isomorphic-layout-effect.ts
Normal file
6
src/ui/hooks/use-isomorphic-layout-effect.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
||||
|
||||
export { useIsomorphicLayoutEffect };
|
||||
13
src/ui/hooks/use-lazy-ref.ts
Normal file
13
src/ui/hooks/use-lazy-ref.ts
Normal 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 };
|
||||
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(),
|
||||
});
|
||||
63
src/utils/types/results.ts
Normal file
63
src/utils/types/results.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { unstable_cache } from 'next/cache';
|
||||
|
||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
export type TypedResult<T> = Result<T, Error>;
|
||||
|
||||
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) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCached<
|
||||
F extends (...args: never[]) => Promise<unknown>,
|
||||
E = unknown,
|
||||
>(
|
||||
fn: F,
|
||||
options: {
|
||||
key: (...args: Parameters<F>) => string[];
|
||||
tags?: (...args: Parameters<F>) => string[];
|
||||
revalidate?: number;
|
||||
},
|
||||
mapError: (e: unknown) => E = (e) => e as E
|
||||
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
|
||||
return async (...args: Parameters<F>) => {
|
||||
try {
|
||||
const cachedFn = unstable_cache(
|
||||
async (...innerArgs: Parameters<F>) => {
|
||||
return await fn(...innerArgs);
|
||||
},
|
||||
options.key(...args),
|
||||
{
|
||||
tags: options.tags?.(...args),
|
||||
revalidate: options.revalidate,
|
||||
}
|
||||
);
|
||||
|
||||
const value = await cachedFn(...args);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: value as Awaited<ReturnType<F>>,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error: mapError(e),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,4 @@
|
||||
type HexChar =
|
||||
| '0'
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '4'
|
||||
| '5'
|
||||
| '6'
|
||||
| '7'
|
||||
| '8'
|
||||
| '9'
|
||||
| 'a'
|
||||
| 'b'
|
||||
| 'c'
|
||||
| 'd'
|
||||
| 'e'
|
||||
| 'f';
|
||||
type UUIDv4Segment<Length extends number> =
|
||||
`${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity
|
||||
import { z } from 'zod';
|
||||
|
||||
export type UUIDv4 =
|
||||
`${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`;
|
||||
export const UUIDv4 = z.uuid();
|
||||
export type UUIDv4 = z.infer<typeof UUIDv4>;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||
import {
|
||||
CreateArticleModel,
|
||||
UpdateArticleModel,
|
||||
} from '@/lib/feature/article/article.model';
|
||||
import {
|
||||
deleteArticle,
|
||||
deleteArticleByExternalId,
|
||||
getArticleByExternalId,
|
||||
getArticleBySlug,
|
||||
getArticlesByAuthorId,
|
||||
getArticlesPaginated,
|
||||
saveArticle,
|
||||
updateArticle,
|
||||
updateArticleByExternalId,
|
||||
} from '@/lib/feature/article/article.service';
|
||||
import { CreateUserModel } from '@/lib/feature/user/user.model';
|
||||
import { saveUser } from '@/lib/feature/user/user.service';
|
||||
@@ -34,7 +34,8 @@ describe('ArticleService', () => {
|
||||
role: 'admin',
|
||||
};
|
||||
const savedAuthor = await saveUser(author);
|
||||
authorId = savedAuthor.id;
|
||||
if (!savedAuthor.ok) throw savedAuthor.error;
|
||||
authorId = savedAuthor.value.id;
|
||||
}, 1_000_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -51,16 +52,17 @@ describe('ArticleService', () => {
|
||||
authorId: authorId,
|
||||
};
|
||||
|
||||
const savedArticle = await saveArticle(articleToSave);
|
||||
const result = await saveArticle(articleToSave);
|
||||
|
||||
expect(savedArticle.id).toBeDefined();
|
||||
expect(savedArticle.title).toBe(articleToSave.title);
|
||||
expect(savedArticle.slug).toBe(articleToSave.slug);
|
||||
expect(savedArticle.description).toBe(articleToSave.description);
|
||||
expect(savedArticle.coverImageUrl).toBe(articleToSave.coverImageUrl);
|
||||
expect(savedArticle.content).toBe(articleToSave.content);
|
||||
expect(savedArticle.authorId).toBe(authorId);
|
||||
expect(savedArticle.externalId).toBeDefined();
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.title).toBe(articleToSave.title);
|
||||
expect(result.value.slug).toBe(articleToSave.slug);
|
||||
expect(result.value.description).toBe(articleToSave.description);
|
||||
expect(result.value.coverImageUrl).toBe(articleToSave.coverImageUrl);
|
||||
expect(result.value.content).toBe(articleToSave.content);
|
||||
expect(result.value.authorId).toBe(authorId);
|
||||
expect(result.value.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot save article with existing slug', async () => {
|
||||
@@ -72,77 +74,98 @@ describe('ArticleService', () => {
|
||||
content: 'Duplicate content.',
|
||||
authorId: authorId,
|
||||
};
|
||||
await expect(saveArticle(articleToSave)).rejects.toThrow(
|
||||
`Article with slug ${articleToSave.slug} already exists`
|
||||
);
|
||||
|
||||
const result = await saveArticle(articleToSave);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain(articleToSave.slug);
|
||||
});
|
||||
|
||||
test('can getArticleBySlug', async () => {
|
||||
const article = await getArticleBySlug('test-article');
|
||||
const result = await getArticleBySlug('test-article');
|
||||
|
||||
expect(article).toBeDefined();
|
||||
expect(article?.slug).toBe('test-article');
|
||||
expect(article?.title).toBe('Test Article');
|
||||
expect(article?.authorId).toBe(authorId);
|
||||
expect(article?.externalId).toBeDefined();
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeDefined();
|
||||
expect(result.value?.slug).toBe('test-article');
|
||||
expect(result.value?.title).toBe('Test Article');
|
||||
expect(result.value?.authorId).toBe(authorId);
|
||||
expect(result.value?.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot getArticleBySlug with non-existing slug', async () => {
|
||||
await expect(getArticleBySlug('non-existing-slug')).resolves.toBeNull();
|
||||
const result = await getArticleBySlug('non-existing-slug');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeNull();
|
||||
});
|
||||
|
||||
test('can getArticlesByAuthorId', async () => {
|
||||
const articles = await getArticlesByAuthorId(authorId);
|
||||
const result = await getArticlesByAuthorId(authorId);
|
||||
|
||||
expect(articles).toBeDefined();
|
||||
expect(articles.length).toBeGreaterThanOrEqual(1);
|
||||
expect(articles[0].authorId).toBe(authorId);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeDefined();
|
||||
expect(result.value.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.value[0].authorId).toBe(authorId);
|
||||
});
|
||||
|
||||
test('getArticlesByAuthorId returns empty for non-existing author', async () => {
|
||||
const articles = await getArticlesByAuthorId('9999');
|
||||
const result = await getArticlesByAuthorId('9999');
|
||||
|
||||
expect(articles).toBeDefined();
|
||||
expect(articles.length).toBe(0);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeDefined();
|
||||
expect(result.value.length).toBe(0);
|
||||
});
|
||||
|
||||
test('can getArticleByExternalId', async () => {
|
||||
const article = await getArticleBySlug('test-article');
|
||||
const slugResult = await getArticleBySlug('test-article');
|
||||
expect(slugResult.ok).toBe(true);
|
||||
if (!slugResult.ok) return;
|
||||
const article = slugResult.value;
|
||||
expect(article).toBeDefined();
|
||||
|
||||
const foundArticle = await getArticleByExternalId(
|
||||
const result = await getArticleByExternalId(
|
||||
article!.externalId as UUIDv4
|
||||
);
|
||||
|
||||
expect(foundArticle).toBeDefined();
|
||||
expect(foundArticle?.id).toBe(article!.id);
|
||||
expect(foundArticle?.title).toBe(article!.title);
|
||||
expect(foundArticle?.slug).toBe(article!.slug);
|
||||
expect(foundArticle?.externalId).toBe(article!.externalId);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeDefined();
|
||||
expect(result.value?.title).toBe(article!.title);
|
||||
expect(result.value?.slug).toBe(article!.slug);
|
||||
expect(result.value?.externalId).toBe(article!.externalId);
|
||||
});
|
||||
|
||||
test('getArticleByExternalId returns null for non-existing id', async () => {
|
||||
const result = await getArticleByExternalId(
|
||||
'00000000-0000-4000-a000-000000000000' as UUIDv4
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeNull();
|
||||
});
|
||||
|
||||
test('can getArticlesPaginated with defaults', async () => {
|
||||
const result = await getArticlesPaginated();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
expect(result.totalPages).toBeGreaterThanOrEqual(1);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.value.page).toBe(1);
|
||||
expect(result.value.pageSize).toBe(10);
|
||||
expect(result.value.total).toBeGreaterThanOrEqual(1);
|
||||
expect(result.value.totalPages).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('can getArticlesPaginated with custom page size', async () => {
|
||||
// Save extra articles to test pagination
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await saveArticle({
|
||||
const r = await saveArticle({
|
||||
title: `Paginated Article ${i}`,
|
||||
slug: `paginated-article-${i}`,
|
||||
description: `Description ${i}`,
|
||||
@@ -150,29 +173,36 @@ describe('ArticleService', () => {
|
||||
content: `Content ${i}`,
|
||||
authorId: authorId,
|
||||
});
|
||||
if (!r.ok) throw r.error;
|
||||
}
|
||||
|
||||
const firstPage = await getArticlesPaginated(1, 2);
|
||||
const firstPageResult = await getArticlesPaginated(1, 2);
|
||||
|
||||
expect(firstPage.data.length).toBe(2);
|
||||
expect(firstPage.page).toBe(1);
|
||||
expect(firstPage.pageSize).toBe(2);
|
||||
expect(firstPage.total).toBeGreaterThanOrEqual(4);
|
||||
expect(firstPage.totalPages).toBeGreaterThanOrEqual(2);
|
||||
expect(firstPageResult.ok).toBe(true);
|
||||
if (!firstPageResult.ok) return;
|
||||
expect(firstPageResult.value.data.length).toBe(2);
|
||||
expect(firstPageResult.value.page).toBe(1);
|
||||
expect(firstPageResult.value.pageSize).toBe(2);
|
||||
expect(firstPageResult.value.total).toBeGreaterThanOrEqual(4);
|
||||
expect(firstPageResult.value.totalPages).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const secondPage = await getArticlesPaginated(2, 2);
|
||||
const secondPageResult = await getArticlesPaginated(2, 2);
|
||||
|
||||
expect(secondPage.data.length).toBe(2);
|
||||
expect(secondPage.page).toBe(2);
|
||||
expect(secondPage.pageSize).toBe(2);
|
||||
expect(secondPageResult.ok).toBe(true);
|
||||
if (!secondPageResult.ok) return;
|
||||
expect(secondPageResult.value.data.length).toBe(2);
|
||||
expect(secondPageResult.value.page).toBe(2);
|
||||
expect(secondPageResult.value.pageSize).toBe(2);
|
||||
});
|
||||
|
||||
test('can getArticlesPaginated returns empty on out-of-range page', async () => {
|
||||
const result = await getArticlesPaginated(999, 10);
|
||||
|
||||
expect(result.data.length).toBe(0);
|
||||
expect(result.page).toBe(999);
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.data.length).toBe(0);
|
||||
expect(result.value.page).toBe(999);
|
||||
expect(result.value.total).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('can update article', async () => {
|
||||
@@ -181,18 +211,25 @@ describe('ArticleService', () => {
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
const article = await getArticleBySlug('test-article');
|
||||
const slugResult = await getArticleBySlug('test-article');
|
||||
expect(slugResult.ok).toBe(true);
|
||||
if (!slugResult.ok) return;
|
||||
const article = slugResult.value;
|
||||
expect(article).toBeDefined();
|
||||
|
||||
const updatedArticle = await updateArticle(article!.id, dataToUpdate);
|
||||
const result = await updateArticleByExternalId(
|
||||
article!.externalId,
|
||||
dataToUpdate
|
||||
);
|
||||
|
||||
expect(updatedArticle).toBeDefined();
|
||||
expect(updatedArticle.id).toBe(article!.id);
|
||||
expect(updatedArticle.title).toBe(dataToUpdate.title);
|
||||
expect(updatedArticle.description).toBe(dataToUpdate.description);
|
||||
expect(updatedArticle.slug).toBe(article!.slug);
|
||||
expect(updatedArticle.content).toBe(article!.content);
|
||||
expect(updatedArticle.externalId).toBeDefined();
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeDefined();
|
||||
expect(result.value.title).toBe(dataToUpdate.title);
|
||||
expect(result.value.description).toBe(dataToUpdate.description);
|
||||
expect(result.value.slug).toBe(article!.slug);
|
||||
expect(result.value.content).toBe(article!.content);
|
||||
expect(result.value.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot update non-existing article', async () => {
|
||||
@@ -200,9 +237,11 @@ describe('ArticleService', () => {
|
||||
title: 'Updated Article Title',
|
||||
};
|
||||
|
||||
await expect(updateArticle('9999', dataToUpdate)).rejects.toThrow(
|
||||
`Article with ID 9999 not found`
|
||||
);
|
||||
const result = await updateArticleByExternalId('9999', dataToUpdate);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain('9999');
|
||||
});
|
||||
|
||||
test('can delete article', async () => {
|
||||
@@ -215,18 +254,27 @@ describe('ArticleService', () => {
|
||||
authorId: authorId,
|
||||
};
|
||||
|
||||
const savedArticle = await saveArticle(articleToSave);
|
||||
expect(savedArticle.id).toBeDefined();
|
||||
const saveResult = await saveArticle(articleToSave);
|
||||
expect(saveResult.ok).toBe(true);
|
||||
if (!saveResult.ok) return;
|
||||
expect(saveResult.value.externalId).toBeDefined();
|
||||
|
||||
await deleteArticle(savedArticle.id);
|
||||
const deleteResult = await deleteArticleByExternalId(
|
||||
saveResult.value.externalId
|
||||
);
|
||||
expect(deleteResult.ok).toBe(true);
|
||||
|
||||
const deletedArticle = await getArticleBySlug('article-to-delete');
|
||||
expect(deletedArticle).toBeNull();
|
||||
const getResult = await getArticleBySlug('article-to-delete');
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (!getResult.ok) return;
|
||||
expect(getResult.value).toBeNull();
|
||||
});
|
||||
|
||||
test('cannot delete non-existing article', async () => {
|
||||
await expect(deleteArticle('9999')).rejects.toThrow(
|
||||
`Article with ID 9999 not found`
|
||||
);
|
||||
const result = await deleteArticleByExternalId('9999');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain('9999');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,14 +35,15 @@ describe('UserService', () => {
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const savedUser = await saveUser(userToSave);
|
||||
const result = await saveUser(userToSave);
|
||||
|
||||
expect(savedUser.id).toBeDefined();
|
||||
expect(savedUser.name).toBe(userToSave.name);
|
||||
expect(savedUser.email).toBe(userToSave.email);
|
||||
expect(savedUser.role).toBe(userToSave.role);
|
||||
expect(savedUser.role).toBe(userToSave.role);
|
||||
expect(savedUser.externalId).toBeDefined(); // Default to true if not set
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.id).toBeDefined();
|
||||
expect(result.value.name).toBe(userToSave.name);
|
||||
expect(result.value.email).toBe(userToSave.email);
|
||||
expect(result.value.role).toBe(userToSave.role);
|
||||
expect(result.value.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot save user with existing email', async () => {
|
||||
@@ -51,23 +52,31 @@ describe('UserService', () => {
|
||||
email: 'test@email.com',
|
||||
role: 'user',
|
||||
};
|
||||
await expect(saveUser(userToSave)).rejects.toThrow(
|
||||
`User with email ${userToSave.email} already exists`
|
||||
);
|
||||
|
||||
const result = await saveUser(userToSave);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain(userToSave.email);
|
||||
});
|
||||
|
||||
test('can getUserByEmail', async () => {
|
||||
const user = await getUserByEmail('test@email.com');
|
||||
const result = await getUserByEmail('test@email.com');
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.email).toBe('test@email.com');
|
||||
expect(user?.name).toBe('Test User');
|
||||
expect(user?.role).toBe('user');
|
||||
expect(user?.externalId).toBeDefined(); // Default to true if not set
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value?.email).toBe('test@email.com');
|
||||
expect(result.value?.name).toBe('Test User');
|
||||
expect(result.value?.role).toBe('user');
|
||||
expect(result.value?.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot getUserByEmail with non-existing email', async () => {
|
||||
await expect(getUserByEmail('missing@email.com')).resolves.toBeNull();
|
||||
const result = await getUserByEmail('missing@email.com');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeNull();
|
||||
});
|
||||
|
||||
test('can update user', async () => {
|
||||
@@ -76,17 +85,21 @@ describe('UserService', () => {
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
const user = await getUserByEmail('test@email.com');
|
||||
const userResult = await getUserByEmail('test@email.com');
|
||||
expect(userResult.ok).toBe(true);
|
||||
if (!userResult.ok) return;
|
||||
const user = userResult.value;
|
||||
expect(user).toBeDefined();
|
||||
|
||||
const updatedUser = await updateUser(user!.id, dataToUpdate);
|
||||
const result = await updateUser(user!.id, dataToUpdate);
|
||||
|
||||
expect(updatedUser).toBeDefined();
|
||||
expect(updatedUser.id).toBe(user!.id);
|
||||
expect(updatedUser.name).toBe(dataToUpdate.name);
|
||||
expect(updatedUser.role).toBe(dataToUpdate.role);
|
||||
expect(updatedUser.email).toBe(user!.email);
|
||||
expect(updatedUser.externalId).toBeDefined(); // Default to true if not set
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.id).toBe(user!.id);
|
||||
expect(result.value.name).toBe(dataToUpdate.name);
|
||||
expect(result.value.role).toBe(dataToUpdate.role);
|
||||
expect(result.value.email).toBe(user!.email);
|
||||
expect(result.value.externalId).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot update non-existing user', async () => {
|
||||
@@ -95,9 +108,11 @@ describe('UserService', () => {
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
await expect(updateUser('9999', dataToUpdate)).rejects.toThrow(
|
||||
`User with ID 9999 not found`
|
||||
);
|
||||
const result = await updateUser('9999', dataToUpdate);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain('9999');
|
||||
});
|
||||
|
||||
test('can sync admin user', async () => {
|
||||
@@ -131,12 +146,14 @@ describe('UserService', () => {
|
||||
},
|
||||
},
|
||||
} as SessionClaims;
|
||||
const syncedUser = await syncUser(sessionClaims);
|
||||
|
||||
expect(syncedUser).toBeDefined();
|
||||
expect(syncedUser.name).toBe('Updated Name');
|
||||
expect(syncedUser.email).toBe('test@email.com');
|
||||
expect(syncedUser.role).toBe('admin');
|
||||
const result = await syncUser(sessionClaims);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.name).toBe('Updated Name');
|
||||
expect(result.value.email).toBe('test@email.com');
|
||||
expect(result.value.role).toBe('admin');
|
||||
});
|
||||
|
||||
test('can sync internal user', async () => {
|
||||
@@ -170,12 +187,14 @@ describe('UserService', () => {
|
||||
},
|
||||
},
|
||||
} as SessionClaims;
|
||||
const syncedUser = await syncUser(sessionClaims);
|
||||
|
||||
expect(syncedUser).toBeDefined();
|
||||
expect(syncedUser.name).toBe('Updated Name');
|
||||
expect(syncedUser.email).toBe('test@email.com');
|
||||
expect(syncedUser.role).toBe('internal');
|
||||
const result = await syncUser(sessionClaims);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.name).toBe('Updated Name');
|
||||
expect(result.value.email).toBe('test@email.com');
|
||||
expect(result.value.role).toBe('internal');
|
||||
});
|
||||
|
||||
test('can sync user', async () => {
|
||||
@@ -202,12 +221,14 @@ describe('UserService', () => {
|
||||
},
|
||||
},
|
||||
} as SessionClaims;
|
||||
const syncedUser = await syncUser(sessionClaims);
|
||||
|
||||
expect(syncedUser).toBeDefined();
|
||||
expect(syncedUser.name).toBe('Updated Name');
|
||||
expect(syncedUser.email).toBe('test@email.com');
|
||||
expect(syncedUser.role).toBe('user');
|
||||
const result = await syncUser(sessionClaims);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.name).toBe('Updated Name');
|
||||
expect(result.value.email).toBe('test@email.com');
|
||||
expect(result.value.role).toBe('user');
|
||||
});
|
||||
|
||||
test('can sync saving new user', async () => {
|
||||
@@ -234,11 +255,13 @@ describe('UserService', () => {
|
||||
},
|
||||
},
|
||||
} as SessionClaims;
|
||||
const syncedUser = await syncUser(sessionClaims);
|
||||
|
||||
expect(syncedUser).toBeDefined();
|
||||
expect(syncedUser.name).toBe('Updated Name');
|
||||
expect(syncedUser.email).toBe('new@email.com');
|
||||
expect(syncedUser.role).toBe('user');
|
||||
const result = await syncUser(sessionClaims);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.name).toBe('Updated Name');
|
||||
expect(result.value.email).toBe('new@email.com');
|
||||
expect(result.value.role).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
166
tests/lib/storage/storage.s3.test.ts
Normal file
166
tests/lib/storage/storage.s3.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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',
|
||||
publicUrl: 'http://test.com',
|
||||
};
|
||||
|
||||
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://test.com/${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://test.com/${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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user