Compare commits
6 Commits
16cda78d10
...
9c0006e2dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
9c0006e2dc
|
|||
|
a3ea9e8dc8
|
|||
|
f494e243d0
|
|||
|
324653dd2a
|
|||
| 24a076c2c1 | |||
|
164745c6c0
|
@@ -5,7 +5,7 @@ services:
|
|||||||
image: postgres:16
|
image: postgres:16
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: absolute
|
POSTGRES_DB: local_db
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ CREATE TABLE users (
|
|||||||
role user_role NOT NULL DEFAULT 'user',
|
role user_role NOT NULL DEFAULT 'user',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
external_id UUID NOT NULL DEFAULT gen_random_uuid()
|
external_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE
|
||||||
);
|
);
|
||||||
--end-sql`);
|
--end-sql`);
|
||||||
}
|
}
|
||||||
|
|||||||
28
migrations/1775010269415-adds-articles-table.ts
Normal file
28
migrations/1775010269415-adds-articles-table.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddsArticlesTable1775010269415 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`--sql
|
||||||
|
CREATE TABLE articles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
cover_image_url TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
author_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
external_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
||||||
|
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--end-sql`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`--sql
|
||||||
|
DROP TABLE articles;
|
||||||
|
--end-sql`);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/1775011588385-adds-base-triggers.ts
Normal file
33
migrations/1775011588385-adds-base-triggers.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddsBaseTriggers1775011588385 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER set_articles_updated_at
|
||||||
|
BEFORE UPDATE ON articles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER set_users_updated_at
|
||||||
|
BEFORE UPDATE on users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
--end-sql`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`--sql
|
||||||
|
DROP TRIGGER set_articles_updated_at ON articles;
|
||||||
|
DROP TRIGGER set_users_updated_at ON users;
|
||||||
|
DROP FUNCTION set_updated_at();
|
||||||
|
--end-sql`);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^7.0.7",
|
"@clerk/nextjs": "^7.0.7",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -22,7 +23,9 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
"shadcn": "^4.1.1",
|
"shadcn": "^4.1.1",
|
||||||
|
"slugify": "^1.6.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -1281,6 +1284,18 @@
|
|||||||
"hono": "^4"
|
"hono": "^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/utils": "^0.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.55.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -4520,6 +4535,12 @@
|
|||||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -13796,6 +13817,22 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz",
|
||||||
|
"integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -14668,6 +14705,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/slugify": {
|
||||||
|
"version": "1.6.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz",
|
||||||
|
"integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^7.0.7",
|
"@clerk/nextjs": "^7.0.7",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -31,7 +32,9 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
"shadcn": "^4.1.1",
|
"shadcn": "^4.1.1",
|
||||||
|
"slugify": "^1.6.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -82,6 +85,7 @@
|
|||||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||||
},
|
},
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
"\\.(png|jpg|jpeg|gif|webp|svg|ico)$": "<rootDir>/tests/setup/__mocks__/fileMock.js",
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
"^~/(.*)$": "<rootDir>/$1"
|
"^~/(.*)$": "<rootDir>/$1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import { siteConfig } from '@/site.config';
|
const AboutPage = async () => {
|
||||||
|
|
||||||
const Home = async () => {
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center'>
|
<div className='max-w-3xl mx-auto px-4 py-10'>
|
||||||
<h1 className='mb-4 text-4xl font-bold'>About</h1>
|
<p className='mb-4'>Hi, I’m Vitor Hideyoshi.</p>
|
||||||
<p className='text-lg'>Welcome {siteConfig.name}!</p>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<p className='mb-4'>
|
||||||
|
For me, programming is more than work. It’s something I like to
|
||||||
|
explore, improve, and keep experimenting with.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
export default AboutPage;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { siteConfig } from '@/site.config';
|
import CreateArticleForm from '@/ui/components/internal/create-article-form';
|
||||||
|
|
||||||
const Home = async () => {
|
const AdminPage = async () => {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center'>
|
<div className='container mx-auto py-10 min-h-3/4'>
|
||||||
<h1 className='mb-4 text-4xl font-bold'>Admin</h1>
|
<div className='h-full rounded-lg border border-border bg-card p-6'>
|
||||||
<p className='text-lg'>Welcome {siteConfig.name}!</p>
|
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
||||||
|
<CreateArticleForm />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
export default AdminPage;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { ArticleEntity } from '@/lib/feature/article/article.entity';
|
||||||
export { UserEntity } from '@/lib/feature/user/user.entity';
|
export { UserEntity } from '@/lib/feature/user/user.entity';
|
||||||
|
|||||||
58
src/lib/feature/article/article.entity.ts
Normal file
58
src/lib/feature/article/article.entity.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { UserEntity } from '@/lib/feature/user/user.entity';
|
||||||
|
import type { Relation } from '@/utils/types/relation';
|
||||||
|
import type { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('articles')
|
||||||
|
export class ArticleEntity {
|
||||||
|
@PrimaryColumn({
|
||||||
|
type: 'bigint',
|
||||||
|
primary: true,
|
||||||
|
generated: true,
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, unique: true })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cover_image_url', type: 'text' })
|
||||||
|
coverImageUrl: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ name: 'author_id', type: 'bigint' })
|
||||||
|
authorId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'author_id' })
|
||||||
|
author: Relation<UserEntity>;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'created_at',
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
default: () => 'now()',
|
||||||
|
})
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
default: () => 'now()',
|
||||||
|
})
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'external_id',
|
||||||
|
type: 'uuid',
|
||||||
|
unique: true,
|
||||||
|
default: () => 'gen_random_uuid()',
|
||||||
|
})
|
||||||
|
externalId: UUIDv4;
|
||||||
|
}
|
||||||
61
src/lib/feature/article/article.external.ts
Normal file
61
src/lib/feature/article/article.external.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
CreateArticleModel,
|
||||||
|
PaginatedArticlesResult,
|
||||||
|
UpdateArticleModel,
|
||||||
|
} from '@/lib/feature/article/article.model';
|
||||||
|
import * as service from '@/lib/feature/article/article.service';
|
||||||
|
import { getSessionData } from '@/lib/session/session-storage';
|
||||||
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
|
||||||
|
export const getArticleByExternalId = async (
|
||||||
|
externalId: UUIDv4
|
||||||
|
): Promise<ArticleModel | null> => {
|
||||||
|
return await service.getArticleByExternalId(externalId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArticleBySlug = async (
|
||||||
|
slug: string
|
||||||
|
): Promise<ArticleModel | null> => {
|
||||||
|
return await service.getArticleBySlug(slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArticlesPaginated = async (
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10
|
||||||
|
): Promise<PaginatedArticlesResult> => {
|
||||||
|
return await service.getArticlesPaginated(page, pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveArticle = 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;
|
||||||
|
|
||||||
|
return await service.saveArticle(article);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateArticle = async (
|
||||||
|
articleId: string,
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
await service.deleteArticle(articleId);
|
||||||
|
};
|
||||||
45
src/lib/feature/article/article.model.ts
Normal file
45
src/lib/feature/article/article.model.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Pagination } from '@/utils/types/pagination';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CreateArticleModel = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
coverImageUrl: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
authorId: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type CreateArticleModel = z.infer<typeof CreateArticleModel>;
|
||||||
|
|
||||||
|
export const UpdateArticleModel = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
coverImageUrl: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
|
||||||
|
|
||||||
|
export const ArticleModel = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
coverImageUrl: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
authorId: z.string(),
|
||||||
|
externalId: z.uuid(),
|
||||||
|
});
|
||||||
|
export type ArticleModel = z.infer<typeof ArticleModel>;
|
||||||
|
|
||||||
|
export const ArticleInfoModel = z.object({
|
||||||
|
externalId: z.uuid(),
|
||||||
|
title: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
coverImageUrl: z.string(),
|
||||||
|
});
|
||||||
|
export type ArticleInfoModel = z.infer<typeof ArticleInfoModel>;
|
||||||
|
|
||||||
|
export const PaginatedArticlesResult = Pagination(ArticleModel);
|
||||||
|
export type PaginatedArticlesResult = z.infer<typeof PaginatedArticlesResult>;
|
||||||
177
src/lib/feature/article/article.service.ts
Normal file
177
src/lib/feature/article/article.service.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { getRepository } from '@/lib/db/client';
|
||||||
|
import { ArticleEntity } from '@/lib/feature/article/article.entity';
|
||||||
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
CreateArticleModel,
|
||||||
|
PaginatedArticlesResult,
|
||||||
|
UpdateArticleModel,
|
||||||
|
} from '@/lib/feature/article/article.model';
|
||||||
|
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,
|
||||||
|
coverImageUrl: articleEntity.coverImageUrl,
|
||||||
|
content: articleEntity.content,
|
||||||
|
authorId: articleEntity.authorId,
|
||||||
|
externalId: articleEntity.externalId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
externalId: UUIDv4
|
||||||
|
): Promise<ArticleModel | null> => {
|
||||||
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
|
const articleEntity = await articleRepository.findOneBy({
|
||||||
|
externalId: externalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!articleEntity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
slug: string
|
||||||
|
): Promise<ArticleModel | null> => {
|
||||||
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
|
const articleEntity = await articleRepository.findOneBy({ slug });
|
||||||
|
|
||||||
|
if (!articleEntity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
authorId: string
|
||||||
|
): Promise<ArticleModel[]> => {
|
||||||
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
|
const articleEntities = await articleRepository.findBy({ authorId });
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
article: CreateArticleModel
|
||||||
|
): Promise<ArticleModel> => {
|
||||||
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
};
|
||||||
197
src/ui/components/internal/create-article-form.tsx
Normal file
197
src/ui/components/internal/create-article-form.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { saveArticle } from '@/lib/feature/article/article.external';
|
||||||
|
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 { useEffect } from 'react';
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import slugify from 'slugify';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const CreateArticleForm = () => {
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(3).max(255),
|
||||||
|
slug: z.string().min(3),
|
||||||
|
description: z.string().min(10),
|
||||||
|
coverImageUrl: z.string().url(),
|
||||||
|
content: z.string().min(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
coverImageUrl: '',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: 'title',
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
form.setValue('slug', slugify(title).toLowerCase());
|
||||||
|
}, [form, title]);
|
||||||
|
|
||||||
|
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||||
|
try {
|
||||||
|
const result = await saveArticle(data);
|
||||||
|
toast.success('Article created successfully!', {
|
||||||
|
description: `Article "${result.title}" has been created.`,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to create article', {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'An error occurred',
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id='form-create-article' onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FieldGroup>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name='coverImageUrl'
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor='form-create-article-cover-image-url'>
|
||||||
|
Cover Image URL
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id='form-create-article-cover-image-url'
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder='https://example.com/image.jpg'
|
||||||
|
type='url'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name='content'
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor='form-create-article-content'>
|
||||||
|
Content
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupTextarea
|
||||||
|
{...field}
|
||||||
|
id='form-create-article-content'
|
||||||
|
placeholder='Write your article content here...'
|
||||||
|
rows={12}
|
||||||
|
className='min-h-48 resize-none font-mono text-sm'
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{fieldState.invalid && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className='flex w-full justify-end'>
|
||||||
|
<Button type='submit' className='mt-6'>
|
||||||
|
Create Article
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateArticleForm;
|
||||||
@@ -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',
|
'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",
|
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",
|
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: 'size-8',
|
||||||
'icon-xs':
|
'icon-xs':
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
|||||||
102
src/ui/components/shadcn/card.tsx
Normal file
102
src/ui/components/shadcn/card.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card'
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-header'
|
||||||
|
className={cn(
|
||||||
|
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-title'
|
||||||
|
className={cn(
|
||||||
|
'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-description'
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-action'
|
||||||
|
className={cn(
|
||||||
|
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-content'
|
||||||
|
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='card-footer'
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
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,
|
||||||
|
};
|
||||||
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 };
|
||||||
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 };
|
||||||
13
src/utils/types/pagination.ts
Normal file
13
src/utils/types/pagination.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const Pagination = <ItemType extends z.ZodTypeAny>(
|
||||||
|
itemType: ItemType
|
||||||
|
) => {
|
||||||
|
return z.object({
|
||||||
|
data: z.array(itemType),
|
||||||
|
total: z.number(),
|
||||||
|
page: z.number(),
|
||||||
|
pageSize: z.number(),
|
||||||
|
totalPages: z.number(),
|
||||||
|
});
|
||||||
|
};
|
||||||
232
tests/lib/feature/article/article.service.test.ts
Normal file
232
tests/lib/feature/article/article.service.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||||
|
import {
|
||||||
|
CreateArticleModel,
|
||||||
|
UpdateArticleModel,
|
||||||
|
} from '@/lib/feature/article/article.model';
|
||||||
|
import {
|
||||||
|
deleteArticle,
|
||||||
|
getArticleByExternalId,
|
||||||
|
getArticleBySlug,
|
||||||
|
getArticlesByAuthorId,
|
||||||
|
saveArticle,
|
||||||
|
updateArticle,
|
||||||
|
} from '@/lib/feature/article/article.service';
|
||||||
|
import { CreateUserModel } from '@/lib/feature/user/user.model';
|
||||||
|
import { saveUser } from '@/lib/feature/user/user.service';
|
||||||
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { AbstractStartedContainer } from 'testcontainers';
|
||||||
|
import { startTestDB } from '~/tests/setup/setup-db';
|
||||||
|
|
||||||
|
jest.mock('@clerk/nextjs/server', () => ({
|
||||||
|
clerkClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ArticleService', () => {
|
||||||
|
let container: AbstractStartedContainer;
|
||||||
|
let authorId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
container = await startTestDB();
|
||||||
|
|
||||||
|
const author: CreateUserModel = {
|
||||||
|
name: 'Article Author',
|
||||||
|
email: 'author@email.com',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
const savedAuthor = await saveUser(author);
|
||||||
|
authorId = savedAuthor.id;
|
||||||
|
}, 1_000_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await container.stop();
|
||||||
|
}, 1_000_000);
|
||||||
|
|
||||||
|
test('can save article', async () => {
|
||||||
|
const articleToSave: CreateArticleModel = {
|
||||||
|
title: 'Test Article',
|
||||||
|
slug: 'test-article',
|
||||||
|
description: 'A test article description',
|
||||||
|
coverImageUrl: 'https://example.com/cover.png',
|
||||||
|
content: 'This is the article content.',
|
||||||
|
authorId: authorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedArticle = 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot save article with existing slug', async () => {
|
||||||
|
const articleToSave: CreateArticleModel = {
|
||||||
|
title: 'Duplicate Slug Article',
|
||||||
|
slug: 'test-article',
|
||||||
|
description: 'Another article with the same slug',
|
||||||
|
coverImageUrl: 'https://example.com/cover2.png',
|
||||||
|
content: 'Duplicate content.',
|
||||||
|
authorId: authorId,
|
||||||
|
};
|
||||||
|
await expect(saveArticle(articleToSave)).rejects.toThrow(
|
||||||
|
`Article with slug ${articleToSave.slug} already exists`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can getArticleBySlug', async () => {
|
||||||
|
const article = 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot getArticleBySlug with non-existing slug', async () => {
|
||||||
|
await expect(getArticleBySlug('non-existing-slug')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can getArticlesByAuthorId', async () => {
|
||||||
|
const articles = await getArticlesByAuthorId(authorId);
|
||||||
|
|
||||||
|
expect(articles).toBeDefined();
|
||||||
|
expect(articles.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(articles[0].authorId).toBe(authorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getArticlesByAuthorId returns empty for non-existing author', async () => {
|
||||||
|
const articles = await getArticlesByAuthorId('9999');
|
||||||
|
|
||||||
|
expect(articles).toBeDefined();
|
||||||
|
expect(articles.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can getArticleByExternalId', async () => {
|
||||||
|
const article = await getArticleBySlug('test-article');
|
||||||
|
expect(article).toBeDefined();
|
||||||
|
|
||||||
|
const foundArticle = 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getArticleByExternalId returns null for non-existing id', async () => {
|
||||||
|
const result = await getArticleByExternalId(
|
||||||
|
'00000000-0000-4000-a000-000000000000' as UUIDv4
|
||||||
|
);
|
||||||
|
expect(result).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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can getArticlesPaginated with custom page size', async () => {
|
||||||
|
// Save extra articles to test pagination
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await saveArticle({
|
||||||
|
title: `Paginated Article ${i}`,
|
||||||
|
slug: `paginated-article-${i}`,
|
||||||
|
description: `Description ${i}`,
|
||||||
|
coverImageUrl: `https://example.com/cover-${i}.png`,
|
||||||
|
content: `Content ${i}`,
|
||||||
|
authorId: authorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPage = 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);
|
||||||
|
|
||||||
|
const secondPage = await getArticlesPaginated(2, 2);
|
||||||
|
|
||||||
|
expect(secondPage.data.length).toBe(2);
|
||||||
|
expect(secondPage.page).toBe(2);
|
||||||
|
expect(secondPage.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can update article', async () => {
|
||||||
|
const dataToUpdate: UpdateArticleModel = {
|
||||||
|
title: 'Updated Article Title',
|
||||||
|
description: 'Updated description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const article = await getArticleBySlug('test-article');
|
||||||
|
expect(article).toBeDefined();
|
||||||
|
|
||||||
|
const updatedArticle = await updateArticle(article!.id, 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot update non-existing article', async () => {
|
||||||
|
const dataToUpdate: UpdateArticleModel = {
|
||||||
|
title: 'Updated Article Title',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(updateArticle('9999', dataToUpdate)).rejects.toThrow(
|
||||||
|
`Article with ID 9999 not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can delete article', async () => {
|
||||||
|
const articleToSave: CreateArticleModel = {
|
||||||
|
title: 'Article to Delete',
|
||||||
|
slug: 'article-to-delete',
|
||||||
|
description: 'This article will be deleted',
|
||||||
|
coverImageUrl: 'https://example.com/delete-cover.png',
|
||||||
|
content: 'Content to delete.',
|
||||||
|
authorId: authorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedArticle = await saveArticle(articleToSave);
|
||||||
|
expect(savedArticle.id).toBeDefined();
|
||||||
|
|
||||||
|
await deleteArticle(savedArticle.id);
|
||||||
|
|
||||||
|
const deletedArticle = await getArticleBySlug('article-to-delete');
|
||||||
|
expect(deletedArticle).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot delete non-existing article', async () => {
|
||||||
|
await expect(deleteArticle('9999')).rejects.toThrow(
|
||||||
|
`Article with ID 9999 not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
tests/setup/__mocks__/fileMock.js
Normal file
1
tests/setup/__mocks__/fileMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = 'test-file-stub';
|
||||||
Reference in New Issue
Block a user