├── . babelrc
├── .cursorrules
├── .eslintrc.js
├── .github
└── workflows
│ └── codeql.yml
├── .gitignore
├── .husky
└── pre-commit
├── .npmrc
├── .prettierignore
├── README.md
├── apps
├── docs
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ └── tsconfig.json
└── web
│ ├── .env.example
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── README.md
│ ├── components.json
│ ├── env.example
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── assets
│ │ ├── logo.png
│ │ └── logo.svg
│ ├── favicon.ico
│ └── uploads
│ │ └── 2024-08-13
│ │ ├── large-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
│ │ ├── medium-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
│ │ └── thumbnail-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
│ ├── src
│ ├── actions
│ │ ├── auth
│ │ │ ├── index.ts
│ │ │ └── type.ts
│ │ └── protect
│ │ │ └── postAction.ts
│ ├── app
│ │ ├── [lang]
│ │ │ ├── (auth)
│ │ │ │ ├── forgot-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── signin
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── signup
│ │ │ │ │ └── page.tsx
│ │ │ │ └── verify-email
│ │ │ │ │ └── index.tsx
│ │ │ ├── (protected)
│ │ │ │ ├── layout.tsx
│ │ │ │ └── user
│ │ │ │ │ ├── posts
│ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── profile
│ │ │ │ │ └── page.tsx
│ │ │ │ │ └── settings
│ │ │ │ │ └── page.tsx
│ │ │ ├── (protected-post)
│ │ │ │ ├── layout.tsx
│ │ │ │ └── user
│ │ │ │ │ └── posts
│ │ │ │ │ ├── [postId]
│ │ │ │ │ └── edit
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── create
│ │ │ │ │ └── page.tsx
│ │ │ │ │ └── loading.tsx
│ │ │ ├── (public)
│ │ │ │ ├── about-us
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── contact
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── search
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── tags
│ │ │ │ │ └── page.tsx
│ │ │ │ └── ui
│ │ │ │ │ └── page.tsx
│ │ │ ├── (public-fullwidth)
│ │ │ │ ├── author
│ │ │ │ │ └── [authorId]
│ │ │ │ │ │ ├── followers
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ │ └── page.tsx
│ │ │ │ ├── posts
│ │ │ │ │ ├── [postId]
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── tocbot.css
│ │ │ │ │ └── page.tsx
│ │ │ │ └── tags
│ │ │ │ │ └── [tagId]
│ │ │ │ │ ├── follower
│ │ │ │ │ └── page.tsx
│ │ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── not-found.tsx
│ │ └── api
│ │ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ │ ├── protected
│ │ │ ├── comment
│ │ │ │ └── route.ts
│ │ │ ├── images
│ │ │ │ └── route.ts
│ │ │ ├── tags
│ │ │ │ └── route.ts
│ │ │ └── user
│ │ │ │ └── [userId]
│ │ │ │ ├── follower
│ │ │ │ └── route.ts
│ │ │ │ ├── followers
│ │ │ │ └── route.ts
│ │ │ │ ├── followings
│ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ └── public
│ │ │ ├── post
│ │ │ └── [postIdOrSlug]
│ │ │ │ ├── comments
│ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ └── tags
│ │ │ └── route.ts
│ ├── configs
│ │ └── auth.ts
│ ├── constants
│ │ ├── apis.ts
│ │ ├── index.ts
│ │ ├── order.ts
│ │ ├── routes.ts
│ │ └── upload.ts
│ ├── emails
│ │ ├── index.ts
│ │ └── verify-email.tsx
│ ├── font
│ │ └── index.ts
│ ├── hooks
│ │ ├── useFollowTag.ts
│ │ ├── useFollowUser.ts
│ │ ├── useGetImages.ts
│ │ ├── useInfinityScroll.ts
│ │ └── useUploadImage.ts
│ ├── i18n.ts
│ ├── libs
│ │ ├── resend
│ │ │ └── index.tsx
│ │ └── validationAction
│ │ │ └── index.ts
│ ├── messages
│ │ ├── en.json
│ │ └── fr.json
│ ├── middleware.ts
│ ├── molecules
│ │ ├── auth
│ │ │ ├── auth-form.tsx
│ │ │ ├── forgot-password
│ │ │ │ └── index.tsx
│ │ │ ├── reset-password
│ │ │ │ └── index.tsx
│ │ │ ├── sign-in
│ │ │ │ └── index.tsx
│ │ │ └── sign-up
│ │ │ │ └── index.tsx
│ │ ├── contact-form
│ │ │ └── contact-form.tsx
│ │ ├── editor-js
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── menu-bar.tsx
│ │ │ └── menu-item.tsx
│ │ ├── follower
│ │ │ ├── followers
│ │ │ │ ├── follower-item.tsx
│ │ │ │ └── index.tsx
│ │ │ └── user-profile
│ │ │ │ ├── follow-button.tsx
│ │ │ │ └── index.tsx
│ │ ├── footer
│ │ │ ├── index.tsx
│ │ │ └── theme-toggle.tsx
│ │ ├── home
│ │ │ └── filter
│ │ │ │ ├── filter-item.tsx
│ │ │ │ └── index.tsx
│ │ ├── infinite-scroll
│ │ │ └── index.tsx
│ │ ├── input-title
│ │ │ └── index.tsx
│ │ ├── language-switcher
│ │ │ └── index.tsx
│ │ ├── list-summary
│ │ │ └── index.tsx
│ │ ├── nav
│ │ │ ├── index.module.css
│ │ │ ├── index.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── search-bar.tsx
│ │ │ └── theme-toggle.tsx
│ │ ├── no-item-founded
│ │ │ └── index.tsx
│ │ ├── page-title
│ │ │ └── index.tsx
│ │ ├── pagination
│ │ │ └── index.tsx
│ │ ├── post-form
│ │ │ └── index.tsx
│ │ ├── posts
│ │ │ ├── post-detail
│ │ │ │ ├── comments
│ │ │ │ │ ├── comment-detail.tsx
│ │ │ │ │ ├── comment-header.tsx
│ │ │ │ │ ├── comment-input.tsx
│ │ │ │ │ ├── comment-list.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── edit-post-button
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── toggle-post.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── like-button
│ │ │ │ │ ├── LikeButton.tsx
│ │ │ │ │ ├── Likers.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── post-content
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── share-button
│ │ │ │ │ └── index.tsx
│ │ │ │ └── table-of-contents
│ │ │ │ │ └── index.tsx
│ │ │ ├── post-item
│ │ │ │ ├── bookmark-button
│ │ │ │ │ ├── BookmarkButton.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── comment-button
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── like-button
│ │ │ │ │ └── index.tsx
│ │ │ │ └── post-meta
│ │ │ │ │ └── index.tsx
│ │ │ ├── post-list
│ │ │ │ └── index.tsx
│ │ │ └── post-pagination
│ │ │ │ └── index.tsx
│ │ ├── profile
│ │ │ └── index.tsx
│ │ ├── sidebar-item
│ │ │ └── index.tsx
│ │ ├── square-advertisement
│ │ │ └── index.tsx
│ │ ├── tag
│ │ │ ├── filter
│ │ │ │ └── index.tsx
│ │ │ ├── tag-badge
│ │ │ │ └── index.tsx
│ │ │ ├── tag-detail
│ │ │ │ └── index.tsx
│ │ │ ├── tag-item
│ │ │ │ └── index.tsx
│ │ │ ├── tag-list-meta
│ │ │ │ └── index.tsx
│ │ │ └── tag-list
│ │ │ │ └── index.tsx
│ │ ├── top-tags
│ │ │ ├── NumberIndex.tsx
│ │ │ └── index.tsx
│ │ ├── upload
│ │ │ ├── AssetsManagement.tsx
│ │ │ ├── FileManagerContainer.tsx
│ │ │ ├── FileUploader.tsx
│ │ │ ├── ImageItem.tsx
│ │ │ ├── ImageList.tsx
│ │ │ ├── ImageSearchBar.tsx
│ │ │ ├── Loading.tsx
│ │ │ ├── UploadImageButton.tsx
│ │ │ └── index.tsx
│ │ ├── user-nav
│ │ │ ├── LogoutMenu.tsx
│ │ │ └── index.tsx
│ │ └── user
│ │ │ └── posts
│ │ │ ├── filter.tsx
│ │ │ ├── post-item.tsx
│ │ │ └── post-meta.tsx
│ ├── providers
│ │ ├── authProvider.tsx
│ │ └── index.tsx
│ ├── types
│ │ ├── comment.ts
│ │ ├── index.ts
│ │ └── users.ts
│ └── utils
│ │ ├── generatePath.ts
│ │ ├── index.ts
│ │ ├── navigation.ts
│ │ └── text.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── types
│ └── next-auth.d.ts
├── docker-compose.yml
├── docs
├── comment-prisma-queries.md
├── database.md
├── post-prisma-queries.md
├── tag-primsa-queries.md
└── user-prisma-queries.md
├── images
└── home-screen.png
├── package.json
├── packages
├── database
│ ├── .env.example
│ ├── .gitignore
│ ├── package.json
│ ├── prisma
│ │ ├── migrations
│ │ │ ├── 20241223151826_add_counter_triggers.sql
│ │ │ ├── 20241223151827_add_full_text_search.sql
│ │ │ ├── 20241223151828_add_follower_count_triggers.sql
│ │ │ ├── 20250312144706_init
│ │ │ │ └── migration.sql
│ │ │ └── migration_lock.toml
│ │ ├── schema.prisma
│ │ └── seeds
│ │ │ └── seed.mjs
│ ├── src
│ │ ├── constant.ts
│ │ ├── images
│ │ │ ├── queries.ts
│ │ │ ├── selects.ts
│ │ │ └── type.ts
│ │ ├── index.ts
│ │ ├── posts
│ │ │ ├── queries.ts
│ │ │ ├── selects.ts
│ │ │ └── utils.ts
│ │ ├── prisma.ts
│ │ ├── shared
│ │ │ └── type.ts
│ │ ├── tags
│ │ │ ├── queries.ts
│ │ │ └── selects.ts
│ │ └── users
│ │ │ ├── queries.ts
│ │ │ └── selects.ts
│ └── tsconfig.json
├── eslint-config-custom
│ ├── .babelrc
│ ├── index.js
│ └── package.json
├── prettier-config-custom
│ └── package.json
├── tailwind-config
│ ├── package.json
│ └── tailwind.config.js
├── tsconfig
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── ui
│ ├── .gitignore
│ ├── components.json
│ ├── index.ts
│ ├── package.json
│ ├── postcss.config.js
│ ├── src
│ ├── components
│ │ ├── molecules
│ │ │ └── skeleton
│ │ │ │ └── posts.tsx
│ │ └── ui
│ │ │ ├── accordion.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── breadcrumb.tsx
│ │ │ ├── button.tsx
│ │ │ ├── calendar.tsx
│ │ │ ├── card.tsx
│ │ │ ├── carousel.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── command.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── drawer.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── hover-card.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── loading-button.tsx
│ │ │ ├── menubar.tsx
│ │ │ ├── navigation-menu.tsx
│ │ │ ├── pagination.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── toast.tsx
│ │ │ ├── toaster.tsx
│ │ │ ├── toggle-group.tsx
│ │ │ ├── toggle.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── typography.tsx
│ │ │ └── use-toast.ts
│ ├── globals.css
│ └── lib
│ │ └── utils.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prettier.config.js
├── pull_request_template.md
└── turbo.json
/. babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": []
6 | }
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | // This tells ESLint to load the config from the package `eslint-config-custom`
4 | extends: ["custom"],
5 | settings: {
6 | next: {
7 | rootDir: ["apps/*/", "packages/*/"],
8 | },
9 | },
10 | parserOptions: {
11 | babelOptions: {
12 | presets: [require.resolve('next/babel')],
13 | },
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # testing
9 | coverage
10 |
11 | # next.js
12 | .next/
13 | out/
14 | build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # local env files
26 | .env
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | # turbo
33 | .turbo
34 |
35 | # vercel
36 | .vercel
37 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | pnpm format
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers = true
2 | public-hoist-pattern[] = *prisma*
3 | public-hoist-pattern[] = *next*
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | pnpm-lock.yaml
3 | .next
4 | .turbo
5 | dist
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | NEXT FORUM
5 |
6 |
7 |
8 | # About next-forum
9 |
10 | next-form is a minimal next-forum build with nextjs with AI
11 |
12 | # About technology
13 |
14 | - This project uses [Turborepo](https://turbo.build/) alongside [Next.js](https://nextjs.org/).
15 | - The database is powered by PostgreSQL, managed with [Prisma](https://www.prisma.io/).
16 | - The user interface is styled using [TailwindCSS](https://tailwindcss.com/) for utility-first CSS and [shadcn/ui](https://shadcn.dev/) for component design.
17 |
18 | # Libraries
19 |
20 | - ReactJS - 19.
21 | - TypeScript
22 | - NextJS 15. - App router and server actions
23 | - next-auth 5.
24 | - Prisma ORM
25 | - Postgres
26 | - Turborepo
27 | - TailwindCSS
28 | - shadcn
29 | - next-themes
30 | - Zod validation
31 | - React Form Hook
32 | - Tsup
33 | - EditorJs
34 | - react-toastify
35 | - react-textarea-autosize
36 | - lucide-react icon
37 | - dayjs
38 | - Eslint
39 | - Husky
40 | - Prettier
41 |
42 | # Installation
43 |
44 | Install
45 |
46 | ```
47 | turbo install
48 | ```
49 |
50 | In the `apps/web` folder, copy the env.example to env.local and enter the environment values
51 |
52 | In the `packages/database`, copy the env.example to .env and enter the DATABASE_URL
53 |
54 | Migration
55 |
56 | ```
57 | db:migrate
58 | ```
59 |
60 | Start
61 |
62 | ```
63 | turbo dev
64 | ```
65 |
66 | # Folder structure
67 |
68 | ## Front side functions
69 |
70 | - [x] Register by email or github
71 | - [x] Login by email, github or magic link
72 | - [x] User logout
73 | - [x] CRUD post
74 | - [x] List post: Search & filter by top or hot week, month, year, infinity
75 | - [x] Like post
76 | - [ ] Comment on post
77 | - [x] Share post
78 | - [x] Manage tag
79 | - [x] Follow user
80 | - [x] Multiple theme & dark mode or light mode
81 | - [x] Multiple language
82 | - [x] Follow tag
83 | - [x] Manage user profile
84 | - [ ] Multiple type: post/question
85 |
86 | ## Admin functions
87 |
88 | - [x] Dashboard
89 | - [x] CRUD tags
90 | - [ ] CRUD users
91 | - [ ] Manage posts
92 | - [ ] Manage images
93 | - [ ] Settings: Header/Menu
94 | - [ ] Manage roles and permission
95 |
--------------------------------------------------------------------------------
/apps/docs/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ["custom"],
4 | };
5 |
--------------------------------------------------------------------------------
/apps/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/apps/docs/README.md:
--------------------------------------------------------------------------------
1 | ## Getting Started
2 |
3 | First, run the development server:
4 |
5 | ```bash
6 | yarn dev
7 | ```
8 |
9 | Open [http://localhost:3001](http://localhost:3001) with your browser to see the result.
10 |
11 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
12 |
13 | To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3001/api/hello](http://localhost:3001/api/hello).
14 |
15 | ## Learn More
16 |
17 | To learn more about Next.js, take a look at the following resources:
18 |
19 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
20 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial.
21 |
22 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
23 |
24 | ## Deploy on Vercel
25 |
26 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
27 |
28 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
29 |
--------------------------------------------------------------------------------
/apps/docs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function RootLayout({ children }: { children: React.ReactNode }) {
2 | return (
3 |
4 | {children}
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/apps/docs/app/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 | <>
4 | Hello World
5 | >
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/apps/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/docs/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | reactStrictMode: true,
3 | transpilePackages: ["ui"],
4 | };
5 |
--------------------------------------------------------------------------------
/apps/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --port 3001",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "next": "^13.4.1",
13 | "react": "^18.2.0",
14 | "react-dom": "^18.2.0",
15 | "ui": "workspace:*"
16 | },
17 | "devDependencies": {
18 | "@types/node": "^17.0.12",
19 | "@types/react": "^18.0.22",
20 | "@types/react-dom": "^18.0.7",
21 | "eslint-config-custom": "workspace:*",
22 | "tsconfig": "workspace:*",
23 | "typescript": "^5.1.6"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tsconfig/nextjs.json",
3 | "compilerOptions": {
4 | "plugins": [
5 | {
6 | "name": "next"
7 | }
8 | ]
9 | },
10 | "include": [
11 | "next-env.d.ts",
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".next/types/**/*.ts"
15 | ],
16 | "exclude": [
17 | "node_modules"
18 | ]
19 | }
--------------------------------------------------------------------------------
/apps/web/.env.example:
--------------------------------------------------------------------------------
1 | NEXTAUTH_SECRET=
2 | NEXTAUTH_URL=
3 |
4 | NEXT_PUBLIC_FRONTEND_URL=
5 |
6 | GITHUB_ID=
7 | GITHUB_SECRET=
8 |
9 | JWT_SECRET=
10 |
11 | NEXT_PUBLIC_FB_APP_ID=
12 |
13 | RESEND_AUDIENCE_ID=
14 |
--------------------------------------------------------------------------------
/apps/web/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ["custom"],
4 | parserOptions: {
5 | babelOptions: {
6 | presets: [require.resolve('next/babel')],
7 | },
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | /public/uploads/
9 |
10 | # testing
11 | /coverage
12 |
13 | # next.js
14 | /.next/
15 | /out/
16 |
17 | # production
18 | /build
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env.local
31 | .env.development.local
32 | .env.test.local
33 | .env.production.local
34 |
35 | # vercel
36 | .vercel
37 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
1 | ## Getting Started
2 |
3 | First, run the development server:
4 |
5 | ```bash
6 | yarn dev
7 | ```
8 |
9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
10 |
11 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
12 |
13 | To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello).
14 |
15 | ## Learn More
16 |
17 | To learn more about Next.js, take a look at the following resources:
18 |
19 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
20 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial.
21 |
22 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
23 |
24 | ## Deploy on Vercel
25 |
26 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
27 |
28 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
29 |
--------------------------------------------------------------------------------
/apps/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "ui": "@/components/ui",
14 | "components": "@/components",
15 | "utils": "../../lib/utils",
16 | "molecules": "@/components/molecules"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/env.example:
--------------------------------------------------------------------------------
1 | NEXTAUTH_SECRET=
2 | NEXTAUTH_URL=
3 |
4 | NEXT_PUBLIC_FRONTEND_URL=
5 |
6 | GITHUB_ID=
7 | GITHUB_SECRET=
8 |
9 | JWT_SECRET=
10 |
11 | NEXT_PUBLIC_FB_APP_ID=
--------------------------------------------------------------------------------
/apps/web/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/web/next.config.mjs:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from 'next-intl/plugin';
2 |
3 | const withNextIntl = createNextIntlPlugin();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const nextConfig = {
7 | reactStrictMode: true,
8 | transpilePackages: ["ui", "database"],
9 | images: {
10 | remotePatterns: [
11 | {
12 | protocol: 'https',
13 | hostname: '**',
14 | },
15 | {
16 | protocol: 'http',
17 | hostname: 'localhost',
18 | },
19 | ],
20 | },
21 | experimental: {
22 | turbo: true,
23 | },
24 | webpack: (config) => {
25 | config.module.rules.push({
26 | test: /\.svg$/i,
27 | use: ['@svgr/webpack'],
28 | });
29 | return config;
30 | },
31 | };
32 |
33 | export default withNextIntl(nextConfig);
34 |
35 |
--------------------------------------------------------------------------------
/apps/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-import': {},
4 | 'tailwindcss/nesting': {},
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | };
--------------------------------------------------------------------------------
/apps/web/public/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/public/assets/logo.png
--------------------------------------------------------------------------------
/apps/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/public/favicon.ico
--------------------------------------------------------------------------------
/apps/web/public/uploads/2024-08-13/large-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/public/uploads/2024-08-13/large-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
--------------------------------------------------------------------------------
/apps/web/public/uploads/2024-08-13/medium-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/public/uploads/2024-08-13/medium-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
--------------------------------------------------------------------------------
/apps/web/public/uploads/2024-08-13/thumbnail-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/public/uploads/2024-08-13/thumbnail-4c7e9cf4-b12f-4ae6-9576-12cb4484cd72.jpg
--------------------------------------------------------------------------------
/apps/web/src/actions/auth/index.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import bcryptjs from "bcryptjs"
4 | import { Prisma } from "database"
5 | import { createUser } from "database/src/users/queries"
6 | import VerifyEmail from "emails/verify-email"
7 |
8 | import { signIn, signOut } from "@/configs/auth"
9 | import { sendEmail } from "@/emails"
10 | import { redirect } from "@/utils/navigation"
11 |
12 | import { SignUpDataOutput, signUpSchema } from "./type"
13 |
14 | export const signInWithCredentials = async (email: string, password: string) => {
15 | await signIn("credentials", {
16 | email,
17 | password,
18 | })
19 | }
20 |
21 | export const signInWithGithub = async () => {
22 | await signIn("github")
23 | }
24 |
25 | export const onSignOut = async () => {
26 | await signOut({
27 | redirectTo: "/",
28 | })
29 | }
30 |
31 | // SIGN UP
32 | export const signUp = async (
33 | data: Pick
34 | ): Promise => {
35 | try {
36 | // hash password
37 | const { email, password } = data
38 | const hashedPassword = await bcryptjs.hash(password, 10)
39 |
40 | await createUser({
41 | email,
42 | password: hashedPassword,
43 | name: email.split("@")[0],
44 | })
45 |
46 | // create verification code
47 | const token = crypto.randomUUID()
48 | await prisma.verificationToken.create({
49 | data: {
50 | token, // 6 digits code
51 | identifier: email,
52 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24), // 1 day
53 | },
54 | })
55 |
56 | // send email
57 | await sendEmail({
58 | email,
59 | subject: "Welcome to Next Forum",
60 | react: VerifyEmail({
61 | token,
62 | email,
63 | }),
64 | })
65 | } catch (error) {
66 | if (error?.error?.code === "P2002") {
67 | return {
68 | formErrors: null,
69 | fieldErrors: {
70 | email: ["Email already exists"], // TODO: localize error message
71 | },
72 | }
73 | }
74 |
75 | return {
76 | formErrors: [error?.message],
77 | fieldErrors: {},
78 | }
79 | }
80 |
81 | // TODO: white this redirect not work
82 | redirect("/login")
83 | }
84 |
--------------------------------------------------------------------------------
/apps/web/src/actions/auth/type.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | export const signUpSchema = z.object({
4 | email: z.string().email("Email is invalid"),
5 | password: z.string().min(8),
6 | confirmPassword: z.string().min(8),
7 | })
8 |
9 | export type SignUpDataInput = z.infer
10 |
11 | export type SignUpDataOutput = z.inferFlattenedErrors
12 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(auth)/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | const ForgotPasswordPage = () => {
4 | return (
5 |
6 |
Forgot Password
7 |
14 |
15 | )
16 | }
17 |
18 | export default ForgotPasswordPage
19 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(auth)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | const ResetPasswordPage = () => {
4 | return (
5 |
6 |
Forgot Password
7 |
14 |
15 | )
16 | }
17 |
18 | export default ResetPasswordPage
19 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(auth)/signin/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { redirect } from "next/navigation"
3 |
4 | import { getTranslations } from "next-intl/server"
5 |
6 | import { auth } from "@/configs/auth"
7 | import SignIn from "@/molecules/auth/sign-in"
8 |
9 | export async function generateMetadata() {
10 | const t = await getTranslations()
11 |
12 | return {
13 | title: t("auth.sign_in.title"),
14 | description: t("auth.sign_in.description"),
15 | }
16 | }
17 |
18 | export default async function Page() {
19 | const session = await auth()
20 |
21 | if (session) {
22 | redirect("/")
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(auth)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import SignUp from "@/molecules/auth/sign-up"
4 |
5 | const RegisterPage = () => {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
13 | export default RegisterPage
14 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(auth)/verify-email/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useSearchParams } from "next/navigation"
4 |
5 | export default function VerifyEmail() {
6 | const searchParams = useSearchParams()
7 | const code = searchParams.get("code")
8 |
9 | return Verify Email
10 | }
11 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected)/layout.tsx:
--------------------------------------------------------------------------------
1 | import SidebarItem, { SidebarItemProps } from "@/molecules/sidebar-item"
2 |
3 | const SIDE_BAR = [
4 | {
5 | label: "Profile",
6 | link: "/user/profile",
7 | },
8 | {
9 | label: "Posts",
10 | link: "/user/posts",
11 | },
12 | // {
13 | // label: "Password",
14 | // link: "/user/change-password",
15 | // },
16 | // {
17 | // label: "Setting",
18 | // link: "/user/settings",
19 | // },
20 | ] as Array
21 |
22 | export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
23 | return (
24 |
25 |
26 | {SIDE_BAR.map((item) => (
27 |
31 | ))}
32 |
33 |
{children}
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected)/user/posts/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Metadata } from "next/types"
3 |
4 | import { getUser } from "database"
5 |
6 | import { auth } from "@/configs/auth"
7 | import PageTitle from "@/molecules/page-title"
8 |
9 | export async function generateMetadata(): Promise {
10 | const session = await auth()
11 | const user = await getUser({ where: { id: session?.user?.id } })
12 |
13 | return {
14 | title: `Posts - ${user?.data?.username}`,
15 | description: `Posts of ${user?.data?.username}`,
16 | }
17 | }
18 |
19 | export default async function Page({ searchParams }) {
20 | const session = await auth()
21 |
22 | return (
23 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected)/user/profile/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@/configs/auth"
2 | import APP_APIS from "@/constants/apis"
3 | import PageTitle from "@/molecules/page-title"
4 | import Profile from "@/molecules/profile"
5 | import { generatePath } from "@/utils/generatePath"
6 |
7 | export default async function Page() {
8 | let currentUser = null
9 | try {
10 | const session = await auth()
11 |
12 | const userRaw = await fetch(
13 | `${process.env.NEXT_PUBLIC_FRONTEND_URL}${generatePath(APP_APIS.protected.user.GET, {
14 | userId: session?.user?.id,
15 | })}`,
16 | {
17 | cache: "no-cache",
18 | }
19 | )
20 |
21 | currentUser = await userRaw.json()
22 | } catch (error) {
23 | //
24 | }
25 |
26 | return (
27 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected)/user/settings/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return Setting
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected-post)/layout.tsx:
--------------------------------------------------------------------------------
1 | export default async function PublicLayout({ children }: { children: React.ReactNode }) {
2 | return {children}
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected-post)/user/posts/[postId]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import { findPostBySlugOrId } from "database"
4 |
5 | import PostForm from "@/molecules/post-form"
6 |
7 | export async function generateMetadata(props): Promise {
8 | const params = await props.params
9 | const post = await findPostBySlugOrId(params?.postId)
10 |
11 | return {
12 | title: post?.data?.title,
13 | description: "", // post?.content.slice(0, 160),
14 | }
15 | }
16 |
17 | export default async function Page(props: { params: Promise<{ postId: string }> }) {
18 | const params = await props.params
19 | const post = await findPostBySlugOrId(params?.postId)
20 |
21 | return
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected-post)/user/posts/create/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation"
2 |
3 | import { auth } from "@/configs/auth"
4 | import APP_ROUTES from "@/constants/routes"
5 | import PostForm from "@/molecules/post-form"
6 |
7 | export const metadata = {
8 | title: "Create Post",
9 | description: "Create a new post",
10 | }
11 |
12 | export default async function Page() {
13 | const session = await auth()
14 |
15 | if (!session) {
16 | redirect(APP_ROUTES.LOGIN)
17 | }
18 |
19 | return
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(protected-post)/user/posts/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "ui"
2 |
3 | export default function UserPostLoading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | {Array.from({ length: 5 }).map((_, i) => (
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ))}
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/about-us/page.tsx:
--------------------------------------------------------------------------------
1 | export default async function AboutUs() {
2 | return About
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/contact/page.tsx:
--------------------------------------------------------------------------------
1 | import ContactForm from "@/molecules/contact-form/contact-form"
2 | import PageTitle from "@/molecules/page-title"
3 |
4 | export default async function ContactUs() {
5 | return (
6 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { BookCopy, HomeIcon, Smartphone, TagIcon } from "lucide-react"
2 | import { useTranslations } from "next-intl"
3 |
4 | import SidebarItem, { SidebarItemProps } from "@/molecules/sidebar-item"
5 | import SquareAdvertisement from "@/molecules/square-advertisement"
6 | import TopTag from "@/molecules/top-tags"
7 |
8 | export default function PublicLayout({ children }: { children: React.ReactNode }) {
9 | const t = useTranslations("common")
10 |
11 | const SIDE_BAR = [
12 | {
13 | label: t("home"),
14 | link: "/",
15 | icons: ,
16 | },
17 | {
18 | label: t("tags"),
19 | link: "/tags",
20 | icons: ,
21 | },
22 | {
23 | label: t("contact"),
24 | link: "/contact",
25 | icons: ,
26 | },
27 | {
28 | label: t("about"),
29 | link: "/about-us",
30 | icons: ,
31 | },
32 | ] as Array
33 |
34 | return (
35 |
36 |
37 | {SIDE_BAR.map((item) => (
38 |
42 | ))}
43 |
44 | {/* */}
45 |
46 |
{children}
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | return Loading...
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react"
2 | import { Metadata } from "next"
3 |
4 | import { Typography } from "ui"
5 |
6 | import PostPagination from "@/molecules/posts/post-pagination"
7 |
8 | export const metadata: Metadata = {
9 | title: "Home",
10 | description: "Welcome to our community forum",
11 | }
12 |
13 | export default function HomePage() {
14 | return (
15 |
16 | Loading posts...
}>
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/search/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import { getTranslations } from "next-intl/server"
4 | import { Typography } from "ui"
5 |
6 | import Filter from "@/molecules/home/filter"
7 | import SearchBar from "@/molecules/nav/search-bar"
8 | import PostList from "@/molecules/posts/post-list"
9 |
10 | export async function generateMetadata(props): Promise {
11 | const searchParams = await props.searchParams
12 | return {
13 | title: `${searchParams?.search} - Search results`,
14 | description: `Search results for "${searchParams?.search}"`,
15 | }
16 | }
17 |
18 | export default async function Page(props) {
19 | const searchParams = await props.searchParams
20 | const t = await getTranslations({
21 | namespace: "common",
22 | })
23 |
24 | return (
25 |
26 |
30 | {t("search_results_for")}
31 | {`"${searchParams?.search}"`}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public)/tags/page.tsx:
--------------------------------------------------------------------------------
1 | import PageTitle from "@/molecules/page-title"
2 | import Filter from "@/molecules/tag/filter"
3 | import TagList from "@/molecules/tag/tag-list"
4 |
5 | export const metadata = {
6 | title: "Tags",
7 | description:
8 | "A tag is a keyword or label that categorizes your question with other, similar questions. Using the right tags makes it easier for others to find and answer your question.",
9 | }
10 |
11 | export default async function Page() {
12 | return (
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/author/[authorId]/followers/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import APP_APIS from "@/constants/apis"
4 | import Followers from "@/molecules/follower/followers"
5 | import UserProfile from "@/molecules/follower/user-profile"
6 | import { TUserItem } from "@/types/users"
7 | import { generatePath } from "@/utils/generatePath"
8 |
9 | export async function generateMetadata(props: {
10 | params: Promise<{ authorId: string }>
11 | }): Promise {
12 | const params = await props.params
13 | const rawAuthor = await fetch(
14 | `${process.env.NEXT_PUBLIC_FRONTEND_URL}${generatePath(APP_APIS.protected.user.GET, {
15 | userId: params?.authorId,
16 | })}`,
17 | {
18 | method: "GET",
19 | cache: "no-cache",
20 | headers: {
21 | "Content-Type": "application/json",
22 | },
23 | }
24 | )
25 | const author: TUserItem = await rawAuthor?.json()
26 |
27 | return {
28 | title: `${author.name} - Followers`,
29 | description: author.bio,
30 | }
31 | }
32 |
33 | export default async function Page(props: { params: Promise<{ authorId: string }> }) {
34 | const params = await props.params
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/author/[authorId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getUser } from "database"
2 |
3 | import UserProfile from "@/molecules/follower/user-profile"
4 | import PostList from "@/molecules/posts/post-list"
5 |
6 | export const generateMetadata = async (props) => {
7 | const params = await props.params
8 | const { data: author, error } = await getUser({
9 | where: {
10 | id: params?.authorId,
11 | },
12 | })
13 |
14 | return {
15 | title: author?.name,
16 | description: author?.bio,
17 | }
18 | }
19 |
20 | export default async function Page(props) {
21 | const searchParams = await props.searchParams
22 | const params = await props.params
23 | return (
24 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/posts/[postId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 | import { notFound } from "next/navigation"
3 |
4 | import PostDetail from "@/molecules/posts/post-detail"
5 | import Comments from "@/molecules/posts/post-detail/comments"
6 | import LikeButton from "@/molecules/posts/post-detail/like-button"
7 | import TableOfContents from "@/molecules/posts/post-detail/table-of-contents"
8 | import BookmarkButton from "@/molecules/posts/post-item/bookmark-button"
9 |
10 | import "./tocbot.css"
11 |
12 | import { findPostBySlugOrId, PostStatus } from "database"
13 |
14 | import { auth } from "@/configs/auth"
15 | import { TSearchParams } from "@/types"
16 |
17 | export async function generateMetadata(props): Promise {
18 | const params = await props.params
19 | const post = await findPostBySlugOrId(params?.postId)
20 |
21 | return {
22 | title: post?.data?.title,
23 | description: "", //post?.content.slice(0, 160),
24 | }
25 | }
26 |
27 | export default async function Page(props: {
28 | params: Promise<{ postId: string }>
29 | searchParams: Promise
30 | }) {
31 | const searchParams = await props.searchParams
32 | const params = await props.params
33 | const post = await findPostBySlugOrId(params?.postId)
34 | const session = await auth()
35 |
36 | if (
37 | !post ||
38 | (post.data?.postStatus === PostStatus.DRAFT && session?.user?.id !== post?.data?.author?.id)
39 | ) {
40 | return notFound()
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
52 |
53 |
54 |
61 |
62 |
63 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/posts/[postId]/tocbot.css:
--------------------------------------------------------------------------------
1 | .toc {
2 | overflow-y: auto
3 | }
4 |
5 | .toc>.toc-list {
6 | overflow: hidden;
7 | position: relative
8 | }
9 |
10 | .toc>.toc-list li {
11 | list-style: none
12 | }
13 |
14 | .js-toc {
15 | overflow-y: hidden
16 | }
17 |
18 | .toc-list {
19 | margin: 0;
20 | padding-left: 10px
21 | }
22 |
23 | a.toc-link {
24 | color: currentColor;
25 | height: 100%;
26 | height: 32px;
27 | display: flex;
28 | border-radius: 4px;
29 | justify-content: flex-start;
30 | align-items: center;
31 | padding: 0 8px;
32 | }
33 |
34 | a.toc-link:hover {
35 | /* background-color: #f5f5f5; */
36 | color: #000;
37 | }
38 |
39 | .is-collapsible {
40 | max-height: 1000px;
41 | overflow: hidden;
42 | transition: all 300ms ease-in-out
43 | }
44 |
45 | .is-collapsed {
46 | max-height: 0
47 | }
48 |
49 | .is-position-fixed {
50 | position: fixed !important;
51 | top: 0
52 | }
53 |
54 | .is-active-link {
55 | font-weight: 700;
56 | /* background-color: #f5f5f5; */
57 | }
58 |
59 | .toc-link::before {
60 | content: ' ';
61 | display: inline-block;
62 | height: inherit;
63 | left: 0;
64 | margin-top: -1px;
65 | position: absolute;
66 | width: 2px
67 | }
68 |
69 | /* .is-active-link::before {
70 | background-color: #54BC4B
71 | } */
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/posts/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation"
2 |
3 | export default async function Page() {
4 | redirect("/")
5 |
6 | return null
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/tags/[tagId]/follower/page.tsx:
--------------------------------------------------------------------------------
1 | // import { getTagById } from "@/actions/public/tags"
2 | import TagDetail from "@/molecules/tag/tag-detail"
3 |
4 | export const metadata = {
5 | title: "Tags",
6 | description: "A list of tags used in the blog posts",
7 | }
8 |
9 | export default async function Page(props: { params: Promise<{ tagId: string }> }) {
10 | const params = await props.params
11 | // const tag = await getTagById(params?.tagId as string)
12 |
13 | return {/* */}
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/(public-fullwidth)/tags/[tagId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getTag } from "database"
2 |
3 | import PostList from "@/molecules/posts/post-list"
4 | import TagDetail from "@/molecules/tag/tag-detail"
5 |
6 | export const generateMetadata = async (props) => {
7 | const params = await props.params
8 | const { data: tag, error } = await getTag({
9 | tagIdOrSlug: params?.tagId,
10 | })
11 |
12 | return {
13 | title: tag?.name,
14 | description: tag?.description,
15 | }
16 | }
17 |
18 | export default async function Page(props) {
19 | const params = await props.params
20 | return (
21 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 47.4% 11.2%;
9 |
10 | --muted: 210 40% 96.1%;
11 | --muted-foreground: 215.4 16.3% 46.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 47.4% 11.2%;
15 |
16 | --border: 214.3 31.8% 91.4%;
17 | --input: 214.3 31.8% 91.4%;
18 |
19 | --card: 0 0% 100%;
20 | --card-foreground: 222.2 47.4% 11.2%;
21 |
22 | --primary: 222.2 47.4% 11.2%;
23 | --primary-foreground: 210 40% 98%;
24 |
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 |
28 | --accent: 210 40% 96.1%;
29 | --accent-foreground: 222.2 47.4% 11.2%;
30 |
31 | --destructive: 0 100% 50%;
32 | --destructive-foreground: 210 40% 98%;
33 |
34 | --ring: 215 20.2% 65.1%;
35 |
36 | --radius: 0.5rem;
37 | }
38 |
39 | .dark {
40 | --background: 224 71% 4%;
41 | --foreground: 213 31% 91%;
42 |
43 | --muted: 223 47% 11%;
44 | --muted-foreground: 215.4 16.3% 56.9%;
45 |
46 | --accent: 216 34% 17%;
47 | --accent-foreground: 210 40% 98%;
48 |
49 | --popover: 224 71% 4%;
50 | --popover-foreground: 215 20.2% 65.1%;
51 |
52 | --border: 216 34% 17%;
53 | --input: 216 34% 17%;
54 |
55 | --card: 224 71% 4%;
56 | --card-foreground: 213 31% 91%;
57 |
58 | --primary: 210 40% 98%;
59 | --primary-foreground: 222.2 47.4% 1.2%;
60 |
61 | --secondary: 222.2 47.4% 11.2%;
62 | --secondary-foreground: 210 40% 98%;
63 |
64 | --destructive: 0 63% 31%;
65 | --destructive-foreground: 210 40% 98%;
66 |
67 | --ring: 216 34% 17%;
68 |
69 | --radius: 0.5rem;
70 | }
71 | }
72 |
73 | @layer base {
74 | * {
75 | @apply border-border;
76 | }
77 |
78 | body {
79 | @apply bg-background text-foreground;
80 | font-feature-settings:
81 | "rlig" 1,
82 | "calt" 1;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { use } from "react"
2 |
3 | import "./globals.css"
4 | import "ui/dist/index.css"
5 | import "react-toastify/dist/ReactToastify.css"
6 |
7 | import { NextIntlClientProvider, useMessages } from "next-intl"
8 | import { ToastContainer } from "react-toastify"
9 |
10 | import Footer from "@/molecules/footer"
11 | import Nav from "@/molecules/nav"
12 | import { Providers } from "@/providers"
13 |
14 | export const metadata = {
15 | icons: {
16 | icon: "/assets/logo.png",
17 | },
18 | }
19 |
20 | export default function RootLayout(props: {
21 | params: Promise<{ lang: string }>
22 | children: React.ReactNode
23 | }) {
24 | const params = use(props.params)
25 |
26 | const { lang } = params
27 |
28 | const { children } = props
29 |
30 | const messages = useMessages()
31 |
32 | return (
33 |
37 |
38 |
42 |
43 |
44 |
45 | {children}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | const NotFound = () => {
5 | return (
6 |
7 |
8 | 404
9 | Page Not Found
10 |
11 |
12 |
13 |
14 | Home
15 |
16 |
17 | Tags
18 |
19 |
20 | About
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export default NotFound
29 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { GET, POST } from "@/configs/auth"
2 |
3 | export { GET, POST }
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/comment/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma from "database"
4 | import { z } from "zod"
5 |
6 | import { auth } from "@/configs/auth"
7 | import { commentSelect } from "@/types/comment"
8 |
9 | export async function POST(request: NextRequest) {
10 | const session = await auth()
11 |
12 | if (!session?.user?.id) return Response.error()
13 |
14 | const data = await request.json()
15 |
16 | const { comment, postId } = z
17 | .object({
18 | comment: z.string().min(1).max(255),
19 | postId: z.string().min(1).max(255),
20 | })
21 | .parse(data)
22 |
23 | const result = await prisma.comment.create({
24 | data: {
25 | content: comment,
26 | author: {
27 | connect: {
28 | id: session?.user?.id,
29 | },
30 | },
31 | parentCommentId: null,
32 | commentOnPost: {
33 | connect: {
34 | id: postId,
35 | },
36 | },
37 | },
38 | select: commentSelect,
39 | })
40 |
41 | return Response.json({ message: "Success", data: result })
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/tags/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma from "database"
4 |
5 | export async function GET(request: NextRequest) {
6 | const newUrl = request.nextUrl.clone()
7 | const search = newUrl.searchParams.get("search") || ""
8 |
9 | try {
10 | const posts = await prisma.tag.findMany({
11 | where: {
12 | name: {
13 | contains: search,
14 | mode: "insensitive",
15 | },
16 | },
17 | })
18 |
19 | return Response.json(posts)
20 | } catch (error) {
21 | throw error
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/user/[userId]/follower/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma from "database"
4 |
5 | import { auth } from "@/configs/auth"
6 |
7 | export async function GET(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
8 | const params = await props.params
9 | const { userId } = params
10 |
11 | const currentUser = await auth()
12 |
13 | try {
14 | if (!currentUser) {
15 | return Response.json({ isFollowing: false }, { status: 200 })
16 | }
17 |
18 | const isFollowing = await prisma.follower.findUnique({
19 | where: {
20 | followerId_followingId: {
21 | followerId: currentUser?.user?.id,
22 | followingId: userId,
23 | },
24 | },
25 | })
26 |
27 | return Response.json({ isFollowing: Boolean(isFollowing) }, { status: 200 })
28 | } catch (error) {
29 | return Response.json(
30 | {
31 | status: 500,
32 | message: "Internal Server Error",
33 | },
34 | { status: 500 }
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/user/[userId]/followers/route.ts:
--------------------------------------------------------------------------------
1 | import { revalidatePath } from "next/cache"
2 | import { NextRequest } from "next/server"
3 |
4 | import prisma from "database"
5 |
6 | import { auth } from "@/configs/auth"
7 |
8 | export async function GET(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
9 | const params = await props.params
10 | const { userId } = params
11 |
12 | try {
13 | const users = await prisma.user.findMany({
14 | where: {
15 | followers: {
16 | some: {
17 | followingId: userId,
18 | },
19 | },
20 | },
21 | })
22 |
23 | if (!users) {
24 | return Response.json({ message: "User not found" }, { status: 404 })
25 | }
26 |
27 | return Response.json(users, { status: 200 })
28 | } catch (error) {
29 | return Response.error()
30 | }
31 | }
32 |
33 | export async function POST(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
34 | const params = await props.params
35 | const { userId } = params
36 | const data = await request.json()
37 |
38 | const session = await auth()
39 | if (!session) {
40 | return new Response(null, { status: 403 })
41 | }
42 |
43 | try {
44 | const isFollowing = await prisma.follower.findFirst({
45 | where: {
46 | followerId: session?.user?.id,
47 | followingId: data?.followerId,
48 | },
49 | })
50 |
51 | if (!isFollowing) {
52 | await prisma.follower.create({
53 | data: {
54 | followerId: session?.user?.id,
55 | followingId: data?.followerId,
56 | },
57 | })
58 | } else {
59 | await prisma.follower.deleteMany({
60 | where: {
61 | followerId: session?.user?.id,
62 | followingId: data?.followerId,
63 | },
64 | })
65 | }
66 | revalidatePath(`/author/${userId}/followers`)
67 |
68 | return Response.json({ message: "Success" }, { status: 200 })
69 | } catch (error) {
70 | return Response.json({ message: "Internal server error" }, { status: 500 })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/user/[userId]/followings/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma from "database"
4 |
5 | export async function GET(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
6 | const params = await props.params
7 | const { userId } = params
8 |
9 | try {
10 | const user = await prisma.user.findMany({
11 | where: {
12 | followers: {
13 | some: {
14 | followingId: userId,
15 | },
16 | },
17 | },
18 | })
19 |
20 | if (!user) {
21 | return Response.json({ message: "User not found" }, { status: 404 })
22 | }
23 |
24 | return Response.json(user, { status: 200 })
25 | } catch (error) {
26 | return Response.error()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/protected/user/[userId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma from "database"
4 |
5 | import { userSelect } from "@/types/users"
6 |
7 | export async function GET(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
8 | const params = await props.params
9 | if (!params.userId) {
10 | return Response.json({
11 | status: 400,
12 | message: "User id is required",
13 | })
14 | }
15 |
16 | try {
17 | const user = await prisma.user.findFirst({
18 | where: {
19 | OR: [
20 | {
21 | id: params.userId,
22 | },
23 | {
24 | username: params.userId,
25 | },
26 | ],
27 | },
28 | select: userSelect,
29 | })
30 |
31 | if (!user)
32 | return Response.json({
33 | status: 404,
34 | message: "User not found",
35 | })
36 |
37 | return Response.json(user, { status: 200 })
38 | } catch (error) {
39 | return Response.error()
40 | }
41 | }
42 |
43 | export async function PUT(request: NextRequest, props: { params: Promise<{ userId: string }> }) {
44 | const params = await props.params
45 | const data = await request.json()
46 | try {
47 | const user = await prisma.user.update({
48 | where: {
49 | id: params.userId,
50 | },
51 | data,
52 | })
53 |
54 | if (!user) {
55 | return Response.json({
56 | status: 404,
57 | message: "User not found",
58 | })
59 | }
60 |
61 | return Response.json(user, { status: 200 })
62 | } catch (error) {
63 | return Response.error()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/public/post/[postIdOrSlug]/comments/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma, { Prisma } from "database"
4 |
5 | import { commentSelect } from "@/types/comment"
6 |
7 | export async function GET(
8 | request: NextRequest,
9 | props: { params: Promise<{ postIdOrSlug: string }> }
10 | ) {
11 | const params = await props.params
12 | const newUrl = request.nextUrl.clone()
13 | const searchTerm = newUrl.searchParams.get("query") || ""
14 | const limit = newUrl.searchParams.get("limit") || 10
15 | const page = newUrl.searchParams.get("page") || 1
16 | const sort = newUrl.searchParams.get("sort") || "new"
17 |
18 | let where: Prisma.CommentWhereInput = {
19 | commentOnPostId: params.postIdOrSlug,
20 | }
21 |
22 | // const orderBy: Prisma.CommentOrderByWithRelationAndSearchRelevanceInput = {
23 | // updatedAt: sort === "new" ? "desc" : "asc",
24 | // }
25 |
26 | if (searchTerm) {
27 | where = {
28 | ...where,
29 | content: {
30 | contains: searchTerm,
31 | mode: "insensitive",
32 | },
33 | }
34 | }
35 |
36 | try {
37 | const [total, comments] = await Promise.all([
38 | prisma.comment.count({ where }),
39 | prisma.comment.findMany({
40 | where,
41 | select: commentSelect,
42 | take: Number(limit),
43 | skip: (Number(page) - 1) * Number(limit),
44 | // orderBy: {
45 | // ...orderBy,
46 | // },
47 | }),
48 | ])
49 |
50 | return Response.json({
51 | data: comments,
52 | total,
53 | page: Number(page),
54 | limit: Number(limit),
55 | })
56 | } catch (error) {
57 | throw error
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/public/post/[postIdOrSlug]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma, { findPostBySlugOrId } from "database"
4 |
5 | export async function GET(
6 | request: NextRequest,
7 | props: { params: Promise<{ postIdOrSlug: string }> }
8 | ) {
9 | const params = await props.params
10 | try {
11 | const result = await findPostBySlugOrId(params.postIdOrSlug)
12 |
13 | if (!result || !result.data) {
14 | return Response.json({
15 | status: 404,
16 | message: "Post not found",
17 | })
18 | }
19 |
20 | return Response.json(result.data, { status: 200 })
21 | } catch (error) {
22 | return Response.error()
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/public/tags/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server"
2 |
3 | import prisma, { Prisma, tagListSelect } from "database"
4 |
5 | import { DEFAULT_TAG_PAGE_LIMIT } from "@/constants"
6 |
7 | export async function GET(request: NextRequest) {
8 | const newUrl = request.nextUrl.clone()
9 | const searchTerm = newUrl.searchParams.get("query") || ""
10 | const page = Number(newUrl.searchParams.get("page")) || 0
11 | const limit = newUrl.searchParams.get("limit") || DEFAULT_TAG_PAGE_LIMIT
12 |
13 | const query = {
14 | select: tagListSelect,
15 | take: Number(limit),
16 | skip: (page === 0 ? 0 : page - 1) * Number(limit),
17 | where: {
18 | name: {
19 | contains: searchTerm,
20 | mode: "insensitive",
21 | },
22 | },
23 | } as Prisma.TagFindManyArgs
24 |
25 | try {
26 | const [data, total] = await Promise.all([
27 | prisma.tag.findMany(query),
28 | prisma.tag.count({
29 | where: query.where,
30 | }),
31 | ])
32 |
33 | return Response.json({
34 | total,
35 | data,
36 | limit,
37 | page,
38 | })
39 | } catch (error) {
40 | throw error
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/configs/auth.ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from "@auth/prisma-adapter"
2 | import bcryptjs from "bcryptjs"
3 | import prisma, { getUser } from "database"
4 | import NextAuth from "next-auth"
5 | import Credentials from "next-auth/providers/credentials"
6 | import GithubProvider from "next-auth/providers/github"
7 |
8 | export const {
9 | handlers: { GET, POST },
10 | auth,
11 | signIn,
12 | signOut,
13 | } = NextAuth({
14 | adapter: PrismaAdapter(prisma),
15 | providers: [
16 | GithubProvider({
17 | clientId: process.env.GITHUB_ID,
18 | clientSecret: process.env.GITHUB_SECRET,
19 | }),
20 | Credentials({
21 | credentials: {
22 | email: {},
23 | password: {},
24 | },
25 | authorize: async (credentials: Record) => {
26 | try {
27 | // IMPROVE:
28 | const { data: user } = await getUser({
29 | where: {
30 | email: credentials.email,
31 | },
32 | })
33 |
34 | if (!user) {
35 | return null
36 | }
37 |
38 | const isPasswordValid = await bcryptjs.compare(credentials.password, user.password)
39 |
40 | if (!isPasswordValid) {
41 | return null // Invalid password
42 | }
43 |
44 | return user
45 | } catch (e) {
46 | return null
47 | }
48 | },
49 | }),
50 | ],
51 | pages: {
52 | signIn: "/signin",
53 | },
54 | session: {
55 | strategy: "jwt",
56 | maxAge: 30 * 24 * 60 * 60,
57 | updateAge: 24 * 60 * 60,
58 | },
59 | jwt: {
60 | // secret: process.env.NEXTAUTH_SECRET,
61 | },
62 | callbacks: {
63 | redirect: async ({ url, baseUrl }) => {
64 | if (url.startsWith("/")) return `${baseUrl}${url}`
65 | else if (new URL(url).origin === baseUrl) {
66 | return url
67 | }
68 | return baseUrl
69 | },
70 | session: async ({ session, token }) => {
71 | if (token) {
72 | session.user.id = token.uid as string
73 | }
74 | return session
75 | },
76 | jwt: async ({ user, token }) => {
77 | if (user) {
78 | token.uid = user.id
79 | }
80 | return token
81 | },
82 | },
83 | })
84 |
--------------------------------------------------------------------------------
/apps/web/src/constants/apis.ts:
--------------------------------------------------------------------------------
1 | const APP_APIS = {
2 | public: {
3 | post: {
4 | GET: "/api/public/post/:postIdOrSlug",
5 | GET_COMMENTS: "/api/public/post/:postIdOrSlug/comments",
6 | GET_ACTIONS: "/api/public/post/:postIdOrSlug/actions/:actionType", // actionType: LIKE, BOOKMARK
7 | },
8 | comment: {
9 | GET: "/api/public/comment/:commentId",
10 | },
11 | posts: {
12 | GET: "/api/public/posts",
13 | },
14 | tags: {
15 | GET: "/api/public/tags",
16 | },
17 | tag: {
18 | GET: "/api/public/tag/:tagIdOrSlug",
19 | FOLLOWERS: "/api/public/tag/:tagIdOrSlug/followers",
20 | FOLLOWINGS: "/api/public/tag/:tagIdOrSlug/followings",
21 | },
22 | users: {
23 | GET: "/api/public/users",
24 | FOLLOWERS: "/api/protected/user/:userIdOrSlug/followers",
25 | FOLLOWINGS: "/api/protected/user/:userIdOrSlug/followings",
26 | FOLLOWINGS_TAGS: "/api/public/users/:userIdOrSlug/followings/tags",
27 | },
28 | },
29 | protected: {
30 | comment: {
31 | CREATE: "/api/protected/comment",
32 | DELETE: "/api/protected/comment/:commentId",
33 | UPDATE: "/api/protected/comment/:commentId",
34 | ACTIONS: "/api/protected/comment/:commentId/actions",
35 | },
36 | posts: {
37 | GET: "/api/protected/posts",
38 | },
39 | post: {
40 | ACTIONS: "/api/protected/post/:postId/actions", // actionType: LIKE, BOOKMARK
41 | DELETE: "/api/protected/post/:postId",
42 | // CREATE: "/api/protected/post",
43 | UPDATE: "/api/protected/post/:postId",
44 | POST_TOGGLE_PUBLISHED: "/api/protected/post/:postId/toggle-published", // actionType: PUBLISH, UNPUBLISH
45 | },
46 | tag: {
47 | CREATE: "/api/protected/tags",
48 | },
49 | user: {
50 | GET: "/api/protected/user/:userId",
51 | UPDATE: "/api/protected/user/:userId",
52 | DELETE: "/api/protected/user/:userId", // Deactivate account
53 | TOGGLE_FOLLOWER: "/api/protected/user/:userId/followers",
54 | GET_FOLLOWER_STATUS: "api/protected/user/:userId/follower",
55 | },
56 | },
57 | }
58 |
59 | export default APP_APIS
60 |
--------------------------------------------------------------------------------
/apps/web/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | import * as routers from "./routes"
2 |
3 | export const DEFAULT_TAG_PAGE_LIMIT = 40
4 |
5 | export const DEFAULT_PAGE_LIMIT = 20
6 |
7 | export const DD_MMM_YYYY_HH_MM = "DD MMM YYYY - HH:mm"
8 |
9 | export { routers }
10 |
--------------------------------------------------------------------------------
/apps/web/src/constants/order.ts:
--------------------------------------------------------------------------------
1 | export enum ORDER_BY {
2 | created_at_asc = "created_at_asc",
3 | created_at_desc = "created_at_desc",
4 | updated_at_asc = "updated_at_asc",
5 | updated_at_desc = "updated_at_desc",
6 | total_like_asc = "total_like_asc",
7 | total_like_desc = "total_like_desc",
8 | total_comment_asc = "total_comment_asc",
9 | total_comment_desc = "total_comment_desc",
10 | // total_view_asc = "total_view_asc",
11 | // total_view_desc = "total_view_desc",
12 | }
13 |
--------------------------------------------------------------------------------
/apps/web/src/constants/routes.ts:
--------------------------------------------------------------------------------
1 | const APP_ROUTES = {
2 | // Public routes
3 | HOME: "/",
4 |
5 | LOGIN: "/signin",
6 | REGISTER: "/signup",
7 | FORGOT_PASSWORD: "/forgot-password",
8 | RESET_PASSWORD: "/reset-password",
9 | VERIFY_EMAIL: "/verify-email",
10 |
11 | ABOUT: "/about",
12 | TECHNICAL: "/technical",
13 |
14 | POSTS: "/posts",
15 | POST: "/posts/:postId",
16 | TAGS: "/tags",
17 | TAG: "/tags/:tagId",
18 | USERS: "/users",
19 | USER: "/users/:userId",
20 | AUTHOR: "/author/:authorId",
21 | AUTHORS: "/authors",
22 |
23 | // Authenticated routes
24 | PROFILE: "/user/profile",
25 | SETTINGS: "/user/settings",
26 | CHANGE_PASSWORD: "/user/change-password",
27 |
28 | USER_POSTS: "/user/posts",
29 | CREATE_POST: "/user/posts/create",
30 | EDIT_POST: "/user/posts/:postId/edit",
31 | }
32 |
33 | export default APP_ROUTES
34 |
--------------------------------------------------------------------------------
/apps/web/src/constants/upload.ts:
--------------------------------------------------------------------------------
1 | export const OrderByField = {
2 | newest: "newest",
3 | oldest: "oldest",
4 | nameAsc: "name_asc",
5 | nameDesc: "name_desc",
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/emails/verify-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Heading,
7 | Html,
8 | Link,
9 | Preview,
10 | Section,
11 | Tailwind,
12 | Text,
13 | } from "@react-email/components"
14 |
15 | export default function VerifyEmail({ token, email }: { token: string; email: string }) {
16 | return (
17 |
18 |
19 | Your Next-Forum Verification Code
20 |
21 |
22 |
23 |
24 |
25 | Please confirm your email address
26 |
27 |
28 | Click link below to verify your email:
29 |
30 |
31 |
35 | GET STARTED
36 |
37 |
38 |
39 | This link is expired in 10 minutes.
40 |
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/src/font/index.ts:
--------------------------------------------------------------------------------
1 | import { Bebas_Neue } from "next/font/google"
2 |
3 | export const bebasNeue = Bebas_Neue({
4 | display: "swap",
5 | weight: "400",
6 | subsets: ["latin"],
7 | })
8 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/useFollowTag.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/src/hooks/useFollowTag.ts
--------------------------------------------------------------------------------
/apps/web/src/hooks/useFollowUser.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react"
4 | import { useParams, useRouter } from "next/navigation"
5 |
6 | import { toast } from "react-toastify"
7 |
8 | import APP_APIS from "@/constants/apis"
9 | import { generatePath } from "@/utils/generatePath"
10 |
11 | const useFollowUser = () => {
12 | const [isLoading, setIsLoading] = useState(false)
13 | const [isFollowing, setIsFollowing] = useState(false)
14 | const params = useParams()
15 | const router = useRouter()
16 |
17 | useEffect(() => {
18 | let isMounted = true
19 | const checkIfUserIsFollowed = async () => {
20 | const followStatusRaw = await fetch(
21 | `${process.env.NEXT_PUBLIC_FRONTEND_URL}/${generatePath(
22 | APP_APIS.protected.user.GET_FOLLOWER_STATUS,
23 | { userId: params?.authorId }
24 | )}`,
25 | {
26 | method: "GET",
27 | cache: "no-cache",
28 | headers: {
29 | "Content-Type": "application/json",
30 | },
31 | }
32 | )
33 | const followStatus = await followStatusRaw.json()
34 |
35 | if (isMounted) {
36 | setIsFollowing(followStatus?.isFollowing)
37 | }
38 | }
39 |
40 | checkIfUserIsFollowed()
41 |
42 | return () => {
43 | isMounted = false
44 | }
45 | }, [params?.authorId])
46 |
47 | const onFollowUser = async (authorId: string) => {
48 | setIsLoading(true)
49 | try {
50 | await fetch(
51 | `${process.env.NEXT_PUBLIC_FRONTEND_URL}${generatePath(
52 | APP_APIS.protected.user.TOGGLE_FOLLOWER,
53 | { userId: params?.authorId }
54 | )}`,
55 | {
56 | method: "POST",
57 | body: JSON.stringify({
58 | followerId: authorId,
59 | action: "follow",
60 | }),
61 | }
62 | )
63 | } catch (error) {
64 | toast.error("Failed to follow user")
65 | } finally {
66 | setIsLoading(false)
67 | setIsFollowing(!isFollowing)
68 | router.refresh()
69 | }
70 | }
71 |
72 | return {
73 | isLoading,
74 | isFollowing,
75 | onFollowUser,
76 | }
77 | }
78 |
79 | export default useFollowUser
80 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/useGetImages.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { useMemo } from "react"
3 |
4 | import { IImageFilter, IListImageResponse } from "database"
5 | import useSWRInfinite from "swr/infinite"
6 |
7 | const getImages = async (url): Promise => {
8 | const response = await fetch(url, {
9 | method: "GET",
10 | headers: {
11 | "Content-Type": "application/json",
12 | },
13 | })
14 |
15 | if (!response.ok) {
16 | throw new Error("Failed to fetch images")
17 | }
18 |
19 | return response.json()
20 | }
21 |
22 | export function useGetImages(filter: IImageFilter) {
23 | const { data, mutate, size, setSize, isLoading, error } = useSWRInfinite(
24 | (index) => {
25 | const queryParams = new URLSearchParams()
26 |
27 | if (filter.search) queryParams.append("search", filter.search)
28 | if (filter.order) queryParams.append("order", filter.order)
29 | queryParams.append("page", (index + 1).toString())
30 |
31 | return `/api/protected/images?${queryParams.toString()}`
32 | },
33 | (url) => getImages(url)
34 | )
35 |
36 | const images = useMemo(() => (data || []).flatMap((page) => page?.data?.data?.data), [data])
37 | const totalPages = useMemo(() => data?.[0]?.data?.data?.totalPages, [data])
38 | const total = useMemo(() => data?.[0]?.data?.data?.total, [data])
39 |
40 | const fetchMore = () => {
41 | if (size >= totalPages) {
42 | return
43 | }
44 |
45 | setSize(size + 1)
46 | }
47 |
48 | return {
49 | images,
50 | total,
51 | isLoading,
52 | isError: error,
53 | mutate,
54 | fetchMore,
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/useInfinityScroll.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react"
2 |
3 | const useInfiniteScroll = (callback: Function, root: HTMLElement | null, isFetching: boolean) => {
4 | const observer = useRef(null)
5 | const [node, setNode] = useState(null)
6 |
7 | const handleIntersection = useCallback(
8 | (entries: IntersectionObserverEntry[]) => {
9 | if (entries[0].isIntersecting && !isFetching) {
10 | callback?.()
11 | }
12 | },
13 | [callback, isFetching]
14 | )
15 |
16 | useEffect(() => {
17 | if (!node || isFetching) return
18 |
19 | if (observer.current) {
20 | observer.current.disconnect()
21 | }
22 |
23 | observer.current = new IntersectionObserver(handleIntersection, {
24 | root,
25 | rootMargin: "100px",
26 | threshold: 0.1,
27 | })
28 |
29 | observer.current.observe(node)
30 |
31 | return () => {
32 | if (observer.current) {
33 | observer.current.disconnect()
34 | }
35 | }
36 | }, [handleIntersection, root, node])
37 |
38 | return { setNode }
39 | }
40 |
41 | export default useInfiniteScroll
42 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/useUploadImage.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 |
3 | import { toast } from "react-toastify"
4 | import { useSWRConfig } from "swr"
5 | import useSWRMutation from "swr/mutation"
6 | import useSWRImmutable from "swr/mutation"
7 |
8 | // upload image to server API
9 | const uploadImage = async (file: File) => {
10 | try {
11 | const formData = new FormData()
12 |
13 | formData.append("file", file)
14 |
15 | // Todo: replace with hook
16 | const response = await fetch("/api/protected/images", {
17 | method: "POST",
18 | body: formData,
19 | headers: {
20 | Authorization: `Bearer ${localStorage.getItem("token")}`,
21 | },
22 | })
23 |
24 | toast.success("Image uploaded successfully")
25 |
26 | return response.json()
27 | } catch (error) {
28 | toast.error("Error uploading image")
29 | // throw error
30 | }
31 | }
32 |
33 | // upload image hook
34 | export const useUploadImage = () => {
35 | const { mutate } = useSWRConfig()
36 |
37 | const { trigger, isMutating, error, data } = useSWRMutation(
38 | "/api/protected/images",
39 | async (url, { arg }: { arg: File }) => {
40 | const result = await uploadImage(arg)
41 | mutate(["/api/protected/images"])
42 | return result
43 | }
44 | )
45 |
46 | return { uploadImage: trigger, isMutating, error, data }
47 | }
48 |
--------------------------------------------------------------------------------
/apps/web/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import { notFound } from "next/navigation"
2 |
3 | import { getRequestConfig } from "next-intl/server"
4 |
5 | // Can be imported from a shared config
6 | export const locales = ["en", "fr"]
7 |
8 | export default getRequestConfig(async ({ requestLocale }) => {
9 | const locale = await requestLocale
10 | // Validate that the incoming `locale` parameter is valid
11 | if (!locales.includes(locale as unknown as string)) notFound()
12 |
13 | return {
14 | messages: (await import(`./messages/${locale}.json`)).default,
15 | }
16 | })
17 |
--------------------------------------------------------------------------------
/apps/web/src/libs/resend/index.tsx:
--------------------------------------------------------------------------------
1 | import { Resend } from "resend"
2 |
3 | const resendApiKey = process.env.RESEND_API_KEY
4 |
5 | export const resend = resendApiKey ? new Resend(resendApiKey) : null
6 |
7 | export async function subscribe({ email, name }: { email: string; name?: string | null }) {
8 | const audienceId = process.env.RESEND_AUDIENCE_ID
9 |
10 | if (!audienceId) {
11 | console.error("RESEND_AUDIENCE_ID is not set in the .env. Skipping.")
12 | return
13 | }
14 |
15 | return await resend?.contacts.create({
16 | email,
17 | ...(name && {
18 | firstName: name.split(" ")[0],
19 | lastName: name.split(" ").slice(1).join(" "),
20 | }),
21 | audienceId: process.env.RESEND_AUDIENCE_ID as string,
22 | })
23 | }
24 |
25 | export async function unsubscribe({ email }: { email: string }) {
26 | return await resend?.contacts.remove({
27 | email,
28 | audienceId: process.env.RESEND_AUDIENCE_ID as string,
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/apps/web/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server"
2 |
3 | import createIntlMiddleware from "next-intl/middleware"
4 |
5 | import { auth } from "@/configs/auth"
6 |
7 | import { locales } from "./i18n"
8 |
9 | const handleI18nRouting = createIntlMiddleware({
10 | locales,
11 | localePrefix: "as-needed",
12 | defaultLocale: "en",
13 | })
14 |
15 | export async function middleware(req: NextRequest) {
16 | const currentPathname = req.nextUrl.pathname
17 | const session = await auth()
18 |
19 | if (!session?.user?.email && currentPathname.startsWith("/user")) {
20 | const newUrl = req.nextUrl.clone()
21 | const currentSearchParam = newUrl.searchParams.toString()
22 | newUrl.pathname = "/signin"
23 | newUrl.searchParams.set(
24 | "callbackUrl",
25 | encodeURIComponent(`${currentPathname}?${currentSearchParam}`)
26 | )
27 |
28 | return NextResponse.redirect(newUrl)
29 | }
30 |
31 | return handleI18nRouting(req)
32 | }
33 |
34 | export const config = {
35 | matcher: [
36 | // Skip all internal paths (_next)
37 | // "/((?!_next).*)",
38 | // Optional: only run on root (/) URL
39 | "/((?!api/|_next/|_proxy/|asset|_static|_vercel|uploads|[\\w-]+\\.\\w+).*)",
40 | ],
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/auth/auth-form.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, Typography } from "ui"
2 |
3 | interface AuthFormProps {
4 | title: string
5 | description: string
6 | children: React.ReactNode
7 | }
8 |
9 | export default function AuthForm({ title, description, children }: AuthFormProps) {
10 | return (
11 |
12 |
13 | {title}
14 | {description}
15 |
16 |
17 |
18 | {children}
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/auth/forgot-password/index.tsx:
--------------------------------------------------------------------------------
1 | export default function ForgotPassword() {
2 | return ForgotPassword
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/auth/reset-password/index.tsx:
--------------------------------------------------------------------------------
1 | export default function ResetPassword() {
2 | return ResetPassword
3 | }
4 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/contact-form/contact-form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { Button, Input, Label, Textarea } from "ui"
5 |
6 | type FormData = {
7 | name: string
8 | email: string
9 | description: string
10 | }
11 |
12 | const ContactForm = () => {
13 | const { register, handleSubmit } = useForm()
14 |
15 | const onSubmit = () => {
16 | // Handle form submission
17 | }
18 |
19 | return (
20 |
58 | )
59 | }
60 |
61 | export default ContactForm
62 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/editor-js/menu-item.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { cn } from "ui"
4 |
5 | type MenuItemProps = {
6 | icon?: string
7 | title?: string
8 | isActive?: () => boolean
9 | action?: () => void
10 | }
11 |
12 | const MenuItem = ({ icon, title, action, isActive = null }: MenuItemProps) => (
13 |
24 |
25 |
26 | )
27 |
28 | export default MenuItem
29 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/follower/followers/follower-item.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { Avatar, AvatarFallback, AvatarImage, Button, cn } from "ui"
5 |
6 | import { TUserItem } from "@/types/users"
7 |
8 | type FollowerItemProps = {
9 | user: TUserItem
10 | className?: string
11 | showFollowButton?: boolean
12 | }
13 |
14 | const FollowerItem = ({ user, className = "", showFollowButton = true }: FollowerItemProps) => {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
26 | {"CO".slice(0, 2)}
27 |
28 |
29 |
30 |
{user.name}
31 |
{user.email}
32 |
33 |
34 |
35 |
36 | {showFollowButton &&
Follow }
37 |
38 | )
39 | }
40 |
41 | export default FollowerItem
42 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/follower/followers/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import APP_APIS from "@/constants/apis"
4 | import { generateApi } from "@/utils/generatePath"
5 |
6 | import FollowerItem from "./follower-item"
7 |
8 | export async function Followers({ authorId }: { authorId: string }) {
9 | const rawFollowers = await fetch(
10 | generateApi(
11 | APP_APIS.public.users.FOLLOWERS,
12 | { userIdOrSlug: authorId },
13 | {
14 | limit: 10,
15 | sort: "desc",
16 | }
17 | ),
18 | {
19 | method: "GET",
20 | cache: "no-cache",
21 | headers: {
22 | "Content-Type": "application/json",
23 | },
24 | }
25 | )
26 |
27 | const followers = await rawFollowers?.json()
28 |
29 | return (
30 |
31 | {followers?.map((follower) => (
32 |
36 | ))}
37 |
38 | )
39 | }
40 |
41 | export default Followers
42 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/follower/user-profile/follow-button.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import Link from "next/link"
5 |
6 | import { useSession } from "next-auth/react"
7 | import { useTranslations } from "next-intl"
8 | import { Button, buttonVariants, cn } from "ui"
9 |
10 | import useFollowUser from "@/hooks/useFollowUser"
11 |
12 | const FollowButton = ({ authorId }: { authorId: string }) => {
13 | const t = useTranslations()
14 | const session = useSession()
15 |
16 | const { isLoading, isFollowing, onFollowUser } = useFollowUser()
17 |
18 | if (authorId === session?.data?.user?.id) {
19 | return (
20 |
29 | {t("common.update_profile").toUpperCase()}
30 |
31 | )
32 | }
33 |
34 | return (
35 | onFollowUser(authorId)}
40 | >
41 | {t(isFollowing ? "common.unfollow" : "common.follow").toUpperCase()}
42 |
43 | )
44 | }
45 |
46 | export default FollowButton
47 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/footer/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 |
5 | import { MonitorDot, Moon, Sun } from "lucide-react"
6 | import { useTheme } from "next-themes"
7 | import { Button } from "ui"
8 |
9 | const ThemeToggle: React.FC = () => {
10 | const { theme, setTheme } = useTheme()
11 |
12 | const toggleTheme = () => {
13 | setTheme(theme === "dark" ? "light" : "dark")
14 | }
15 |
16 | return (
17 |
18 |
23 |
24 |
25 |
30 |
31 |
32 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default ThemeToggle
44 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/home/filter/filter-item.tsx:
--------------------------------------------------------------------------------
1 | import { Button, cn } from "ui"
2 |
3 | type FilterItemProps = {
4 | label: string
5 | isActive: boolean
6 | onclick: () => void
7 | }
8 |
9 | export const FilterItem: React.FC = ({
10 | label,
11 | isActive,
12 | onclick,
13 | }: FilterItemProps) => {
14 | return (
15 |
23 | {label}
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/infinite-scroll/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Loader } from "lucide-react"
4 |
5 | import useInfiniteScroll from "@/hooks/useInfinityScroll"
6 |
7 | interface InfiniteScrollProps {
8 | containerClassName?: string
9 | children: React.ReactNode
10 | hasMore?: boolean
11 | root?: HTMLElement | null
12 | loading: boolean
13 | nextPage: (params: Record) => Promise
14 | }
15 |
16 | export default function InfiniteScroll({
17 | containerClassName,
18 | children,
19 | hasMore,
20 | root,
21 | nextPage,
22 | loading,
23 | }: InfiniteScrollProps) {
24 | const { setNode } = useInfiniteScroll(nextPage, root, loading)
25 |
26 | return (
27 |
28 | {children}
29 |
30 |
34 | {loading && }
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/input-title/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import TextareaAutosize from "react-textarea-autosize"
4 |
5 | type InputTitleProps = {
6 | title?: string
7 | placeholder?: string
8 | name: string
9 | onChange?: (e: React.ChangeEvent) => void
10 | onBlur?: (e: React.FocusEvent) => void
11 | }
12 |
13 | const InputTitle: React.FunctionComponent = ({
14 | placeholder = "",
15 | name,
16 | onChange,
17 | onBlur,
18 | ...props
19 | }: InputTitleProps) => {
20 | return (
21 |
22 |
30 |
31 | )
32 | }
33 |
34 | export default InputTitle
35 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/language-switcher/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTransition } from "react"
4 | import { useParams } from "next/navigation"
5 |
6 | import { useTranslations } from "next-intl"
7 | import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "ui"
8 |
9 | import { locales } from "@/i18n"
10 | import { usePathname, useRouter } from "@/utils/navigation"
11 |
12 | const LanguageSwitcher = () => {
13 | const router = useRouter()
14 | const params = useParams<{ lang: string }>()
15 | const { lang } = params
16 | const pathname = usePathname()
17 | const [isPending, startTransition] = useTransition()
18 | const t = useTranslations()
19 |
20 | const handleLanguageChange = (selectedLocale) => {
21 | const pathnameParts = pathname.split("/")
22 | if (locales.includes(pathnameParts[1])) {
23 | pathnameParts[1] = selectedLocale
24 | } else {
25 | pathnameParts[0] = `/${selectedLocale}`
26 | }
27 |
28 | startTransition(() => {
29 | router.replace(pathname, { locale: selectedLocale })
30 | })
31 | }
32 |
33 | return (
34 |
38 |
42 |
43 |
44 |
45 |
46 | {t("common.english")}
47 | {t("common.french")}
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default LanguageSwitcher
55 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/list-summary/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { useTranslations } from "next-intl"
4 | import { Typography } from "ui"
5 |
6 | interface ListSummaryProps {
7 | total?: number
8 | subTotal?: number
9 | }
10 |
11 | const ListSummary: React.FC = ({ total = 0, subTotal = 0 }) => {
12 | const t = useTranslations()
13 |
14 | return (
15 |
16 |
17 | {t("common.total_post_plural", { total, sub_total: subTotal })}
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | export default ListSummary
25 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/nav/index.module.css:
--------------------------------------------------------------------------------
1 | .logo {
2 | -webkit-text-strokes: 1px;
3 | -webkit-text-stroke-color: #000;
4 | }
--------------------------------------------------------------------------------
/apps/web/src/molecules/nav/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import { Edit } from "lucide-react"
4 | import { getTranslations } from "next-intl/server"
5 | import { buttonVariants, cn } from "ui"
6 |
7 | import { auth } from "@/configs/auth"
8 |
9 | import { UserNav } from "../user-nav"
10 | import Logo from "./logo"
11 | import SearchBar from "./search-bar"
12 | import ThemeToggle from "./theme-toggle"
13 |
14 | export default async function Nav() {
15 | const session = await auth()
16 | const t = await getTranslations()
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | {session?.user ? (
26 |
27 |
28 |
35 |
39 | {t("common.write")}
40 |
41 |
42 |
43 |
44 | ) : (
45 |
46 |
50 | {t("common.signIn")}
51 |
52 |
53 | )}
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/nav/logo.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import Link from "next/link"
5 |
6 | import { cn } from "ui"
7 |
8 | import { bebasNeue } from "@/font"
9 |
10 | const Logo: React.FC = () => {
11 | return (
12 |
13 |
19 | NEXT-FORUM
20 |
21 |
22 | )
23 | }
24 |
25 | export default Logo
26 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/nav/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 |
5 | import { MoonIcon, SunIcon } from "lucide-react"
6 | import { useTranslations } from "next-intl"
7 | import { useTheme } from "next-themes"
8 | import {
9 | Button,
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuTrigger,
14 | } from "ui"
15 |
16 | const ThemeToggle: React.FC = () => {
17 | const { setTheme } = useTheme()
18 | const t = useTranslations()
19 |
20 | return (
21 |
22 |
23 |
27 |
28 |
29 | {t("common.toggleTheme")}
30 |
31 |
32 |
33 | setTheme("light")}>{t("common.light")}
34 | setTheme("dark")}>{t("common.dark")}
35 | setTheme("system")}>{t("common.system")}
36 |
37 |
38 | )
39 | }
40 |
41 | export default ThemeToggle
42 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/no-item-founded/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { Rabbit } from "lucide-react"
4 | import { useTranslations } from "next-intl"
5 | import { cn, Typography } from "ui"
6 |
7 | type NoItemFoundedProps = {
8 | className?: string
9 | }
10 |
11 | const NoItemFounded: React.FC = ({ className = "" }) => {
12 | const t = useTranslations()
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
{t("common.no_items_founded")}
21 |
22 | )
23 | }
24 |
25 | export default NoItemFounded
26 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/page-title/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { Typography } from "ui"
4 |
5 | export type PageTitleProps = {
6 | title: string
7 | description?: string
8 | }
9 |
10 | export default function PageTitle({ title, description }: PageTitleProps) {
11 | return (
12 |
13 | {title}
14 | {description && {description} }
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/pagination/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import { useSearchParams } from "next/navigation"
5 |
6 | import {
7 | Pagination,
8 | PaginationContent,
9 | PaginationLink,
10 | PaginationNext,
11 | PaginationPrevious,
12 | } from "ui"
13 |
14 | interface PaginationProps {
15 | totalPages: number
16 | baseUrl: string
17 | }
18 |
19 | const generatePaginationPath = (baseUrl: string, page: number, searchParams: URLSearchParams) => {
20 | searchParams.set("page", String(page))
21 | return `${baseUrl}?${searchParams.toString()}`
22 | }
23 |
24 | const CustomPagination: React.FC = ({ baseUrl, totalPages }) => {
25 | const searchParams = useSearchParams()
26 | const currentSearchParams = new URLSearchParams(Array.from(searchParams.entries()))
27 |
28 | const currentPage = Number(searchParams.get("page")) || 1
29 |
30 | return (
31 |
32 |
33 | 1
36 | ? generatePaginationPath(baseUrl, currentPage - 1, currentSearchParams)
37 | : "#"
38 | }
39 | isActive={currentPage > 1}
40 | />
41 | {Array.from({ length: totalPages }, (_, i) => (
42 |
47 | {i + 1}
48 |
49 | ))}
50 |
58 |
59 |
60 | )
61 | }
62 |
63 | export default CustomPagination
64 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/comments/comment-header.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
5 |
6 | import { TPostItem } from "database"
7 | import {
8 | Select,
9 | SelectContent,
10 | SelectGroup,
11 | SelectItem,
12 | SelectTrigger,
13 | SelectValue,
14 | Typography,
15 | } from "ui"
16 |
17 | import { GetDataSuccessType } from "@/types"
18 | import { TCommentItem } from "@/types/comment"
19 |
20 | type CommentHeaderProps = {
21 | post: TPostItem
22 | comments: GetDataSuccessType
23 | }
24 |
25 | const CommentHeader: React.FC = ({ comments }) => {
26 | const searchParams = useSearchParams()
27 | const router = useRouter()
28 | const pathname = usePathname()
29 |
30 | const onChangeSort = (value) => {
31 | const urlSearchParam = new URLSearchParams(searchParams)
32 | urlSearchParam.set("sort", value)
33 |
34 | router.push(`${pathname}?${urlSearchParam.toString()}`)
35 | }
36 |
37 | const sortKey = searchParams.get("sort") || "top"
38 |
39 | return (
40 |
41 |
48 |
49 | Sort by
50 |
54 |
55 |
56 |
57 |
58 |
59 | Top
60 | New
61 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default CommentHeader
70 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/comments/comment-list.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useState } from "react"
4 |
5 | import { TCommentItem } from "@/types/comment"
6 |
7 | import CommentItem from "./comment-detail"
8 | import CommentInput from "./comment-input"
9 |
10 | type CommentListProps = {
11 | comments: TCommentItem[]
12 | postId: string
13 | }
14 |
15 | const CommentList: React.FC = ({ comments = [], postId }) => {
16 | const [curComments, setCurComments] = useState(comments)
17 |
18 | const onAddComment = (comment: TCommentItem) => {
19 | setCurComments([comment, ...curComments])
20 | }
21 |
22 | return (
23 |
24 |
25 |
29 |
30 |
31 | {curComments?.length > 0 ? (
32 | curComments?.map((comment) => (
33 |
37 | ))
38 | ) : (
39 |
40 | )}
41 |
42 | )
43 | }
44 |
45 | export default CommentList
46 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/comments/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { TPostItem } from "database"
4 |
5 | import { GetDataSuccessType, TSearchParams } from "@/types"
6 | import { TCommentItem } from "@/types/comment"
7 |
8 | import CommentHeader from "./comment-header"
9 | import CommentList from "./comment-list"
10 |
11 | interface CommentsProps {
12 | post: TPostItem
13 | searchParams: TSearchParams
14 | }
15 |
16 | const Comments: React.FC = async ({ post, searchParams }) => {
17 | let comments: GetDataSuccessType = null
18 | try {
19 | const urlSearchParam = new URLSearchParams(searchParams as Record)
20 | const commentRaw = await fetch(
21 | `${
22 | process.env.NEXT_PUBLIC_FRONTEND_URL
23 | }/api/public/post/${post?.id}/comments?${urlSearchParam.toString()}`,
24 | {
25 | cache: "no-cache",
26 | }
27 | )
28 |
29 | comments = await commentRaw.json()
30 | } catch (error) {
31 | //
32 | }
33 |
34 | return (
35 |
36 |
40 |
44 |
45 | )
46 | }
47 |
48 | export default Comments
49 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/edit-post-button/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { TPostItem } from "database"
5 | import { LucideEdit } from "lucide-react"
6 | import { getTranslations } from "next-intl/server"
7 | import { buttonVariants, cn } from "ui"
8 |
9 | import { auth } from "@/configs/auth"
10 | import APP_ROUTES from "@/constants/routes"
11 |
12 | import TogglePost from "./toggle-post"
13 |
14 | interface EditPostButtonProps {
15 | post: TPostItem
16 | }
17 |
18 | const EditPostButton: React.FC = async ({ post }) => {
19 | const session = await auth()
20 | const t = await getTranslations()
21 |
22 | if (post?.author?.id !== session?.user?.id) {
23 | return null
24 | }
25 |
26 | return (
27 |
28 |
29 |
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | export default EditPostButton
45 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/edit-post-button/toggle-post.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useActionState } from "react"
4 |
5 | import { PostStatus, TPostItem } from "database"
6 | import { useTranslations } from "next-intl"
7 | import { Button } from "ui"
8 |
9 | import { onTogglePost } from "@/actions/protect/postAction"
10 |
11 | export default function TogglePost({ post }: { post: TPostItem }) {
12 | const t = useTranslations()
13 |
14 | const [_, formAction, pending] = useActionState(onTogglePost, {
15 | post,
16 | })
17 |
18 | return (
19 |
20 |
25 | {t(post.postStatus === PostStatus.DRAFT ? "common.turn_publish" : "common.turn_draft")}
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import { TPostItem } from "database"
4 | import { Typography } from "ui"
5 |
6 | import APP_ROUTES from "@/constants/routes"
7 | import TagListMeta from "@/molecules/tag/tag-list-meta"
8 | import PostMeta from "@/molecules/user/posts/post-meta"
9 | import { generatePath } from "@/utils/generatePath"
10 |
11 | import EditPostButton from "./edit-post-button"
12 | import PostContent from "./post-content"
13 |
14 | export type PostDetailProps = {
15 | post: TPostItem
16 | }
17 |
18 | export default function PostDetail({ post }: PostDetailProps) {
19 | return (
20 |
21 |
22 |
23 |
27 |
32 | {post?.title}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | ({
42 | id: tag.tag.id,
43 | slug: tag.tag.slug,
44 | name: tag.tag.name,
45 | }))}
46 | classes={{
47 | container: "mt-4",
48 | }}
49 | />
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/like-button/LikeButton.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useActionState } from "react"
4 |
5 | import { TPostItem } from "database"
6 | import { Heart } from "lucide-react"
7 | import { useTranslations } from "next-intl"
8 | import { Button, cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "ui"
9 |
10 | import { onToggleLikePostWithUser, ToggleLikePostSchemaType } from "@/actions/protect/postAction"
11 | import { ActionState } from "@/libs/validationAction"
12 |
13 | type LikeButtonProps = {
14 | post: TPostItem
15 | totalLike: number
16 | isLiked: boolean
17 | children: React.ReactNode
18 | }
19 |
20 | const LikeButton: React.FC = ({ children, post, isLiked }: LikeButtonProps) => {
21 | const t = useTranslations()
22 |
23 | const [state, toggleLikePost, pending] = useActionState(
24 | onToggleLikePostWithUser,
25 | {
26 | isLiked,
27 | postId: post.id,
28 | postSlug: post.slug,
29 | error: "",
30 | success: "",
31 | }
32 | )
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
47 |
51 |
52 |
53 | {t(isLiked ? "common.unlike" : "common.like")}
54 |
55 |
56 |
57 | {children}
58 |
59 | )
60 | }
61 |
62 | export default LikeButton
63 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/like-button/Likers.tsx:
--------------------------------------------------------------------------------
1 | import React, { use } from "react"
2 |
3 | import { TPostItem } from "database"
4 | import { useTranslations } from "next-intl"
5 | import { Button, Dialog, DialogContent, DialogHeader, DialogTrigger, Typography } from "ui"
6 |
7 | import { getLikers } from "@/actions/protect/postAction"
8 | import FollowerItem from "@/molecules/follower/followers/follower-item"
9 |
10 | interface LikerProps {
11 | totalLike: number
12 | post: TPostItem
13 | }
14 |
15 | export default function Liker({ totalLike, post }: LikerProps) {
16 | const t = useTranslations()
17 |
18 | const { data: likers } = use(
19 | getLikers({
20 | postId: post.id,
21 | })
22 | )
23 |
24 | return (
25 |
26 |
27 |
31 | {totalLike}
32 |
33 |
34 |
35 |
36 | {t("common.likers")}
37 |
38 |
39 | {likers?.map((liker) => (
40 |
46 | ))}
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/like-button/index.tsx:
--------------------------------------------------------------------------------
1 | import { PostOnUserType, TPostItem } from "database"
2 |
3 | import { getTotalActions } from "@/actions/protect/postAction"
4 |
5 | import LikeButton from "./LikeButton"
6 | import Liker from "./Likers"
7 |
8 | interface LikeButtonProps {
9 | post: TPostItem
10 | }
11 |
12 | export default async function LikeButtonContainer({ post }: LikeButtonProps) {
13 | // Get total like
14 | const { total, haveAction: isLiked } = await getTotalActions({
15 | postId: post.id,
16 | actionType: PostOnUserType.LIKE,
17 | })
18 |
19 | return (
20 |
25 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/post-content/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { TPostItem } from "database"
4 | import reactHtmlParser from "react-html-parser"
5 |
6 | interface PostContentProps {
7 | post: TPostItem
8 | }
9 |
10 | const PostContent: React.FC = ({ post }) => {
11 | return {reactHtmlParser(post?.content)}
12 | }
13 |
14 | export default PostContent
15 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/share-button/index.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/src/molecules/posts/post-detail/share-button/index.tsx
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-detail/table-of-contents/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useEffect } from "react"
4 |
5 | import * as tocbot from "tocbot"
6 |
7 | const TableOfContents = () => {
8 | // Generate table of contents logic here
9 | useEffect(() => {
10 | tocbot.init({
11 | // Where to render the table of contents.
12 | tocSelector: ".tocbot",
13 | // Where to grab the headings to build the table of contents.
14 | contentSelector: ".post-content",
15 | // Which headings to grab inside of the contentSelector element.
16 | headingSelector: "h1, h2, h3, h4, h5, h6",
17 | // For headings inside relative or absolute positioned containers within content.
18 | hasInnerContainers: true,
19 | // positionFixedSelector: ".tocbot",
20 | includeHtml: true,
21 | // includeTitleTags: false,
22 | collapseDepth: 6,
23 | onClick: () => {
24 | // console.log("you clicked a link", e)
25 | },
26 | headingsOffset: 0,
27 | // scrollSmoothOffset: 0,
28 | })
29 | }, [])
30 |
31 | return {/* Render table of contents here */}
32 | }
33 |
34 | export default TableOfContents
35 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-item/bookmark-button/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { PostOnUserType, TPostItem } from "database"
4 |
5 | import { getTotalActions } from "@/actions/protect/postAction"
6 |
7 | import BookmarkButton from "./BookmarkButton"
8 |
9 | type BookmarkButtonContainerProps = {
10 | post: TPostItem
11 | showCount?: boolean
12 | }
13 |
14 | const BookmarkButtonContainer = async ({ post, showCount }: BookmarkButtonContainerProps) => {
15 | const { total, haveAction } = await getTotalActions({
16 | postId: post.id,
17 | actionType: PostOnUserType.BOOKMARK,
18 | })
19 |
20 | return (
21 |
27 | )
28 | }
29 |
30 | export default BookmarkButtonContainer
31 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-item/comment-button/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { TPostItem } from "database"
5 | import { MessageSquareCode } from "lucide-react"
6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "ui"
7 |
8 | import APP_ROUTES from "@/constants/routes"
9 | import { generatePath } from "@/utils/generatePath"
10 |
11 | type CommentButtonProps = {
12 | post: TPostItem
13 | }
14 |
15 | const CommentButton = ({ post }) => {
16 | return (
17 |
18 |
19 |
20 |
26 |
27 |
28 | {post?._count?.comments || 0}
29 |
30 |
31 |
32 | {`${post?._count?.comments || 0} comments`}
33 |
34 |
35 | )
36 | }
37 |
38 | export default CommentButton
39 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-item/index.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 | import Link from "next/link"
3 |
4 | import { TPostItem } from "database"
5 | import { useTranslations } from "next-intl"
6 | import { Typography } from "ui"
7 |
8 | import APP_ROUTES from "@/constants/routes"
9 | import TagListMeta from "@/molecules/tag/tag-list-meta"
10 | import { generatePath } from "@/utils/generatePath"
11 |
12 | import CommentButton from "./comment-button"
13 | import LikeButton from "./like-button"
14 | import PostMeta from "./post-meta"
15 |
16 | export default function PostItem({ post }: { post: TPostItem }) {
17 | const t = useTranslations("common")
18 | console.log(post)
19 |
20 | return (
21 |
22 |
23 |
28 |
32 | {post.title || t("untitled")}
33 |
34 |
35 |
36 |
37 |
38 |
tag?.tag)}
40 | classes={{
41 | container: "mt-2",
42 | }}
43 | />
44 |
45 |
51 |
52 | {post.image && post.image.previewUrl && (
53 |
54 |
61 |
62 | )}
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-item/like-button/index.tsx:
--------------------------------------------------------------------------------
1 | import { TPostItem } from "database"
2 | import { Heart } from "lucide-react"
3 | import { useTranslations } from "next-intl"
4 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography } from "ui"
5 |
6 | type LikeButtonProps = {
7 | post: TPostItem
8 | }
9 |
10 | const LikeButton = ({ post }: LikeButtonProps) => {
11 | const t = useTranslations()
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
25 |
29 | {post.totalLike}
30 |
31 |
32 |
33 | {`${t("common.like_plural", {
34 | count: post.totalLike,
35 | })}`}
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | export default LikeButton
43 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-item/post-meta/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { TPostItem } from "database"
5 | import dayjs from "dayjs"
6 |
7 | import APP_ROUTES from "@/constants/routes"
8 | import { generatePath } from "@/utils/generatePath"
9 |
10 | type PostMetaProps = {
11 | post: TPostItem
12 | }
13 |
14 | const PostMeta = ({ post }: PostMetaProps) => {
15 | if (!post?.author) return null
16 |
17 | return (
18 |
19 |
20 |
25 | @{post?.author?.name}
26 |
27 |
28 |
29 |
Last edited: {dayjs(post?.createdAt).format("MMMM D, YYYY")}
30 |
31 | )
32 | }
33 |
34 | export default PostMeta
35 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/posts/post-list/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useCallback, useState } from "react"
4 | import { useParams } from "next/navigation"
5 |
6 | import { getPosts, TPostItem } from "database"
7 | import { cn } from "ui"
8 |
9 | import InfiniteScroll from "@/molecules/infinite-scroll"
10 |
11 | import PostItem from "../post-item"
12 |
13 | export type TPostListProps = {
14 | getPostParams?: TGetPostsRequest
15 | containerClassName?: string
16 | }
17 |
18 | export default function PostList({ getPostParams = {}, containerClassName }: TPostListProps) {
19 | const searchParams = useParams()
20 | const [isLoading, setIsLoading] = useState(false)
21 | const [posts, setPosts] = useState([])
22 | const [page, setPage] = useState(1)
23 | const [hasNextPage, setHasNextPage] = useState(true)
24 |
25 | const loadPosts = useCallback(async () => {
26 | if (!hasNextPage) return
27 |
28 | setIsLoading(true)
29 | const { data } = await getPosts({
30 | ...getPostParams,
31 | ...searchParams,
32 | page: page.toString(),
33 | })
34 |
35 | setPosts((prev) => [...prev, ...data?.data])
36 | setHasNextPage(data?.totalPages > page)
37 | setIsLoading(false)
38 | setPage((prev) => prev + 1)
39 | }, [searchParams, page])
40 |
41 | return (
42 |
43 |
47 | {posts.map((post) => (
48 |
52 | ))}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/sidebar-item/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { usePathname } from "next/navigation"
5 |
6 | import { buttonVariants, cn, Typography } from "ui"
7 |
8 | export type SidebarItemProps = {
9 | label: string
10 | link: string
11 | icons: React.ReactElement
12 | }
13 |
14 | export default function SidebarItem({ label, link, icons }: SidebarItemProps) {
15 | const currentPathname = usePathname()
16 |
17 | const isActive =
18 | currentPathname === "/" ? link === "/" : currentPathname.startsWith(link) && link !== "/"
19 |
20 | return (
21 |
31 | {icons && {icons}
}
32 |
38 | {label}
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/square-advertisement/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { GithubIcon } from "lucide-react"
5 | import { Typography } from "ui"
6 |
7 | const SquareAdvertisement = () => {
8 | return (
9 |
10 | faster with
11 |
15 | free
16 |
17 |
18 |
19 |
23 | SAAS TEMPLATE
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | export default SquareAdvertisement
31 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/tag/filter/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useState } from "react"
4 | import { useRouter, useSearchParams } from "next/navigation"
5 |
6 | import { Button, Input } from "ui"
7 |
8 | const Filter = () => {
9 | const searchParams = useSearchParams()
10 |
11 | const router = useRouter()
12 | const [searchTerm, setSearchTerm] = useState(searchParams.get("query") || "")
13 |
14 | const onSearch = () => {
15 | router.push(`/tags?query=${searchTerm}`)
16 | }
17 |
18 | return (
19 |
20 | {
24 | if (e.key === "Enter") {
25 | onSearch()
26 | }
27 | }}
28 | onChange={(e) => {
29 | setSearchTerm(e?.target?.value || "")
30 | }}
31 | />
32 |
36 | Filter
37 |
38 |
39 | )
40 | }
41 |
42 | export default Filter
43 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/tag/tag-badge/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { TTagItem } from "database"
5 | import { Badge } from "ui"
6 |
7 | import APP_ROUTES from "@/constants/routes"
8 | import { generatePath } from "@/utils/generatePath"
9 |
10 | interface TagBadgeProps {
11 | tag: {
12 | id: TTagItem["id"]
13 | name: TTagItem["name"]
14 | slug: TTagItem["slug"]
15 | }
16 | }
17 |
18 | const TagBadge: React.FC = ({ tag }) => {
19 | return (
20 |
26 |
30 | {tag.name}
31 |
32 |
33 | )
34 | }
35 |
36 | export default TagBadge
37 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/tag/tag-item/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import { TTagListItem } from "database"
4 | import { useTranslations } from "next-intl"
5 | import { Card, CardContent, CardHeader, Typography } from "ui"
6 |
7 | export default function TagItem({ tag }: { tag: TTagListItem }) {
8 | const t = useTranslations("common")
9 |
10 | return (
11 |
15 |
16 |
17 |
21 | #{tag?.name}
22 |
23 |
24 |
25 | {tag?.description && {tag?.description} }
26 |
27 |
31 | {tag?.description}
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/tag/tag-list-meta/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { TTagItem } from "database"
4 | import { cn } from "ui"
5 |
6 | import TagBadge from "../tag-badge"
7 |
8 | export type TagListProps = {
9 | tags: Pick[]
10 | classes?: {
11 | container?: string
12 | }
13 | }
14 |
15 | export default function TagListMeta({
16 | tags,
17 | classes = {
18 | container: "",
19 | },
20 | }: TagListProps) {
21 | return (
22 |
23 | {tags?.length > 0 &&
24 | tags?.map((tag) => (
25 |
29 | ))}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/tag/tag-list/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useCallback, useState } from "react"
4 | import { useParams } from "next/navigation"
5 |
6 | import { getTags, TTagListItem } from "database"
7 |
8 | import InfiniteScroll from "@/molecules/infinite-scroll"
9 |
10 | import TagItem from "../tag-item"
11 |
12 | const TagList = () => {
13 | const searchParams = useParams()
14 | const [isLoading, setIsLoading] = useState(false)
15 | const [tags, setTags] = useState([])
16 | const [page, setPage] = useState(1)
17 | const [hasNextPage, setHasNextPage] = useState(true)
18 |
19 | const loadTags = useCallback(async () => {
20 | if (!hasNextPage) return
21 |
22 | setIsLoading(true)
23 | const { data } = await getTags({
24 | ...searchParams,
25 | })
26 |
27 | setTags((prev) => [...prev, ...data?.data])
28 | setHasNextPage(data?.totalPages > page)
29 | setIsLoading(false)
30 | setPage((prev) => prev + 1)
31 | }, [searchParams, page])
32 |
33 | return (
34 |
38 |
39 | {tags?.length > 0 ? (
40 |
41 | {tags.map((tag) => (
42 |
46 | ))}
47 |
48 | ) : null}
49 |
50 |
51 | )
52 | }
53 |
54 | export default TagList
55 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/top-tags/NumberIndex.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 |
5 | import { cn } from "ui"
6 |
7 | import { bebasNeue } from "@/font"
8 |
9 | interface NumberIndexProps {
10 | number: number
11 | }
12 |
13 | const NumberIndex: React.FC = ({ number }) => {
14 | return (
15 |
21 | {number}.
22 |
23 | )
24 | }
25 |
26 | export default NumberIndex
27 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/top-tags/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Link from "next/link"
3 |
4 | import { getTags } from "database"
5 | import { getTranslations } from "next-intl/server"
6 | import { Typography } from "ui"
7 |
8 | import NumberIndex from "./NumberIndex"
9 |
10 | const TopTag = async () => {
11 | const t = await getTranslations()
12 |
13 | const { data: topTags } = await getTags({
14 | take: 10,
15 | skip: 0,
16 | // orderBy: {
17 | // tagOnPost: {
18 | // _count: "desc",
19 | // },
20 | // },
21 | })
22 |
23 | return (
24 |
25 |
{t("common.trending")}
26 |
27 | {(topTags?.data || []).map((tag, index) => (
28 |
32 |
33 |
34 |
35 | #{tag.name}
36 |
37 | {t("common.total_post_plural", {
38 | total: 0, //tag?._count?.tagOnPost || 0,
39 | })}
40 |
41 |
42 |
43 |
44 | ))}
45 |
46 |
47 | )
48 | }
49 |
50 | export default TopTag
51 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/AssetsManagement.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useRef } from "react"
2 |
3 | import { useGetImages } from "@/hooks/useGetImages"
4 | import useInfiniteScroll from "@/hooks/useInfinityScroll"
5 |
6 | import { useFileManager } from "./FileManagerContainer"
7 | import ImageList from "./ImageList"
8 |
9 | const AssetManagement = () => {
10 | const { search, setTotal } = useFileManager()
11 | const imageListRef = useRef(null)
12 |
13 | const filterParams = useMemo(() => {
14 | return {
15 | search,
16 | // order,
17 | }
18 | }, [search])
19 |
20 | const { images, isLoading, total, fetchMore } = useGetImages(filterParams)
21 | const { setNode } = useInfiniteScroll(fetchMore, imageListRef.current, isLoading)
22 |
23 | useEffect(() => {
24 | setTotal(total)
25 | }, [total, setTotal])
26 |
27 | return (
28 |
42 | )
43 | }
44 |
45 | export default AssetManagement
46 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/ImageItem.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Image from "next/image"
3 |
4 | import { Image as ImageType } from "database"
5 | import { CheckCircle, Circle, TrashIcon } from "lucide-react"
6 | import { Button, cn } from "ui"
7 |
8 | import { useFileManager } from "./FileManagerContainer"
9 |
10 | interface ImageItemProps {
11 | image: ImageType
12 | }
13 |
14 | export default function ImageItem({ image }: ImageItemProps) {
15 | const { selectedFiles, setSelectedFiles } = useFileManager()
16 |
17 | const handleSelect = () => {
18 | setSelectedFiles(selectedFiles?.at(0)?.id === image.id ? [] : [image])
19 | }
20 |
21 | return (
22 |
28 |
35 | console.log("delete")}
40 | >
41 |
42 |
43 | handleSelect(image)}
47 | >
48 | {selectedFiles?.at(0)?.id === image.id ? (
49 |
50 | ) : (
51 |
52 | )}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/ImageList.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { Image as ImageType } from "database"
4 |
5 | import NoItemFounded from "../no-item-founded"
6 | import ImageItem from "./ImageItem"
7 | import Loading from "./Loading"
8 |
9 | type ImageListProps = {
10 | images: ImageType[]
11 | isLoading: boolean
12 | }
13 |
14 | const ImageList = ({ images, isLoading }) => {
15 | if (isLoading) {
16 | return
17 | }
18 |
19 | if (images?.length === 0) {
20 | return
21 | }
22 |
23 | return (
24 |
25 | {images?.map((image) => (
26 |
30 | ))}
31 |
32 | )
33 | }
34 |
35 | export default ImageList
36 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/ImageSearchBar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ArrowDownWideNarrow } from "lucide-react"
4 | import { useTranslations } from "next-intl"
5 | import {
6 | Button,
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuLabel,
10 | DropdownMenuRadioGroup,
11 | DropdownMenuRadioItem,
12 | DropdownMenuSeparator,
13 | DropdownMenuTrigger,
14 | Input,
15 | } from "ui"
16 |
17 | import { OrderByField } from "@/constants/upload"
18 |
19 | import { useFileManager } from "./FileManagerContainer"
20 |
21 | export default function ImageSearchBar() {
22 | const t = useTranslations()
23 | const { search, order, setSearch, setOrder } = useFileManager()
24 |
25 | const onClearSearch = () => {
26 | setSearch("")
27 | }
28 |
29 | return (
30 |
31 |
setSearch(e.target.value)}
34 | className="max-w-[300px]"
35 | />
36 |
37 |
38 |
39 |
43 |
44 | {t(`uploads.order_by.${order}`)}
45 |
46 |
47 |
48 | Order by
49 |
50 |
54 | {Object.values(OrderByField).map((order) => (
55 |
56 | {t(`uploads.order_by.${order}`)}
57 |
58 | ))}
59 |
60 |
61 |
62 |
63 | {search && (
64 |
68 | {t("common.clear")}
69 |
70 | )}
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "ui"
2 |
3 | export default function Loading() {
4 | return (
5 |
6 | {Array.from({ length: 10 }).map((_, i) => (
7 |
11 | ))}
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/upload/UploadImageButton.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useRef } from "react"
4 |
5 | import { Upload } from "lucide-react"
6 | import { useTranslations } from "next-intl"
7 | import { toast } from "react-toastify"
8 | import { LoadingButton } from "ui"
9 |
10 | import { useUploadImage } from "@/hooks/useUploadImage"
11 |
12 | const UploadImageButton = () => {
13 | const fileInputRef = useRef(null)
14 | const t = useTranslations("uploads")
15 | const { uploadImage, isMutating } = useUploadImage()
16 |
17 | const handleFileChange = (e: React.ChangeEvent) => {
18 | const file = e.target.files?.[0]
19 |
20 | if (file) {
21 | uploadImage(file)
22 | }
23 | }
24 |
25 | const handleButtonClick = () => {
26 | fileInputRef.current?.click()
27 | }
28 |
29 | return (
30 |
31 |
39 |
46 |
47 | {t("upload")}
48 |
49 |
50 | )
51 | }
52 |
53 | export default UploadImageButton
54 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/user-nav/LogoutMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTranslations } from "next-intl"
4 | import { DropdownMenuItem, DropdownMenuShortcut } from "ui"
5 |
6 | import { onSignOut } from "@/actions/auth"
7 |
8 | export const LogoutMenu = () => {
9 | const t = useTranslations()
10 |
11 | return (
12 |
13 |
17 |
18 | {t("common.signOut")}
19 | ⇧⌘Q
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/user/posts/filter.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
5 |
6 | import { useTranslations } from "next-intl"
7 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui"
8 |
9 | import { ORDER_BY } from "@/constants/order"
10 |
11 | interface FilterProps {
12 | total: number
13 | }
14 |
15 | const Filter: React.FC = ({ total }) => {
16 | const t = useTranslations()
17 | const router = useRouter()
18 | const searchParams = useSearchParams()
19 | const pathname = usePathname()
20 |
21 | const orderBy = searchParams.get("order_by") || ORDER_BY.updated_at_desc
22 |
23 | const onChangeFilter = (newValue) => {
24 | const urlSearchParam = new URLSearchParams(searchParams)
25 | urlSearchParam.set("order_by", newValue)
26 |
27 | router.push(`${pathname}?${urlSearchParam.toString()}`)
28 | }
29 |
30 | return (
31 |
32 |
33 | {t("common.total_post_plural", {
34 | total,
35 | })}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {Object.values(ORDER_BY).map((item) => (
44 |
48 | {t(`common.${item}`)}
49 |
50 | ))}
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | export default Filter
59 |
--------------------------------------------------------------------------------
/apps/web/src/molecules/user/posts/post-meta.tsx:
--------------------------------------------------------------------------------
1 | // generate react component meta data for post with author, date, and tags
2 | import Link from "next/link"
3 |
4 | import { TPostItem } from "database"
5 | import dayjs from "dayjs"
6 | import { Avatar, AvatarFallback, AvatarImage } from "ui"
7 |
8 | export type PostMetaProps = {
9 | post: TPostItem
10 | }
11 |
12 | export default function PostMeta({ post }: PostMetaProps) {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
23 | {(post?.author?.name || "CO").slice(0, 2)}
24 |
25 |
26 |
27 |
{post?.author?.name}
28 |
29 | Last edit on {dayjs(post?.updatedAt).format("MMMM D, YYYY")}
30 |
31 |
32 |
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/providers/authProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { SessionProvider } from "next-auth/react"
4 |
5 | export default function AuthProvider({ children }: { children: React.ReactNode }) {
6 | return {children}
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/src/providers/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ThemeProvider } from "next-themes"
4 |
5 | import AuthProvider from "./authProvider"
6 |
7 | export function Providers({ children }: { children: React.ReactNode }) {
8 | return (
9 |
14 | {children}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/apps/web/src/types/comment.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "database"
2 |
3 | export const commentSelect = {
4 | id: true,
5 | content: true,
6 | createdAt: true,
7 | updatedAt: true,
8 | commentOnPostId: true,
9 | author: {
10 | select: {
11 | id: true,
12 | name: true,
13 | email: true,
14 | image: true,
15 | },
16 | },
17 | } satisfies Prisma.CommentSelect
18 |
19 | const getCommentItem = Prisma.validator()({
20 | select: commentSelect,
21 | })
22 |
23 | export type TCommentItem = Prisma.PostGetPayload
24 |
--------------------------------------------------------------------------------
/apps/web/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type GetDataSuccessType = {
2 | data: T
3 | page: number
4 | limit: number
5 | total: number
6 | }
7 |
8 | export type GetDataErrorType = {
9 | message: string
10 | code: number
11 | }
12 |
13 | export type TSearchParams = {
14 | [key: string]: string | string[][] | Record | URLSearchParams
15 | }
16 |
--------------------------------------------------------------------------------
/apps/web/src/types/users.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "database"
2 |
3 | export const userSelect = {
4 | id: true,
5 | name: true,
6 | email: true,
7 | image: true,
8 | username: true,
9 | bio: true,
10 | website: true,
11 | address: true,
12 | accounts: true,
13 | phone: true,
14 | twitter: true,
15 | facebook: true,
16 | github: true,
17 | _count: {
18 | select: {
19 | post: true,
20 | followers: true,
21 | followings: true,
22 | },
23 | },
24 | } satisfies Prisma.UserSelect
25 |
26 | const getUser = Prisma.validator()({
27 | select: userSelect,
28 | })
29 |
30 | export type TUserItem = Prisma.UserGetPayload
31 |
32 | export type TUpdateUserInput = Prisma.UserCreateInput
33 |
--------------------------------------------------------------------------------
/apps/web/src/utils/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/apps/web/src/utils/index.ts
--------------------------------------------------------------------------------
/apps/web/src/utils/navigation.ts:
--------------------------------------------------------------------------------
1 | import { createSharedPathnamesNavigation } from "next-intl/navigation"
2 |
3 | import { locales } from "@/i18n"
4 |
5 | export const localePrefix = "as-needed"
6 |
7 | export const { Link, redirect, usePathname, useRouter } = createSharedPathnamesNavigation({
8 | locales,
9 | localePrefix,
10 | })
11 |
--------------------------------------------------------------------------------
/apps/web/src/utils/text.ts:
--------------------------------------------------------------------------------
1 | const MAX_FILE_NAME_LENGTH = 20
2 |
3 | export const capitalizeFirstLetter = (value: string) => {
4 | return value.charAt(0).toUpperCase() + value.slice(1)
5 | }
6 |
7 | export const truncateFileName = (name: string, maxLength = MAX_FILE_NAME_LENGTH) => {
8 | if (name.length <= maxLength) return name
9 | const half = Math.floor((maxLength - 3) / 2)
10 | return `${name.slice(0, half)}...${name.slice(-half)}`
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import tailwindConfig from "tailwind-config/tailwind.config.js";
2 |
3 | export default {
4 | ...tailwindConfig,
5 | };
6 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tsconfig/nextjs.json",
3 | "compilerOptions": {
4 | "jsx": "preserve",
5 | "plugins": [
6 | {
7 | "name": "next"
8 | }
9 | ],
10 | "baseUrl": ".",
11 | "paths": {
12 | "@/*": [
13 | "src/*"
14 | ],
15 | "emails/*": [
16 | "src/emails/*"
17 | ],
18 | "react": [
19 | "./node_modules/@types/react"
20 | ]
21 | }
22 | },
23 | "include": [
24 | "next-env.d.ts",
25 | "types/next-auth.d.ts",
26 | "**/*.ts",
27 | "**/*.tsx",
28 | ".next/types/**/*.ts"
29 | ],
30 | "exclude": [
31 | "node_modules"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import "next-auth/jwt"
2 | import "next-auth"
3 |
4 | import { type DefaultSession } from "next-auth"
5 |
6 | // Read more at: https://next-auth.js.org/getting-started/typescript#module-augmentation
7 |
8 | declare module "next-auth/jwt" {
9 | interface Session {
10 | user?: {
11 | id?: string | null
12 | name?: string | null
13 | email?: string | null
14 | image?: string | null
15 | } & DefaultSession["user"]
16 | }
17 | }
18 |
19 | declare module "next-auth" {
20 | interface Session {
21 | user?: {
22 | id?: string | null
23 | name?: string | null
24 | email?: string | null
25 | image?: string | null
26 | } & DefaultSession["user"]
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # Use postgres/example user/password credentials
2 | version: "3.1"
3 |
4 | services:
5 | db:
6 | image: postgres
7 | restart: always
8 | environment:
9 | POSTGRES_PASSWORD: example
10 | ports:
11 | - 5432:5432
12 |
13 | adminer:
14 | image: adminer
15 | restart: always
16 | ports:
17 | - 8080:8080
18 |
--------------------------------------------------------------------------------
/docs/comment-prisma-queries.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/docs/comment-prisma-queries.md
--------------------------------------------------------------------------------
/docs/post-prisma-queries.md:
--------------------------------------------------------------------------------
1 | # Generated by Prisma Query Engine
2 |
3 | There are three types of users:
4 |
5 | - **User**
6 | - **Moderator**
7 | - **Admin**
8 |
9 | ## Posts
10 |
11 | ### Get List of Posts
12 |
13 | - **Function Name**: `getPosts(params: GetPostsParams): Promise`
14 | - **Permission**: No permission required
15 | - **Description**: Retrieve a list of active posts
16 | - **Parameters**:
17 | - `offset: number` - The number of posts to skip
18 | - `limit: number` - The maximum number of posts to return
19 | - `filter: Prisma.PostWhereInput` - A Prisma filter object
20 | - **Return**: A promise that resolves to a list of posts
21 | - **Usage**: Home page, search page, tag page, user page
22 |
23 | ### Get a Post
24 |
25 | - **Function Name**: `getPost(postId: string): Promise`
26 | - **Permission**: No permission required
27 | - **Description**: Retrieve an active post by its ID or slug
28 | - **Parameters**:
29 | - `postId: string` - The post's UUID or slug
30 | - **Return**: A promise that resolves to the post or null if not found
31 | - **Usage**: Post detail page
32 |
33 | ### Create a Post
34 |
35 | - **Function Name**: `createPost(data: CreatePostData): Promise`
36 | - **Permission**: Authenticated user
37 | - **Parameters**:
38 | - `data: CreatePostData` - The required fields for creating a post
39 | - **Return**: A promise that resolves to the created post
40 |
41 | ### Update a Post
42 |
43 | - **Function Name**: `updatePost(postId: string, data: UpdatePostData): Promise`
44 | - **Permission**: Author of the post
45 | - **Parameters**:
46 | - `postId: string` - The post's UUID or slug
47 | - `data: UpdatePostData` - The new data for the post
48 | - **Return**: A promise that resolves to the updated post
49 |
50 | ### Delete a Post
51 |
52 | - **Function Name**: `deletePost(postId: string): Promise`
53 | - **Permission**: Author of the post
54 | - **Parameters**:
55 | - `postId: string` - The post's UUID or slug
56 | - **Return**: A promise that resolves when the post is deleted
57 |
--------------------------------------------------------------------------------
/docs/tag-primsa-queries.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/docs/tag-primsa-queries.md
--------------------------------------------------------------------------------
/docs/user-prisma-queries.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/docs/user-prisma-queries.md
--------------------------------------------------------------------------------
/images/home-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luannguyenQV/turborepo-nextjs-prisma-postgres/0d0dbfa8a681ab25290f52462def1f44ab6875c5/images/home-screen.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "build": "turbo run build",
5 | "dev": "turbo run dev",
6 | "lint": "turbo run lint",
7 | "lint:fix": "turbo run lint:fix",
8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"",
9 | "prepare": "husky",
10 | "clean": "turbo run clean && rm -rf node_modules && rm -rf .turbo"
11 | },
12 | "devDependencies": {
13 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
14 | "@turbo/gen": "^1.9.7",
15 | "eslint": "^7.32.0",
16 | "eslint-config-custom": "workspace:*",
17 | "eslint-plugin-prettier": "^5.1.3",
18 | "husky": "^9.0.6",
19 | "prettier": "^3.0.0",
20 | "prettier-plugin-tailwindcss": "^0.5.1",
21 | "tailwind-config": "workspace:*",
22 | "turbo": "^2.3.3"
23 | },
24 | "packageManager": "pnpm@8.6.10",
25 | "name": "codeforstartup",
26 | "description": "Code for Startup: Blogging for Developers",
27 | "version": "1.0.0",
28 | "main": ".eslintrc.js",
29 | "author": "",
30 | "license": "ISC",
31 | "workspaces": [
32 | "apps/*",
33 | "packages/*"
34 | ],
35 | "engines": {
36 | "node": ">=22.12.0",
37 | "pnpm": ">=9.15.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/database/.env.example:
--------------------------------------------------------------------------------
1 | # Environment variables declared in this file are automatically made available to Prisma.
2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
3 |
4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
6 |
7 | # DATABASE_URL=postgresql://localhost:5432/postgres?user=postgres&password=example
8 |
--------------------------------------------------------------------------------
/packages/database/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | # Keep environment variables out of version control
3 | .env
4 |
--------------------------------------------------------------------------------
/packages/database/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "database",
3 | "version": "0.0.0",
4 | "dependencies": {
5 | "@prisma/client": "^5.10.2",
6 | "dayjs": "^1.11.9",
7 | "slugify": "^1.6.6"
8 | },
9 | "devDependencies": {
10 | "@prisma/client": "^5.10.2",
11 | "@types/node": "^17.0.12",
12 | "prisma": "^5.10.2",
13 | "tsconfig": "workspace:*"
14 | },
15 | "scripts": {
16 | "db:generate": "prisma generate",
17 | "db:push": "prisma db push --skip-generate",
18 | "db:migrate": "prisma migrate dev",
19 | "db:reset": "prisma migrate reset",
20 | "db:seed": "ts-node prisma/seeds/seed.mjs"
21 | },
22 | "main": "./src/index.ts",
23 | "types": "./src/index.ts"
24 | }
25 |
--------------------------------------------------------------------------------
/packages/database/prisma/migrations/20241223151828_add_follower_count_triggers.sql:
--------------------------------------------------------------------------------
1 | -- Function to update follower counts
2 | CREATE OR REPLACE FUNCTION update_user_follower_counts()
3 | RETURNS TRIGGER AS $$
4 | BEGIN
5 | IF TG_OP = 'INSERT' THEN
6 | -- Increment follower count for followed user
7 | UPDATE "User"
8 | SET "totalFollowers" = "totalFollowers" + 1
9 | WHERE id = NEW."followingId";
10 |
11 | -- Increment following count for follower
12 | UPDATE "User"
13 | SET "totalFollowing" = "totalFollowing" + 1
14 | WHERE id = NEW."followerId";
15 |
16 | ELSIF TG_OP = 'DELETE' THEN
17 | -- Decrement follower count for followed user
18 | UPDATE "User"
19 | SET "totalFollowers" = "totalFollowers" - 1
20 | WHERE id = OLD."followingId";
21 |
22 | -- Decrement following count for follower
23 | UPDATE "User"
24 | SET "totalFollowing" = "totalFollowing" - 1
25 | WHERE id = OLD."followerId";
26 | END IF;
27 | RETURN NULL;
28 | END;
29 | $$ LANGUAGE plpgsql;
30 |
31 | -- Create trigger
32 | CREATE TRIGGER update_user_follower_counts_trigger
33 | AFTER INSERT OR DELETE ON "Follower"
34 | FOR EACH ROW
35 | EXECUTE FUNCTION update_user_follower_counts();
36 |
37 | -- Initialize counters for existing data
38 | UPDATE "User" u
39 | SET
40 | "totalFollowers" = (SELECT COUNT(*) FROM "Follower" WHERE "followingId" = u.id),
41 | "totalFollowing" = (SELECT COUNT(*) FROM "Follower" WHERE "followerId" = u.id);
--------------------------------------------------------------------------------
/packages/database/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/packages/database/prisma/seeds/seed.mjs:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client"
2 | // import posts from "./posts.json"
3 | // import slugify from "slugify"
4 |
5 | const prisma = new PrismaClient()
6 |
7 | async function main() {
8 | const user = await prisma.user.upsert({
9 | where: {
10 | email: "admin@codeforstartup.com",
11 | },
12 | create: {
13 | name: "Luan Nguyen",
14 | email: "admin@codeforstartup.com",
15 | password: "12345678",
16 | },
17 | update: {},
18 | })
19 |
20 | // for (const post of posts) {
21 | // await prisma.post.create({
22 | // data: {
23 | // title: post.title || '',
24 | // content: post.description,
25 | // slug: slugify(post.title || '-') + new Date().getTime(),
26 | // postStatus: "PUBLISHED",
27 | // author: {
28 | // connect: {
29 | // id: user.id,
30 | // },
31 | // },
32 | // tags: {
33 | // create: {
34 | // name: "next.js",
35 | // },
36 | // },
37 | // },
38 | // })
39 | // }
40 | }
41 |
42 | main().then(() => {
43 | console.log("Seed has been created")
44 | })
45 |
--------------------------------------------------------------------------------
/packages/database/src/constant.ts:
--------------------------------------------------------------------------------
1 | export const LIMIT_PER_PAGE = 20
2 |
--------------------------------------------------------------------------------
/packages/database/src/images/selects.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client"
2 |
3 | export const imageSelect = {
4 | id: true,
5 | url: true,
6 | name: true,
7 | userId: true,
8 | path: true,
9 | hash: true,
10 | ext: true,
11 | width: true,
12 | height: true,
13 | format: true,
14 | previewUrl: true,
15 | caption: true,
16 | mime: true,
17 | createdAt: true,
18 | updatedAt: true,
19 | } satisfies Prisma.ImageSelect
20 |
21 | export type TImage = Prisma.ImageGetPayload<{
22 | select: typeof imageSelect
23 | }>
24 |
--------------------------------------------------------------------------------
/packages/database/src/images/type.ts:
--------------------------------------------------------------------------------
1 | import { Image, Prisma } from "@prisma/client"
2 |
3 | import { IActionReturn, IGetListResponse } from "../shared/type"
4 |
5 | export type ImageOrderBys = "createdAt" | "name"
6 |
7 | export interface IImageFilter {
8 | page?: number
9 | limit?: number
10 | userId?: string
11 | search?: string
12 | where?: Prisma.ImageWhereInput
13 | order?: Prisma.ImageOrderByRelevanceInput
14 | }
15 |
16 | export interface IListImageResponse extends IActionReturn> {}
17 |
--------------------------------------------------------------------------------
/packages/database/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createImage, deleteImage, getImage, getImages, updateImage } from "./images/queries"
2 | import { TImage } from "./images/selects"
3 | import { IImageFilter, IListImageResponse, ImageOrderBys } from "./images/type"
4 | import {
5 | createPost,
6 | deletePost,
7 | findPostBySlugOrId,
8 | getPost,
9 | getPosts,
10 | updatePost,
11 | } from "./posts/queries"
12 | import { TPostItem } from "./posts/selects"
13 | import { PostCreateInputParams } from "./posts/utils"
14 | import prisma from "./prisma"
15 | import {
16 | DEFAULT_LIMIT,
17 | DEFAULT_PAGE,
18 | FilterValues,
19 | IActionReturn,
20 | IGetListResponse,
21 | PeriodValues,
22 | } from "./shared/type"
23 | import { createTag, getTag, getTags } from "./tags/queries"
24 | import type { TTagItem, TTagListItem } from "./tags/selects"
25 | import { tagListSelect } from "./tags/selects"
26 | import { createUser, deleteUser, getUser, updateUser } from "./users/queries"
27 | import { TUserDetail } from "./users/selects"
28 |
29 | export * from "@prisma/client"
30 | export default prisma
31 |
32 | export {
33 | // Tags
34 | createTag,
35 | getTag,
36 | getTags,
37 | tagListSelect,
38 |
39 | // Posts
40 | getPost,
41 | getPosts,
42 | createPost,
43 | updatePost,
44 | deletePost,
45 | findPostBySlugOrId,
46 | FilterValues,
47 | PeriodValues,
48 |
49 | // Users
50 | getUser,
51 | createUser,
52 | updateUser,
53 |
54 | // Images
55 | getImages,
56 | createImage,
57 | updateImage,
58 | deleteImage,
59 | getImage,
60 | }
61 |
62 | export type {
63 | //Tags
64 | TTagItem,
65 | TTagListItem,
66 |
67 | // Posts
68 | TPostItem,
69 | PostCreateInputParams,
70 |
71 | // Images
72 | IImageFilter,
73 | IListImageResponse,
74 | ImageOrderBys,
75 | TImage,
76 |
77 | // Users
78 | TUserDetail,
79 |
80 | // Shared
81 | IActionReturn,
82 | IGetListResponse,
83 | DEFAULT_LIMIT,
84 | DEFAULT_PAGE,
85 | }
86 |
--------------------------------------------------------------------------------
/packages/database/src/posts/selects.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client"
2 |
3 | export const PostSelect = {
4 | id: true,
5 | title: true,
6 | content: true,
7 | slug: true,
8 | postStatus: true,
9 | createdAt: true,
10 | updatedAt: true,
11 | upVotesCount: true,
12 | downVotesCount: true,
13 | viewCount: true,
14 | likesCount: true,
15 | commentsCount: true,
16 | sharesCount: true,
17 | author: {
18 | select: {
19 | id: true,
20 | firstName: true,
21 | lastName: true,
22 | email: true,
23 | },
24 | },
25 | tagOnPost: {
26 | select: {
27 | tag: {
28 | select: {
29 | id: true,
30 | name: true,
31 | slug: true,
32 | },
33 | },
34 | },
35 | },
36 | } satisfies Prisma.PostSelect
37 |
38 | export type TPostItem = Prisma.PostGetPayload<{
39 | select: typeof PostSelect
40 | }>
41 |
--------------------------------------------------------------------------------
/packages/database/src/posts/utils.ts:
--------------------------------------------------------------------------------
1 | import { PostStatus, PostType, TagType } from "@prisma/client"
2 |
3 | export type PostCreateInputParams = {
4 | title: string
5 | content?: string
6 | authorId: string
7 | categoryId?: string
8 | tagIds?: string[]
9 | newTags?: Array<{
10 | name: string
11 | type: TagType
12 | }>
13 | mediaId?: string
14 | postType?: PostType
15 | postStatus?: PostStatus
16 | isPinned?: boolean
17 | isLocked?: boolean
18 | slug?: string
19 | version?: number
20 | }
21 |
--------------------------------------------------------------------------------
/packages/database/src/prisma.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/prisma/prisma/issues/1983#issuecomment-620621213
2 |
3 | import { PrismaClient } from "@prisma/client"
4 |
5 | declare global {
6 | namespace NodeJS {
7 | interface Global {
8 | prisma: PrismaClient | undefined
9 | }
10 | }
11 |
12 | var prisma: PrismaClient | undefined
13 | }
14 |
15 | let prisma: PrismaClient
16 |
17 | if (process.env.NODE_ENV === "production") {
18 | prisma = new PrismaClient()
19 | } else {
20 | if (!global.prisma) {
21 | global.prisma = new PrismaClient()
22 | }
23 |
24 | prisma = global.prisma
25 | }
26 |
27 | export default prisma
28 |
--------------------------------------------------------------------------------
/packages/database/src/shared/type.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_LIMIT = 10
2 | export const DEFAULT_PAGE = 1
3 |
4 | export enum PeriodValues {
5 | THIS_WEEK = "week",
6 | THIS_MONTH = "month",
7 | THIS_YEAR = "year",
8 | INFINITY = "infinity",
9 | }
10 |
11 | export enum FilterValues {
12 | LASTED = "lasted",
13 | HOT = "hot",
14 | // TRENDING = "trending",
15 | }
16 |
17 | export interface IActionReturn {
18 | data?: T
19 | error?: any
20 | }
21 |
22 | export interface IGetListResponse {
23 | data: {
24 | data: T[]
25 | total: number
26 | page?: number
27 | totalPages?: number
28 | limit?: number
29 | }
30 | error?: unknown
31 | }
32 |
--------------------------------------------------------------------------------
/packages/database/src/tags/selects.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client"
2 |
3 | export const TagSelect = {
4 | id: true,
5 | name: true,
6 | slug: true,
7 | type: true,
8 | createdById: true,
9 | parentTagId: true,
10 | mediaId: true,
11 | media: {
12 | select: {
13 | id: true,
14 | url: true,
15 | },
16 | },
17 | creator: {
18 | select: {
19 | id: true,
20 | username: true,
21 | },
22 | },
23 | _count: {
24 | select: {
25 | tagOnPost: true,
26 | userOnTag: true,
27 | },
28 | },
29 | } satisfies Prisma.TagSelect
30 |
31 | export type TTagItem = Prisma.TagGetPayload<{
32 | select: typeof TagSelect
33 | }>
34 |
35 | export const TagListSelect = {
36 | id: true,
37 | name: true,
38 | slug: true,
39 | type: true,
40 | createdById: true,
41 | mediaId: true,
42 | media: {
43 | select: {
44 | id: true,
45 | url: true,
46 | },
47 | },
48 | _count: {
49 | select: {
50 | tagOnPost: true,
51 | },
52 | },
53 | } satisfies Prisma.TagSelect
54 |
55 | export type TTagListItem = Prisma.TagGetPayload<{
56 | select: typeof TagListSelect
57 | }>
58 |
59 | // For backward compatibility
60 | export const tagItemSelect = TagSelect
61 | export const tagListSelect = TagListSelect
62 |
--------------------------------------------------------------------------------
/packages/database/src/users/selects.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client"
2 |
3 | export const UserSelect = {
4 | id: true,
5 | name: true,
6 | firstName: true,
7 | lastName: true,
8 | email: true,
9 | bio: true,
10 | userStatus: true,
11 | trustLevel: true,
12 | lastSeenAt: true,
13 | createdAt: true,
14 | updatedAt: true,
15 | isAdmin: true,
16 | isModerator: true,
17 | totalFollowers: true,
18 | totalFollowing: true,
19 | blockedUntil: true,
20 | blockReason: true,
21 | lastLoginAt: true,
22 | loginAttempts: true,
23 | _count: {
24 | select: {
25 | posts: true,
26 | comments: true,
27 | followers: true,
28 | followings: true,
29 | },
30 | },
31 | } satisfies Prisma.UserSelect
32 |
33 | export type TUserItem = Prisma.UserGetPayload<{
34 | select: typeof UserSelect
35 | }>
36 |
37 | export const UserDetailSelect = {
38 | id: true,
39 | name: true,
40 | firstName: true,
41 | lastName: true,
42 | email: true,
43 | bio: true,
44 | userStatus: true,
45 | trustLevel: true,
46 | lastSeenAt: true,
47 | createdAt: true,
48 | updatedAt: true,
49 | isAdmin: true,
50 | isModerator: true,
51 | totalFollowers: true,
52 | totalFollowing: true,
53 | blockedUntil: true,
54 | blockReason: true,
55 | lastLoginAt: true,
56 | loginAttempts: true,
57 | password: true,
58 | _count: {
59 | select: {
60 | posts: true,
61 | comments: true,
62 | followers: true,
63 | followings: true,
64 | badges: true,
65 | medias: true,
66 | },
67 | },
68 | } satisfies Prisma.UserSelect
69 |
70 | export type TUserDetail = Prisma.UserGetPayload<{
71 | select: typeof UserDetailSelect
72 | }>
73 |
74 | // For backward compatibility
75 | export const userSelect = UserSelect
76 | export const userDetailSelect = UserDetailSelect
77 |
--------------------------------------------------------------------------------
/packages/database/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tsconfig/react-library.json",
3 | "include": [
4 | "**/*.ts",
5 | "**/*.tsx",
6 | "prisma/seeds/seed.mjs"
7 | ],
8 | "exclude": [
9 | "node_modules"
10 | ],
11 | "compilerOptions": {
12 | "jsx": "preserve",
13 | "baseUrl": ".",
14 | "lib": [
15 | "esnext",
16 | ],
17 | },
18 | }
--------------------------------------------------------------------------------
/packages/eslint-config-custom/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": []
6 | }
--------------------------------------------------------------------------------
/packages/eslint-config-custom/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: "@typescript-eslint/parser",
4 | extends: [
5 | "next",
6 | "turbo",
7 | "prettier",
8 | "next/core-web-vitals",
9 | "plugin:react-hooks/recommended",
10 | "plugin:jsx-a11y/recommended",
11 | "plugin:react/recommended",
12 | "plugin:@typescript-eslint/recommended"
13 | ],
14 | plugins: ["unused-imports", "@typescript-eslint"],
15 | rules: {
16 | "react/react-in-jsx-scope": "off",
17 | '@typescript-eslint/no-unused-vars': [
18 | 'error',
19 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
20 | ],
21 | "no-unused-vars": "off",
22 | "unused-imports/no-unused-imports": "error",
23 | "unused-imports/no-unused-vars": [
24 | "warn",
25 | { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
26 | ],
27 | "no-console": "error",
28 | "react/jsx-uses-react": "error",
29 | "react/jsx-uses-vars": "error",
30 | "react-hooks/exhaustive-deps": "warn",
31 | },
32 | parserOptions: {
33 | babelOptions: {
34 | presets: [require.resolve("next/babel")],
35 | },
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/packages/eslint-config-custom/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-config-custom",
3 | "version": "0.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "dependencies": {
7 | "@typescript-eslint/eslint-plugin": "^6.8.0",
8 | "@typescript-eslint/parser": "^6.8.0",
9 | "eslint-config-next": "^13.4.1",
10 | "eslint-config-prettier": "^8.10.0",
11 | "eslint-config-turbo": "^1.9.3",
12 | "eslint-plugin-import": "^2.28.1",
13 | "eslint-plugin-jsx-a11y": "^6.7.1",
14 | "eslint-plugin-react": "7.28.0",
15 | "eslint-plugin-react-hooks": "^4.6.0",
16 | "eslint-plugin-unused-imports": "^3.0.0",
17 | "prettier": "^3.0.3"
18 | },
19 | "publishConfig": {
20 | "access": "public"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/prettier-config-custom/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prettier-config-custom",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "types": "./index.ts",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "devDependencies": {
14 | "tailwind-config": "workspace:*"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/tailwind-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tailwind-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "main": "index.js",
6 | "devDependencies": {
7 | "tailwindcss": "^3.2.4"
8 | },
9 | "dependencies": {
10 | "tailwindcss-animate": "^1.0.7"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "composite": false,
6 | "declaration": true,
7 | "declarationMap": true,
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "inlineSources": false,
11 | "isolatedModules": true,
12 | "moduleResolution": "node",
13 | "noUnusedLocals": false,
14 | "noUnusedParameters": false,
15 | "preserveWatchOutput": true,
16 | "skipLibCheck": true,
17 | "strict": true
18 | },
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/tsconfig/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Next.js",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "plugins": [{ "name": "next" }],
7 | "allowJs": true,
8 | "declaration": false,
9 | "declarationMap": false,
10 | "incremental": true,
11 | "jsx": "preserve",
12 | "lib": ["dom", "dom.iterable", "esnext"],
13 | "module": "esnext",
14 | "noEmit": true,
15 | "resolveJsonModule": true,
16 | "strict": false,
17 | "target": "es5"
18 | },
19 | "include": ["src", "next-env.d.ts"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/packages/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tsconfig",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/tsconfig/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx",
7 | "lib": ["ES2015", "DOM"],
8 | "module": "ESNext",
9 | "target": "es6"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | /dist
9 |
10 | # testing
11 | /coverage
12 |
13 | # next.js
14 | /.next/
15 | /out/
16 |
17 | # production
18 | /build
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env.local
31 | .env.development.local
32 | .env.test.local
33 | .env.production.local
34 |
35 | # vercel
36 | .vercel
37 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-import': {},
4 | 'tailwindcss/nesting': {},
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | };
--------------------------------------------------------------------------------
/packages/ui/src/components/molecules/skeleton/posts.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "../../../lib/utils"
4 | import { Skeleton } from "../../ui/skeleton"
5 |
6 | interface PostSkeletonProps extends React.HTMLAttributes {
7 | total?: number
8 | containerClassName?: string
9 | }
10 |
11 | function PostSkeleton({
12 | total = 1,
13 | className,
14 | containerClassName = "",
15 | ...props
16 | }: PostSkeletonProps) {
17 | return (
18 |
19 | {Array.from({ length: total }).map((_, index) => (
20 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ))}
36 |
37 | )
38 | }
39 |
40 | export { PostSkeleton }
41 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
6 | import { ChevronDown } from "lucide-react"
7 |
8 | import { cn } from "../../lib/utils"
9 |
10 | const Accordion = AccordionPrimitive.Root
11 |
12 | const AccordionItem = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | AccordionItem.displayName = "AccordionItem"
23 |
24 | const AccordionTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, children, ...props }, ref) => (
28 |
29 | svg]:rotate-180",
33 | className
34 | )}
35 | {...props}
36 | >
37 | {children}
38 |
39 |
40 |
41 | ))
42 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
43 |
44 | const AccordionContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, children, ...props }, ref) => (
48 |
53 | {children}
54 |
55 | ))
56 |
57 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
58 |
59 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
60 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "../../lib/utils"
6 |
7 | const alertVariants = cva(
8 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-background text-foreground",
13 | destructive:
14 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | }
21 | )
22 |
23 | const Alert = React.forwardRef<
24 | HTMLDivElement,
25 | React.HTMLAttributes & VariantProps
26 | >(({ className, variant, ...props }, ref) => (
27 |
33 | ))
34 | Alert.displayName = "Alert"
35 |
36 | const AlertTitle = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
43 | )
44 | )
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Avatar = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "../../lib/utils"
6 |
7 | const badgeVariants = cva(
8 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
9 | {
10 | variants: {
11 | variant: {
12 | default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
36 | )
37 | }
38 |
39 | export { Badge, badgeVariants }
40 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { Slot } from "@radix-ui/react-slot"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "../../lib/utils"
7 |
8 | const buttonVariants = cva(
9 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
10 | {
11 | variants: {
12 | variant: {
13 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
14 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
16 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
17 | ghost: "hover:bg-accent hover:text-accent-foreground",
18 | link: "text-primary underline-offset-4 hover:underline",
19 | },
20 | size: {
21 | default: "h-10 px-4 py-2",
22 | sm: "h-9 rounded-md px-3",
23 | lg: "h-11 rounded-md px-8",
24 | icon: "h-10 w-10",
25 | },
26 | },
27 | defaultVariants: {
28 | variant: "default",
29 | size: "default",
30 | },
31 | }
32 | )
33 |
34 | export interface ButtonProps
35 | extends React.ButtonHTMLAttributes,
36 | VariantProps {
37 | asChild?: boolean
38 | }
39 |
40 | const Button = React.forwardRef(
41 | ({ className, variant, size, asChild = false, ...props }, ref) => {
42 | const Comp = asChild ? Slot : "button"
43 | return (
44 |
49 | )
50 | }
51 | )
52 | Button.displayName = "Button"
53 |
54 | export { Button, buttonVariants }
55 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "../../lib/utils"
4 |
5 | const Card = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
12 | )
13 | )
14 | Card.displayName = "Card"
15 |
16 | const CardHeader = React.forwardRef>(
17 | ({ className, ...props }, ref) => (
18 |
23 | )
24 | )
25 | CardHeader.displayName = "CardHeader"
26 |
27 | const CardTitle = React.forwardRef>(
28 | ({ className, ...props }, ref) => (
29 |
34 | )
35 | )
36 | CardTitle.displayName = "CardTitle"
37 |
38 | const CardDescription = React.forwardRef<
39 | HTMLParagraphElement,
40 | React.HTMLAttributes
41 | >(({ className, ...props }, ref) => (
42 |
47 | ))
48 | CardDescription.displayName = "CardDescription"
49 |
50 | const CardContent = React.forwardRef>(
51 | ({ className, ...props }, ref) => (
52 |
57 | )
58 | )
59 | CardContent.displayName = "CardContent"
60 |
61 | const CardFooter = React.forwardRef>(
62 | ({ className, ...props }, ref) => (
63 |
68 | )
69 | )
70 | CardFooter.displayName = "CardFooter"
71 |
72 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
73 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
6 | import { Check } from "lucide-react"
7 |
8 | import { cn } from "../../lib/utils"
9 |
10 | const Checkbox = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 |
23 |
24 |
25 |
26 | ))
27 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
28 |
29 | export { Checkbox }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4 |
5 | const Collapsible = CollapsiblePrimitive.Root
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const HoverCard = HoverCardPrimitive.Root
10 |
11 | const HoverCardTrigger = HoverCardPrimitive.Trigger
12 |
13 | const HoverCardContent = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
17 |
27 | ))
28 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
29 |
30 | export { HoverCard, HoverCardTrigger, HoverCardContent }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "../../lib/utils"
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | )
20 | }
21 | )
22 | Input.displayName = "Input"
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as LabelPrimitive from "@radix-ui/react-label"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "../../lib/utils"
9 |
10 | const labelVariants = cva(
11 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
12 | )
13 |
14 | const Label = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef & VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as PopoverPrimitive from "@radix-ui/react-popover"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Popover = PopoverPrimitive.Root
10 |
11 | const PopoverTrigger = PopoverPrimitive.Trigger
12 |
13 | const PopoverContent = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
17 |
18 |
28 |
29 | ))
30 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
31 |
32 | export { Popover, PopoverTrigger, PopoverContent }
33 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as ProgressPrimitive from "@radix-ui/react-progress"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Progress = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, value, ...props }, ref) => (
13 |
18 |
22 |
23 | ))
24 | Progress.displayName = ProgressPrimitive.Root.displayName
25 |
26 | export { Progress }
27 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
6 | import { Circle } from "lucide-react"
7 |
8 | import { cn } from "../../lib/utils"
9 |
10 | const RadioGroup = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => {
14 | return (
15 |
20 | )
21 | })
22 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
23 |
24 | const RadioGroupItem = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, ...props }, ref) => {
28 | return (
29 |
37 |
38 |
39 |
40 |
41 | )
42 | })
43 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
44 |
45 | export { RadioGroup, RadioGroupItem }
46 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Separator = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
13 |
24 | ))
25 | Separator.displayName = SeparatorPrimitive.Root.displayName
26 |
27 | export { Separator }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../../lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return (
5 |
9 | )
10 | }
11 |
12 | export { Skeleton }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as SwitchPrimitives from "@radix-ui/react-switch"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Switch = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
26 |
27 | ))
28 | Switch.displayName = SwitchPrimitives.Root.displayName
29 |
30 | export { Switch }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as TabsPrimitive from "@radix-ui/react-tabs"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const Tabs = TabsPrimitive.Root
10 |
11 | const TabsList = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | TabsList.displayName = TabsPrimitive.List.displayName
25 |
26 | const TabsTrigger = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
40 |
41 | const TabsContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
53 | ))
54 | TabsContent.displayName = TabsPrimitive.Content.displayName
55 |
56 | export { Tabs, TabsList, TabsTrigger, TabsContent }
57 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "../../lib/utils"
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(
8 | ({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | }
20 | )
21 | Textarea.displayName = "Textarea"
22 |
23 | export { Textarea }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "./toast"
11 | import { useToast } from "./use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
24 |
25 | {title && {title} }
26 | {description && {description} }
27 |
28 | {action}
29 |
30 |
31 | )
32 | })}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
6 | import { VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "../../lib/utils"
9 | import { toggleVariants } from "./toggle"
10 |
11 | const ToggleGroupContext = React.createContext>({
12 | size: "default",
13 | variant: "default",
14 | })
15 |
16 | const ToggleGroup = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef &
19 | VariantProps
20 | >(({ className, variant, size, children, ...props }, ref) => (
21 |
26 | {children}
27 |
28 | ))
29 |
30 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
31 |
32 | const ToggleGroupItem = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef &
35 | VariantProps
36 | >(({ className, children, variant, size, ...props }, ref) => {
37 | const context = React.useContext(ToggleGroupContext)
38 |
39 | return (
40 |
51 | {children}
52 |
53 | )
54 | })
55 |
56 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
57 |
58 | export { ToggleGroup, ToggleGroupItem }
59 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as TogglePrimitive from "@radix-ui/react-toggle"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "../../lib/utils"
9 |
10 | const toggleVariants = cva(
11 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
12 | {
13 | variants: {
14 | variant: {
15 | default: "bg-transparent",
16 | outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
17 | },
18 | size: {
19 | default: "h-10 px-3",
20 | sm: "h-9 px-2.5",
21 | lg: "h-11 px-5",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | }
29 | )
30 |
31 | const Toggle = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef & VariantProps
34 | >(({ className, variant, size, ...props }, ref) => (
35 |
40 | ))
41 |
42 | Toggle.displayName = TogglePrimitive.Root.displayName
43 |
44 | export { Toggle, toggleVariants }
45 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const TooltipProvider = TooltipPrimitive.Provider
10 |
11 | const Tooltip = TooltipPrimitive.Root
12 |
13 | const TooltipTrigger = TooltipPrimitive.Trigger
14 |
15 | const TooltipContent = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, sideOffset = 4, ...props }, ref) => (
19 |
28 | ))
29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
30 |
31 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | * {
7 | @apply border-border;
8 | }
9 | body {
10 | @apply min-h-screen bg-background font-sans text-foreground antialiased;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import tailwindConfig from "tailwind-config/tailwind.config.js";
2 |
3 | export default {
4 | ...tailwindConfig,
5 | }
6 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig/react-library.json",
3 | "include": [
4 | "."
5 | ],
6 | "compilerOptions": {
7 | "jsx": "react-jsx",
8 | "plugins": [
9 | {
10 | "name": "next"
11 | }
12 | ],
13 | "baseUrl": ".",
14 | "paths": {
15 | "@/*": [
16 | "src/*",
17 | "@/*",
18 | "assets/*"
19 | ],
20 | "react": [
21 | "./node_modules/@types/react"
22 | ]
23 | }
24 | },
25 | "exclude": [
26 | "dist",
27 | "build",
28 | "node_modules"
29 | ]
30 | }
--------------------------------------------------------------------------------
/packages/ui/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, Options } from "tsup"
2 |
3 | export default defineConfig((options: Options) => ({
4 | entry: ["./index.ts"],
5 | format: ["esm"],
6 | esbuildOptions(options) {
7 | options.banner = {
8 | js: '"use client"',
9 | }
10 | },
11 | dts: true,
12 | minify: true,
13 | external: ["react"],
14 | ...options,
15 | }))
16 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: false,
3 | printWidth: 100,
4 | trailingComma: "es5",
5 | arrowParens: "always",
6 | tabWidth: 2,
7 | useTabs: false,
8 | quoteProps: "as-needed",
9 | jsxSingleQuote: false,
10 | singleQuote: false,
11 | bracketSpacing: true,
12 | bracketSameLine: false,
13 | singleAttributePerLine: true,
14 | importOrder: [
15 | "^(react/(.*)$)|^(react$)",
16 | "^(next/(.*)$)|^(next$)",
17 | "",
18 | "",
19 | "",
20 | "^@/",
21 | "",
22 | "^[.][.]/",
23 | "^[./]",
24 | ],
25 | tailwindConfig: "./apps/web/tailwind.config.js",
26 | plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'],
27 | }
28 |
--------------------------------------------------------------------------------
/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Describe your works
2 |
3 | ## Issue ticket number and link
4 |
5 | ## Checklist before requesting a review
6 |
7 | - [ ] My code follows the style guidelines of this project
8 | - [ ] I have performed a self-review of my code
9 | - [ ] I have added tests that prove my fix is effective or that my feature works
10 | - [ ] New and existing unit tests pass locally with my changes
11 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["**/.env.*local", "**/.env"],
4 | "globalEnv": [
5 | "NEXT_PUBLIC_FRONTEND_URL",
6 | "NEXTAUTH_SECRET",
7 | "NEXTAUTH_URL",
8 | "GITHUB_ID",
9 | "GITHUB_SECRET",
10 | "JWT_SECRET",
11 | "NEXT_PUBLIC_FB_APP_ID",
12 | "DATABASE_URL",
13 | "NODE_ENV"
14 | ],
15 | "tasks": {
16 | "build": {
17 | "dependsOn": ["^db:generate", "^build"],
18 | "outputs": [".next/**", "!.next/cache/**"],
19 | "env": []
20 | },
21 | "dev": {
22 | "dependsOn": ["^db:generate", "^build"],
23 | "cache": false,
24 | "persistent": true
25 | },
26 | "clean": {
27 | "cache": false
28 | },
29 | "install": {
30 | "cache": false
31 | },
32 | "db:generate": {
33 | "cache": false
34 | },
35 | "db:push": {
36 | "cache": false
37 | },
38 | "db:migrate": {
39 | "cache": false
40 | },
41 | "db:reset": {
42 | "cache": false
43 | },
44 | "db:seed": {
45 | "cache": false
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------