├── .eslintrc.json ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── sign-image │ │ │ └── route.ts │ │ └── pusher-auth │ │ │ └── route.ts │ ├── favicon.ico │ ├── loading.tsx │ ├── (auth) │ │ ├── reset-password │ │ │ ├── page.tsx │ │ │ └── ResetPasswordForm.tsx │ │ ├── forgot-password │ │ │ ├── page.tsx │ │ │ └── ForgotPasswordForm.tsx │ │ ├── complete-profile │ │ │ ├── page.tsx │ │ │ └── CompleteProfileForm.tsx │ │ ├── login │ │ │ ├── page.tsx │ │ │ ├── SocialLogin.tsx │ │ │ └── LoginForm.tsx │ │ ├── register │ │ │ ├── page.tsx │ │ │ ├── success │ │ │ │ └── page.tsx │ │ │ ├── UserDetailsForm.tsx │ │ │ ├── ProfileForm.tsx │ │ │ └── RegisterForm.tsx │ │ └── verify-email │ │ │ └── page.tsx │ ├── members │ │ ├── [userId] │ │ │ ├── loading.tsx │ │ │ ├── page.tsx │ │ │ ├── photos │ │ │ │ └── page.tsx │ │ │ ├── chat │ │ │ │ ├── page.tsx │ │ │ │ ├── ChatForm.tsx │ │ │ │ ├── MessageBox.tsx │ │ │ │ └── MessageList.tsx │ │ │ └── layout.tsx │ │ ├── edit │ │ │ ├── page.tsx │ │ │ ├── photos │ │ │ │ ├── MemberPhotoUpload.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── EditForm.tsx │ │ ├── page.tsx │ │ ├── MemberCard.tsx │ │ └── MemberSidebar.tsx │ ├── globals.css │ ├── lists │ │ ├── page.tsx │ │ └── ListsTab.tsx │ ├── admin │ │ └── moderation │ │ │ └── page.tsx │ ├── session │ │ └── page.tsx │ ├── messages │ │ ├── page.tsx │ │ ├── MessageSidebar.tsx │ │ ├── MessageTableCell.tsx │ │ └── MessageTable.tsx │ ├── layout.tsx │ ├── error.tsx │ ├── page.tsx │ └── actions │ │ ├── adminActions.ts │ │ ├── memberActions.ts │ │ ├── userActions.ts │ │ ├── likeActions.ts │ │ ├── messageActions.ts │ │ └── authActions.ts ├── hooks │ ├── useRole.ts │ ├── usePresenceStore.ts │ ├── useFilterStore.ts │ ├── useMessageStore.ts │ ├── usePaginationStore.ts │ ├── useNotificationChannel.ts │ ├── usePresenceChannel.ts │ ├── useMessages.tsx │ └── useFilters.ts ├── lib │ ├── schemas │ │ ├── messageSchema.ts │ │ ├── loginSchema.ts │ │ ├── forgotPasswordSchema.ts │ │ ├── memberEditSchema.ts │ │ └── registerSchema.ts │ ├── cloudinary.ts │ ├── prisma.ts │ ├── mappings.ts │ ├── pusher.ts │ ├── mail.ts │ ├── tokens.ts │ └── util.ts ├── routes.ts ├── components │ ├── navbar │ │ ├── FiltersWrapper.tsx │ │ ├── NavLink.tsx │ │ ├── UserMenu.tsx │ │ ├── TopNav.tsx │ │ └── Filters.tsx │ ├── LoadingComponent.tsx │ ├── EmptyState.tsx │ ├── ClientSession.tsx │ ├── PresenceAvatar.tsx │ ├── PresenceDot.tsx │ ├── DeleteButton.tsx │ ├── ImageUploadButton.tsx │ ├── StarButton.tsx │ ├── ResultMessage.tsx │ ├── CardInnerWrapper.tsx │ ├── LikeButton.tsx │ ├── NewMessageToast.tsx │ ├── Providers.tsx │ ├── NotificationToast.tsx │ ├── AppModal.tsx │ ├── CardWrapper.tsx │ ├── PaginationComponent.tsx │ ├── MemberPhotos.tsx │ └── MemberImage.tsx ├── types │ ├── next-auth.d.ts │ └── index.d.ts ├── auth.ts ├── auth.config.ts └── middleware.ts ├── public ├── images │ ├── f1.jpeg │ ├── f2.jpeg │ ├── f3.jpeg │ ├── f4.jpeg │ ├── f5.jpeg │ ├── m1.jpeg │ ├── m2.jpeg │ ├── m3.jpeg │ ├── m4.jpeg │ ├── m5.jpeg │ ├── user.png │ └── sunset1.png ├── vercel.svg └── next.svg ├── postcss.config.js ├── docker-compose.yml ├── prisma ├── migrations │ ├── 20240413100752_added_is_approved │ │ └── migration.sql │ ├── migration_lock.toml │ └── 20240413085447_initial │ │ └── migration.sql ├── seed.ts ├── schema.prisma └── membersData.ts ├── next.config.mjs ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── .env.example ├── README.md └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth" -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/images/f1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/f1.jpeg -------------------------------------------------------------------------------- /public/images/f2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/f2.jpeg -------------------------------------------------------------------------------- /public/images/f3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/f3.jpeg -------------------------------------------------------------------------------- /public/images/f4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/f4.jpeg -------------------------------------------------------------------------------- /public/images/f5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/f5.jpeg -------------------------------------------------------------------------------- /public/images/m1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/m1.jpeg -------------------------------------------------------------------------------- /public/images/m2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/m2.jpeg -------------------------------------------------------------------------------- /public/images/m3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/m3.jpeg -------------------------------------------------------------------------------- /public/images/m4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/m4.jpeg -------------------------------------------------------------------------------- /public/images/m5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/m5.jpeg -------------------------------------------------------------------------------- /public/images/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/user.png -------------------------------------------------------------------------------- /public/images/sunset1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/next-match/HEAD/public/images/sunset1.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | environment: 5 | - POSTGRES_PASSWORD=postgrespw 6 | ports: 7 | - 5432:5432 -------------------------------------------------------------------------------- /prisma/migrations/20240413100752_added_is_approved/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Photo" ADD COLUMN "isApproved" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingComponent from '@/components/LoadingComponent'; 2 | 3 | export default function loading() { 4 | return ( 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /src/hooks/useRole.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react' 2 | 3 | export const useRole = () => { 4 | const session = useSession(); 5 | 6 | return session.data?.user?.role 7 | } -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import ResetPasswordForm from './ResetPasswordForm'; 2 | 3 | export default function ResetPassword() { 4 | return ( 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /src/app/(auth)/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import ForgotPasswordForm from './ForgotPasswordForm'; 2 | 3 | export default function ForgotPasswordPage() { 4 | return ( 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /src/app/(auth)/complete-profile/page.tsx: -------------------------------------------------------------------------------- 1 | import CompleteProfileForm from './CompleteProfileForm'; 2 | 3 | export default function CompleteProfilePage() { 4 | return ( 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | staleTimes: { 5 | dynamic: 0 6 | } 7 | } 8 | }; 9 | 10 | export default nextConfig; 11 | -------------------------------------------------------------------------------- /src/lib/schemas/messageSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const messageSchema = z.object({ 4 | text: z.string().min(1, { 5 | message: 'Content is reqired' 6 | }) 7 | }) 8 | 9 | export type MessageSchema = z.infer -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | export const publicRoutes = [ 2 | '/' 3 | ]; 4 | 5 | export const authRoutes = [ 6 | '/login', 7 | '/register', 8 | '/register/success', 9 | '/verify-email', 10 | '/forgot-password', 11 | '/reset-password' 12 | ]; -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LoginForm from './LoginForm' 3 | 4 | export default function LoginPage() { 5 | return ( 6 |
7 | 8 |
9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/cloudinary.ts: -------------------------------------------------------------------------------- 1 | import cloudinary from 'cloudinary'; 2 | 3 | cloudinary.v2.config({ 4 | cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, 5 | api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY, 6 | api_secret: process.env.CLOUDINARY_API_SECRET 7 | }); 8 | 9 | export {cloudinary}; -------------------------------------------------------------------------------- /src/app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import RegisterForm from './RegisterForm' 3 | 4 | export default function RegisterPage() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const globalForPrisma = global as unknown as {prisma: PrismaClient}; 4 | 5 | export const prisma = globalForPrisma.prisma || new PrismaClient({log: ['query']}); 6 | 7 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; -------------------------------------------------------------------------------- /src/lib/schemas/loginSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const loginSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(6, { 6 | message: 'Password must be at least 6 characters' 7 | }) 8 | }) 9 | 10 | export type LoginSchema = z.infer -------------------------------------------------------------------------------- /src/components/navbar/FiltersWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation' 4 | import Filters from './Filters'; 5 | 6 | export default function FiltersWrapper() { 7 | const pathname = usePathname(); 8 | 9 | if (pathname === '/members') return 10 | else return null; 11 | } -------------------------------------------------------------------------------- /src/app/members/[userId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@nextui-org/react' 2 | import React from 'react' 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .vertical-center { 7 | height: calc(100vh - 64px); 8 | } 9 | 10 | .page-size-box { 11 | @apply cursor-pointer border-2 hover:bg-gray-100 12 | flex items-center justify-center rounded-lg 13 | shadow-sm min-w-[36px] w-9 h-9 text-sm 14 | } 15 | } -------------------------------------------------------------------------------- /src/app/api/sign-image/route.ts: -------------------------------------------------------------------------------- 1 | import { cloudinary } from '@/lib/cloudinary'; 2 | 3 | export async function POST(request: Request) { 4 | const body = (await request.json()) as {paramsToSign: Record} 5 | const {paramsToSign} = body; 6 | 7 | const signature = cloudinary.v2.utils.api_sign_request(paramsToSign, 8 | process.env.CLOUDINARY_API_SECRET as string); 9 | 10 | return Response.json({signature}); 11 | } -------------------------------------------------------------------------------- /src/lib/schemas/forgotPasswordSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const resetPasswordSchema = z.object({ 4 | password: z.string().min(6, { 5 | message: 'Password must be 6 characters' 6 | }), 7 | confirmPassword: z.string().min(6) 8 | }).refine(data => data.password === data.confirmPassword, { 9 | message: 'Passwords do not match', 10 | path: ['confirmPassword'] 11 | }) 12 | 13 | export type ResetPasswordSchema = z.infer -------------------------------------------------------------------------------- /src/components/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@nextui-org/react' 2 | import React from 'react' 3 | 4 | export default function LoadingComponent({ label }: { label?: string }) { 5 | return ( 6 |
7 | 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/schemas/memberEditSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | export const memberEditSchema = z.object({ 4 | name: z.string().min(1, { 5 | message: 'Name is required' 6 | }), 7 | description: z.string().min(1, { 8 | message: 'Description is required' 9 | }), 10 | city: z.string().min(1, { 11 | message: 'City is required' 12 | }), 13 | country: z.string().min(1, { 14 | message: 'Country is required' 15 | }) 16 | }) 17 | 18 | export type MemberEditSchema = z.infer -------------------------------------------------------------------------------- /src/app/lists/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ListsTab from './ListsTab' 3 | import { fetchCurrentUserLikeIds, fetchLikedMembers } from '../actions/likeActions' 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function ListsPage({searchParams}: {searchParams: {type: string}}) { 8 | const likeIds = await fetchCurrentUserLikeIds(); 9 | const members = await fetchLikedMembers(searchParams.type); 10 | 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@prisma/client'; 2 | import { DefaultSession } from 'next-auth'; 3 | 4 | declare module 'next-auth' { 5 | interface User { 6 | profileComplete: boolean; 7 | role: Role; 8 | } 9 | 10 | interface Session { 11 | user: { 12 | profileComplete: boolean; 13 | role: Role; 14 | } & DefaultSession['user'] 15 | } 16 | } 17 | 18 | declare module 'next-auth/jwt' { 19 | interface JWT { 20 | profileComplete: boolean; 21 | role: Role; 22 | } 23 | } -------------------------------------------------------------------------------- /src/app/admin/moderation/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUnapprovedPhotos } from '@/app/actions/adminActions' 2 | import MemberPhotos from '@/components/MemberPhotos'; 3 | import { Divider } from '@nextui-org/react'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function PhotoModerationPage() { 8 | const photos = await getUnapprovedPhotos(); 9 | return ( 10 |
11 |

Photos awaiting moderation

12 | 13 | 14 |
15 | ) 16 | } -------------------------------------------------------------------------------- /src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardHeader } from '@nextui-org/react' 2 | import React from 'react' 3 | 4 | export default function EmptyState() { 5 | return ( 6 |
7 | 8 | 9 | There are no results for this filter 10 | 11 | 12 | Please select a different filter 13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/members/[userId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getMemberByUserId } from '@/app/actions/memberActions' 2 | import CardInnerWrapper from '@/components/CardInnerWrapper'; 3 | import { notFound } from 'next/navigation'; 4 | import React from 'react' 5 | 6 | export default async function MemberDetailedPage({ params }: { params: { userId: string } }) { 7 | const member = await getMemberByUserId(params.userId); 8 | 9 | if (!member) return notFound(); 10 | 11 | return ( 12 | {member.description}} 15 | /> 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ClientSession.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSession } from 'next-auth/react' 4 | 5 | export default function ClientSession() { 6 | const session = useSession(); 7 | return ( 8 |
9 |

Client session data:

10 | {session ? ( 11 |
12 |
{JSON.stringify(session, null, 2)}
13 |
14 | ) : ( 15 |
Not signed in
16 | )} 17 |
18 | ) 19 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { nextui } from '@nextui-org/react'; 2 | import type { Config } from "tailwindcss"; 3 | 4 | const config: Config = { 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | theme: { 12 | extend: { 13 | backgroundImage: { 14 | 'dark-gradient': 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)' 15 | } 16 | }, 17 | }, 18 | darkMode: 'class', 19 | plugins: [nextui()], 20 | }; 21 | export default config; 22 | -------------------------------------------------------------------------------- /src/app/(auth)/register/success/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import CardWrapper from '@/components/CardWrapper'; 4 | import { useRouter } from 'next/navigation'; 5 | import { FaCheckCircle } from 'react-icons/fa'; 6 | 7 | export default function RegisterSuccessPage() { 8 | const router = useRouter(); 9 | 10 | return ( 11 | router.push('/login')} 15 | actionLabel='Go to login' 16 | headerIcon={FaCheckCircle} 17 | /> 18 | ) 19 | } -------------------------------------------------------------------------------- /src/hooks/usePresenceStore.ts: -------------------------------------------------------------------------------- 1 | import {create} from 'zustand'; 2 | import {devtools} from 'zustand/middleware'; 3 | 4 | type PresenceState = { 5 | members: string[]; 6 | add: (id: string) => void; 7 | remove: (id: string) => void; 8 | set: (ids: string[]) => void; 9 | } 10 | 11 | const usePresenceStore = create()(devtools((set) => ({ 12 | members: [], 13 | add: (id) => set((state) => ({members: [...state.members, id]})), 14 | remove: (id) => set((state) => ({members: state.members.filter(member => member !== id)})), 15 | set: (ids) => set({members: ids}) 16 | }), {name: 'PresenceStoreDemo'})) 17 | 18 | export default usePresenceStore; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/session/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth'; 2 | import ClientSession from '@/components/ClientSession'; 3 | 4 | export default async function Home() { 5 | const session = await auth(); 6 | 7 | return ( 8 |
9 |
10 |

Server session data:

11 | {session ? ( 12 |
13 |
{JSON.stringify(session, null, 2)}
14 |
15 | ) : ( 16 |
Not signed in
17 | )} 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/PresenceAvatar.tsx: -------------------------------------------------------------------------------- 1 | import usePresenceStore from '@/hooks/usePresenceStore'; 2 | import { Avatar, Badge } from '@nextui-org/react'; 3 | import React from 'react' 4 | 5 | type Props = { 6 | userId?: string; 7 | src?: string | null; 8 | } 9 | 10 | export default function PresenceAvatar({userId, src}: Props) { 11 | const { members } = usePresenceStore(state => ({ 12 | members: state.members 13 | })); 14 | 15 | const isOnline = userId && members.indexOf(userId) !== -1; 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/mappings.ts: -------------------------------------------------------------------------------- 1 | import { formatShortDateTime } from './util'; 2 | import { MessageWithSenderRecipient } from '@/types'; 3 | 4 | export function mapMessageToMessageDto(message: MessageWithSenderRecipient) { 5 | return { 6 | id: message.id, 7 | text: message.text, 8 | created: formatShortDateTime(message.created), 9 | dateRead: message.dateRead ? formatShortDateTime(message.dateRead) : null, 10 | senderId: message.sender?.userId, 11 | senderName: message.sender?.name, 12 | senderImage: message.sender?.image, 13 | recipientId: message.recipient?.userId, 14 | recipientImage: message.recipient?.image, 15 | recipientName: message.recipient?.name 16 | } 17 | } -------------------------------------------------------------------------------- /src/hooks/useFilterStore.ts: -------------------------------------------------------------------------------- 1 | import { UserFilters } from '@/types' 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | type FilterState = { 6 | filters: UserFilters; 7 | setFilters: (filterName: keyof FilterState['filters'], value: any) => void; 8 | } 9 | 10 | const useFilterStore = create()(devtools((set) => ({ 11 | filters: { 12 | ageRange: [18,100], 13 | gender: ['male', 'female'], 14 | orderBy: 'updated', 15 | withPhoto: true 16 | }, 17 | setFilters: (filterName, value) => set(state => { 18 | return { 19 | filters: {...state.filters, [filterName]: value} 20 | } 21 | }) 22 | }))) 23 | 24 | export default useFilterStore; -------------------------------------------------------------------------------- /src/app/members/[userId]/photos/page.tsx: -------------------------------------------------------------------------------- 1 | import { getMemberPhotosByUserId } from '@/app/actions/memberActions' 2 | import MemberPhotos from '@/components/MemberPhotos'; 3 | import { CardHeader, Divider, CardBody } from '@nextui-org/react' 4 | import React from 'react' 5 | 6 | export default async function PhotosPage({ params }: { params: { userId: string } }) { 7 | const photos = await getMemberPhotosByUserId(params.userId); 8 | return ( 9 | <> 10 | 11 | Photos 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/messages/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MessageSidebar from './MessageSidebar' 3 | import { getMessagesByContainer } from '../actions/messageActions' 4 | import MessageTable from './MessageTable'; 5 | 6 | export default async function MessagesPage({searchParams}: {searchParams: {container: string}}) { 7 | const {messages, nextCursor} = await getMessagesByContainer(searchParams.container); 8 | console.log({messages}); 9 | 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/PresenceDot.tsx: -------------------------------------------------------------------------------- 1 | import usePresenceStore from '@/hooks/usePresenceStore'; 2 | import { Member } from '@prisma/client' 3 | import React from 'react' 4 | import { GoDot, GoDotFill } from 'react-icons/go'; 5 | 6 | type Props = { 7 | member: Member; 8 | } 9 | 10 | export default function PresenceDot({ member }: Props) { 11 | const {members} = usePresenceStore(state => ({ 12 | members: state.members 13 | })) 14 | 15 | const isOnline = members.indexOf(member.userId) !== -1; 16 | 17 | if (!isOnline) return null; 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" 2 | 3 | AUTH_SECRET="somethingreallyreallysecret" 4 | 5 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="your cloud name" 6 | NEXT_PUBLIC_CLOUDINARY_API_KEY="Your api key" 7 | CLOUDINARY_API_SECRET="api secret" 8 | 9 | NEXT_PUBLIC_PUSHER_APP_KEY="d9432788a46cdf9fb375" 10 | PUSHER_APP_ID="Your app id" 11 | PUSHER_SECRET="Your pusher secret" 12 | 13 | RESEND_API_KEY="your resend api key" 14 | 15 | GITHUB_CLIENT_ID="448ed8860b968d871843" 16 | GITHUB_CLIENT_SECRET="24c5504868baccd678049a8e21c72b87b1419e28" 17 | 18 | GOOGLE_CLIENT_ID="188963163727-a3f7sadrepsflkekkc0lf0ii9au3mj9r.apps.googleusercontent.com" 19 | GOOGLE_CLIENT_SECRET="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" 20 | 21 | NEXT_PUBLIC_BASE_URL="http://localhost:3000" -------------------------------------------------------------------------------- /src/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AiFillDelete, AiOutlineDelete } from 'react-icons/ai'; 3 | import { PiSpinnerGap } from 'react-icons/pi'; 4 | 5 | type Props = { 6 | loading: boolean; 7 | } 8 | 9 | export default function DeleteButton({ loading }: Props) { 10 | return ( 11 |
12 | {!loading ? ( 13 | <> 14 | 15 | 16 | 17 | ) : ( 18 | 19 | )} 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ImageUploadButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react' 4 | import {CldUploadButton, CloudinaryUploadWidgetResults} from 'next-cloudinary'; 5 | import {HiPhoto} from 'react-icons/hi2' 6 | 7 | type Props = { 8 | onUploadImage: (result: CloudinaryUploadWidgetResults) => void; 9 | } 10 | 11 | export default function ImageUploadButton({onUploadImage}: Props) { 12 | return ( 13 | 21 | 22 | Upload new image 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/api/pusher-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth'; 2 | import { pusherServer } from '@/lib/pusher'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function POST(request: Request) { 6 | try { 7 | const session = await auth(); 8 | 9 | if (!session?.user?.id) { 10 | return new Response('Unauthorised', {status: 401}) 11 | } 12 | 13 | const body = await request.formData(); 14 | 15 | const socketId = body.get('socket_id') as string; 16 | const channel = body.get('channel_name') as string; 17 | const data = { 18 | user_id: session.user.id 19 | } 20 | 21 | const authResonse = pusherServer.authorizeChannel(socketId, channel, data); 22 | 23 | return NextResponse.json(authResonse); 24 | } catch (error) { 25 | 26 | } 27 | } -------------------------------------------------------------------------------- /src/components/StarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AiFillStar, AiOutlineStar } from 'react-icons/ai'; 3 | import { PiSpinnerGap } from 'react-icons/pi'; 4 | 5 | type Props = { 6 | selected: boolean; 7 | loading: boolean; 8 | } 9 | 10 | export default function StarButton({ selected, loading }: Props) { 11 | return ( 12 |
13 | {!loading ? ( 14 | <> 15 | 16 | 17 | 18 | ) : ( 19 | 20 | )} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/members/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { CardHeader, Divider, CardBody } from '@nextui-org/react' 2 | import React from 'react' 3 | import EditForm from './EditForm' 4 | import { getAuthUserId } from '@/app/actions/authActions' 5 | import { getMemberByUserId } from '@/app/actions/memberActions'; 6 | import { notFound } from 'next/navigation'; 7 | 8 | export default async function MemberEditPage() { 9 | const userId = await getAuthUserId(); 10 | 11 | const member = await getMemberByUserId(userId); 12 | 13 | if (!member) return notFound(); 14 | 15 | return ( 16 | <> 17 | 18 | Edit Profile 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/navbar/NavLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useMessageStore from '@/hooks/useMessageStore'; 4 | import { NavbarItem } from '@nextui-org/react' 5 | import Link from 'next/link' 6 | import { usePathname } from 'next/navigation'; 7 | import React from 'react' 8 | 9 | type Props = { 10 | href: string; 11 | label: string; 12 | } 13 | 14 | export default function NavLink({href, label}: Props) { 15 | const pathname = usePathname(); 16 | const {unreadCount} = useMessageStore(state => ({ 17 | unreadCount: state.unreadCount 18 | })) 19 | 20 | return ( 21 | 22 | {label} 23 | {href === '/messages' && unreadCount > 0 && ( 24 | ({unreadCount}) 25 | )} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/members/[userId]/chat/page.tsx: -------------------------------------------------------------------------------- 1 | import CardInnerWrapper from '@/components/CardInnerWrapper' 2 | import React from 'react' 3 | import ChatForm from './ChatForm' 4 | import { getMessageThread } from '@/app/actions/messageActions' 5 | import { getAuthUserId } from '@/app/actions/authActions'; 6 | import MessageList from './MessageList'; 7 | import { createChatId } from '@/lib/util'; 8 | 9 | export default async function ChatPage({params}: {params: {userId: string}}) { 10 | const userId = await getAuthUserId(); 11 | const messages = await getMessageThread(params.userId); 12 | const chatId = createChatId(userId, params.userId); 13 | 14 | return ( 15 | 19 | } 20 | footer={} 21 | /> 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ResultMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ActionResult } from '@/types' 2 | import clsx from 'clsx'; 3 | import { FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa'; 4 | 5 | type Props = { 6 | result: ActionResult | null; 7 | } 8 | 9 | export default function ResultMessage({ result }: Props) { 10 | if (!result) return null; 11 | return ( 12 |
16 | {result.status === 'success' ? ( 17 | 18 | ) : ( 19 | 20 | )} 21 |

{result.status === 'success' ? result.data : result.error as string}

22 |
23 | ) 24 | } -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth" 2 | import { PrismaAdapter } from "@auth/prisma-adapter" 3 | import authConfig from "./auth.config" 4 | import { prisma } from './lib/prisma' 5 | import { Role } from '@prisma/client' 6 | 7 | export const { handlers: {GET, POST}, auth, signIn, signOut } = NextAuth({ 8 | callbacks: { 9 | async jwt({user, token}) { 10 | if (user) { 11 | console.log({user}); 12 | token.profileComplete = user.profileComplete; 13 | token.role = user.role 14 | } 15 | return token; 16 | }, 17 | async session({token, session}) { 18 | if (token.sub && session.user) { 19 | session.user.id = token.sub; 20 | session.user.profileComplete = token.profileComplete as boolean; 21 | session.user.role = token.role as Role; 22 | } 23 | return session; 24 | } 25 | }, 26 | adapter: PrismaAdapter(prisma), 27 | session: { strategy: "jwt" }, 28 | ...authConfig, 29 | }) -------------------------------------------------------------------------------- /src/app/members/edit/photos/MemberPhotoUpload.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { addImage } from '@/app/actions/userActions'; 4 | import ImageUploadButton from '@/components/ImageUploadButton' 5 | import { CloudinaryUploadWidgetResults } from 'next-cloudinary'; 6 | import { useRouter } from 'next/navigation' 7 | import React from 'react' 8 | import { toast } from 'react-toastify'; 9 | 10 | export default function MemberPhotoUpload() { 11 | const router = useRouter(); 12 | 13 | const onAddImage = async (result: CloudinaryUploadWidgetResults) => { 14 | if (result.info && typeof result.info === 'object') { 15 | await addImage(result.info.secure_url, result.info.public_id); 16 | router.refresh(); 17 | } else { 18 | toast.error('Problem adding image'); 19 | } 20 | } 21 | 22 | return ( 23 |
24 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import Providers from '@/components/Providers'; 4 | import TopNav from '@/components/navbar/TopNav'; 5 | import { auth } from '@/auth'; 6 | 7 | export const metadata: Metadata = { 8 | title: "NextMatch", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default async function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | const session = await auth(); 18 | const userId = session?.user?.id || null; 19 | const profileComplete = session?.user.profileComplete as boolean; 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 |
27 | {children} 28 |
29 | 30 |
31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/pusher.ts: -------------------------------------------------------------------------------- 1 | import PusherServer from 'pusher'; 2 | import PusherClient from 'pusher-js'; 3 | 4 | declare global { 5 | var pusherServerInstance: PusherServer | undefined; 6 | var pusherClientInstance: PusherClient | undefined; 7 | } 8 | 9 | if (!global.pusherServerInstance) { 10 | global.pusherServerInstance = new PusherServer({ 11 | appId: process.env.PUSHER_APP_ID!, 12 | key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, 13 | secret: process.env.PUSHER_SECRET!, 14 | cluster: 'ap1', 15 | useTLS: true 16 | }) 17 | } 18 | 19 | if (!global.pusherClientInstance) { 20 | global.pusherClientInstance = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, { 21 | channelAuthorization: { 22 | endpoint: '/api/pusher-auth', 23 | transport: 'ajax' 24 | }, 25 | cluster: 'ap1' 26 | }) 27 | } 28 | 29 | export const pusherServer = global.pusherServerInstance; 30 | export const pusherClient = global.pusherClientInstance; -------------------------------------------------------------------------------- /src/components/CardInnerWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { CardHeader, Divider, CardBody, CardFooter } from '@nextui-org/react' 2 | import React, { ReactNode } from 'react' 3 | 4 | type Props = { 5 | header: ReactNode | string; 6 | body: ReactNode; 7 | footer?: ReactNode; 8 | } 9 | 10 | export default function CardInnerWrapper({header, body, footer}: Props) { 11 | return ( 12 | <> 13 | 14 | {typeof (header) === 'string' ? ( 15 |
16 | {header} 17 |
18 | ) : ( 19 | <>{header} 20 | )} 21 |
22 | 23 | 24 | {body} 25 | 26 | {footer && ( 27 | 28 | {footer} 29 | 30 | )} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(auth)/login/SocialLogin.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@nextui-org/react'; 2 | import { FaGithub } from 'react-icons/fa'; 3 | import {FcGoogle} from 'react-icons/fc'; 4 | import {signIn} from 'next-auth/react'; 5 | 6 | export default function SocialLogin() { 7 | 8 | const onClick = (provider: 'google' | 'github') => { 9 | signIn(provider, { 10 | callbackUrl: '/members' 11 | }) 12 | } 13 | 14 | return ( 15 |
16 | 24 | 32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /src/lib/schemas/registerSchema.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import { calculateAge } from '../util'; 3 | 4 | export const registerSchema = z.object({ 5 | name: z.string().min(3), 6 | email: z.string().email(), 7 | password: z.string().min(6, { 8 | message: 'Password must be at least 6 characters' 9 | }) 10 | }) 11 | 12 | export const profileSchema = z.object({ 13 | gender: z.string().min(1), 14 | description: z.string().min(1), 15 | city: z.string().min(1), 16 | country: z.string().min(1), 17 | dateOfBirth: z.string().min(1, { 18 | message: 'Date of birth is required' 19 | }).refine(dateString => { 20 | const age = calculateAge(new Date(dateString)); 21 | return age >= 18; 22 | }, { 23 | message: 'You must be at least 18 to use this app' 24 | }), 25 | }); 26 | 27 | export const combinedRegisterSchema = registerSchema.and(profileSchema); 28 | 29 | export type ProfileSchema = z.infer; 30 | 31 | export type RegisterSchema = z.infer -------------------------------------------------------------------------------- /src/components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { toggleLikeMember } from '@/app/actions/likeActions'; 4 | import { useRouter } from 'next/navigation'; 5 | import React from 'react' 6 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 7 | import { PiSpinnerGap } from 'react-icons/pi'; 8 | 9 | type Props = { 10 | loading: boolean; 11 | hasLiked: boolean; 12 | toggleLike: () => void; 13 | } 14 | 15 | export default function LikeButton({ loading, toggleLike, hasLiked }: Props) { 16 | 17 | return ( 18 | <> 19 | {!loading ? ( 20 |
21 | 22 | 23 |
24 | ) : ( 25 | 26 | )} 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/members/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getMembers } from '../actions/memberActions' 3 | import MemberCard from './MemberCard'; 4 | import { fetchCurrentUserLikeIds } from '../actions/likeActions'; 5 | import PaginationComponent from '@/components/PaginationComponent'; 6 | import { GetMemberParams } from '@/types'; 7 | import EmptyState from '@/components/EmptyState'; 8 | 9 | export default async function MembersPage({ searchParams }: { searchParams: GetMemberParams }) { 10 | const {items: members, totalCount} = await getMembers(searchParams); 11 | const likeIds = await fetchCurrentUserLikeIds(); 12 | 13 | return ( 14 | <> 15 | {!members || members.length === 0 ? ( 16 | 17 | ) : ( 18 | <> 19 |
20 | {members && members.map(member => ( 21 | 22 | ))} 23 |
24 | 25 | 26 | )} 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(auth)/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { verifyEmail } from '@/app/actions/authActions' 2 | import CardWrapper from '@/components/CardWrapper'; 3 | import ResultMessage from '@/components/ResultMessage'; 4 | import { Spinner } from '@nextui-org/react'; 5 | import { MdOutlineMailOutline } from 'react-icons/md'; 6 | 7 | export default async function VerifyEmailPage({ searchParams }: { searchParams: { token: string } }) { 8 | const result = await verifyEmail(searchParams.token); 9 | 10 | return ( 11 | 16 |
17 |

Verifying your email address. Please wait...

18 | {!result && } 19 |
20 | 21 | } 22 | footer={ 23 | 24 | } 25 | 26 | /> 27 | ) 28 | } -------------------------------------------------------------------------------- /src/components/NewMessageToast.tsx: -------------------------------------------------------------------------------- 1 | import { MessageDto } from '@/types' 2 | import Link from 'next/link' 3 | import React from 'react' 4 | import {Image} from '@nextui-org/react'; 5 | import { transformImageUrl } from '@/lib/util'; 6 | import { toast } from 'react-toastify'; 7 | 8 | type Props = { 9 | message: MessageDto 10 | } 11 | 12 | export default function NewMessageToast({message}: Props) { 13 | return ( 14 | 15 |
16 | Sender image 22 |
23 |
24 |
{message.senderName} sent you a message
25 |
Click to view
26 |
27 | 28 | ) 29 | } 30 | 31 | // export const newMessageToast = (message: MessageDto) => { 32 | // toast() 33 | // } -------------------------------------------------------------------------------- /src/app/members/[userId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getMemberByUserId } from '@/app/actions/memberActions' 2 | import React, { ReactNode } from 'react' 3 | import MemberSidebar from '../MemberSidebar'; 4 | import { notFound } from 'next/navigation'; 5 | import { Card } from '@nextui-org/react'; 6 | 7 | export default async function Layout({ children, params }: 8 | { children: ReactNode, params: { userId: string } }) { 9 | 10 | const member = await getMemberByUserId(params.userId); 11 | if (!member) return notFound(); 12 | 13 | const basePath = `/members/${member.userId}` 14 | 15 | const navLinks = [ 16 | {name: 'Profile', href: `${basePath}`}, 17 | {name: 'Photos', href: `${basePath}/photos`}, 18 | {name: 'Chat', href: `${basePath}/chat`} 19 | ] 20 | 21 | return ( 22 |
23 |
24 | 25 |
26 |
27 | 28 | {children} 29 | 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/members/edit/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getMemberByUserId } from '@/app/actions/memberActions' 2 | import React, { ReactNode } from 'react' 3 | import MemberSidebar from '../MemberSidebar'; 4 | import { notFound } from 'next/navigation'; 5 | import { Card } from '@nextui-org/react'; 6 | import { getAuthUserId } from '@/app/actions/authActions'; 7 | 8 | export default async function Layout({ children }:{ children: ReactNode}) { 9 | const userId = await getAuthUserId(); 10 | 11 | const member = await getMemberByUserId(userId); 12 | if (!member) return notFound(); 13 | 14 | const basePath = `/members/edit` 15 | 16 | const navLinks = [ 17 | {name: 'Edit Profile', href: `${basePath}`}, 18 | {name: 'Update Photos', href: `${basePath}/photos`} 19 | ] 20 | 21 | return ( 22 |
23 |
24 | 25 |
26 |
27 | 28 | {children} 29 | 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/mail.ts: -------------------------------------------------------------------------------- 1 | import {Resend} from 'resend'; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY); 4 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 5 | 6 | export async function sendVerificationEmail(email: string, token: string) { 7 | const link = `${baseUrl}/verify-email?token=${token}`; 8 | 9 | return resend.emails.send({ 10 | from: 'mail@nextmatch.trycatchlearn.com', 11 | to: email, 12 | subject: 'Verify your email address', 13 | html: ` 14 |

Verify your email address

15 |

Click the link below to verify your email address

16 | Verify email 17 | ` 18 | }) 19 | } 20 | 21 | export async function sendPasswordResetEmail(email: string, token: string) { 22 | const link = `${baseUrl}/reset-password?token=${token}`; 23 | 24 | return resend.emails.send({ 25 | from: 'mail@nextmatch.trycatchlearn.com', 26 | to: email, 27 | subject: 'Reset your password', 28 | html: ` 29 |

You have requested to reset your password

30 |

Click the link below to reset password

31 | Reset password 32 | ` 33 | }) 34 | } -------------------------------------------------------------------------------- /src/hooks/useMessageStore.ts: -------------------------------------------------------------------------------- 1 | import { MessageDto } from '@/types' 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | type MessageState = { 6 | messages: MessageDto[]; 7 | unreadCount: number; 8 | add: (message: MessageDto) => void; 9 | remove: (id: string) => void; 10 | set: (messages: MessageDto[]) => void; 11 | updateUnreadCount: (amount: number) => void; 12 | resetMessages: () => void; 13 | } 14 | 15 | const useMessageStore = create()(devtools((set) => ({ 16 | messages: [], 17 | unreadCount: 0, 18 | add: (message) => set(state => ({messages: [message, ...state.messages]})), 19 | remove: (id) => set(state => ({messages: state.messages.filter(message => message.id !== id)})), 20 | set: (messages) => set(state => { 21 | const map = new Map([...state.messages, ...messages].map(m => [m.id, m])); 22 | const uniqueMessages = Array.from(map.values()); 23 | return {messages: uniqueMessages} 24 | }), 25 | updateUnreadCount: (amount: number) => set(state => ({unreadCount: state.unreadCount + amount})), 26 | resetMessages: () => set({messages: []}) 27 | }), {name: 'messageStoreDemo'})) 28 | 29 | export default useMessageStore; -------------------------------------------------------------------------------- /src/auth.config.ts: -------------------------------------------------------------------------------- 1 | import Credentials from "next-auth/providers/credentials" 2 | import Google from "next-auth/providers/google" 3 | import Github from "next-auth/providers/github" 4 | 5 | import type { NextAuthConfig } from "next-auth" 6 | import { loginSchema } from './lib/schemas/loginSchema' 7 | import { getUserByEmail } from './app/actions/authActions'; 8 | import { compare } from 'bcryptjs'; 9 | 10 | export default { 11 | providers: [ 12 | Google({ 13 | clientId: process.env.GOOGLE_CLIENT_ID, 14 | clientSecret: process.env.GOOGLE_CLIENT_SECRET 15 | }), 16 | Github({ 17 | clientId: process.env.GITHUB_CLIENT_ID, 18 | clientSecret: process.env.GITHUB_CLIENT_SECRET 19 | }), 20 | Credentials({ 21 | name: 'credentials', 22 | async authorize(creds) { 23 | const validated = loginSchema.safeParse(creds); 24 | 25 | if (validated.success) { 26 | const { email, password } = validated.data; 27 | 28 | const user = await getUserByEmail(email); 29 | 30 | if (!user || !user.passwordHash || !(await compare(password, user.passwordHash))) return null; 31 | 32 | return user; 33 | } 34 | 35 | return null; 36 | } 37 | }) 38 | ], 39 | } satisfies NextAuthConfig -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button, Card, CardBody, CardFooter, CardHeader } from '@nextui-org/react' 4 | import { BiSolidError } from "react-icons/bi"; 5 | 6 | export default function Error({ 7 | error, 8 | reset, 9 | }: { 10 | error: Error & { digest?: string } 11 | reset: () => void 12 | }) { 13 | 14 | return ( 15 |
16 | 17 | 18 |
19 | 20 |

Error

21 |
22 |
23 | 24 |
25 | {error.message} 26 |
27 |
28 | 29 | 32 | 33 |
34 |
35 | ) 36 | } -------------------------------------------------------------------------------- /src/hooks/usePaginationStore.ts: -------------------------------------------------------------------------------- 1 | import { PagingResult } from '@/types' 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | type PaginationState = { 6 | pagination: PagingResult; 7 | setPagination: (count: number) => void; 8 | setPage: (page: number) => void; 9 | setPageSize: (pageSize: number) => void; 10 | } 11 | 12 | const usePaginationStore = create()(devtools((set) => ({ 13 | pagination: { 14 | pageNumber: 1, 15 | pageSize: 12, 16 | totalCount: 0, 17 | totalPages: 1 18 | }, 19 | setPagination: (totalCount: number) => set(state => ({ 20 | pagination: { 21 | pageNumber: 1, 22 | pageSize: state.pagination.pageSize, 23 | totalCount, 24 | totalPages: Math.ceil(totalCount / state.pagination.pageSize) 25 | } 26 | })), 27 | setPage: (page: number) => set(state => ({pagination: {...state.pagination, pageNumber: page}})), 28 | setPageSize: (pageSize: number) => set(state => ({pagination: { 29 | ...state.pagination, 30 | pageSize: pageSize, 31 | pageNumber: 1, 32 | totalPages: Math.ceil(state.pagination.totalCount / pageSize) 33 | }})) 34 | }), {name: 'paginationStoreDemo'})) 35 | 36 | export default usePaginationStore -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth'; 2 | import { Button } from '@nextui-org/react'; 3 | import Link from 'next/link'; 4 | import { GiMatchTip } from 'react-icons/gi'; 5 | 6 | export default async function Home() { 7 | const session = await auth(); 8 | 9 | return ( 10 |
11 | 12 |

Welcome to NextMatch

13 | {session ? ( 14 | 23 | ) : ( 24 |
25 | 34 | 43 |
44 | )} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/tokens.ts: -------------------------------------------------------------------------------- 1 | import { TokenType } from '@prisma/client'; 2 | import { prisma } from './prisma'; 3 | 4 | export async function getTokenByEmail(email: string) { 5 | try { 6 | return prisma.token.findFirst({ 7 | where: {email} 8 | }) 9 | } catch (error) { 10 | console.log(error); 11 | throw error; 12 | } 13 | } 14 | 15 | export async function getTokenByToken(token: string) { 16 | try { 17 | return prisma.token.findFirst({ 18 | where: {token} 19 | }) 20 | } catch (error) { 21 | console.log(error); 22 | throw error; 23 | } 24 | } 25 | 26 | 27 | export async function generateToken(email: string, type: TokenType) { 28 | const arrayBuffer = new Uint8Array(48); 29 | crypto.getRandomValues(arrayBuffer); 30 | const token = Array.from(arrayBuffer, byte => byte.toString(16).padStart(2, '0')).join(''); 31 | const expires = new Date(Date.now() + 1000 * 60 * 60 * 24); 32 | 33 | const existingToken = await getTokenByEmail(email); 34 | 35 | if (existingToken) { 36 | await prisma.token.delete({ 37 | where: {id: existingToken.id} 38 | }) 39 | } 40 | 41 | return prisma.token.create({ 42 | data: { 43 | email, 44 | token, 45 | expires, 46 | type 47 | } 48 | }) 49 | } -------------------------------------------------------------------------------- /src/app/(auth)/register/UserDetailsForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Input } from '@nextui-org/react'; 4 | import { useFormContext } from 'react-hook-form'; 5 | 6 | export default function UserDetailsForm() { 7 | const {register, getValues, formState: {errors}} = useFormContext(); 8 | return ( 9 |
10 | 18 | 26 | 35 |
36 | ) 37 | } -------------------------------------------------------------------------------- /src/app/members/edit/photos/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthUserId } from '@/app/actions/authActions' 2 | import { getMemberByUserId, getMemberPhotosByUserId } from '@/app/actions/memberActions'; 3 | import DeleteButton from '@/components/DeleteButton'; 4 | import ImageUploadButton from '@/components/ImageUploadButton'; 5 | import StarButton from '@/components/StarButton'; 6 | import { CardHeader, Divider, CardBody, Image } from '@nextui-org/react' 7 | import React from 'react' 8 | import MemberPhotoUpload from './MemberPhotoUpload'; 9 | import MemberImage from '@/components/MemberImage'; 10 | import MemberPhotos from '@/components/MemberPhotos'; 11 | 12 | export default async function PhotosPage() { 13 | const userId = await getAuthUserId(); 14 | const member = await getMemberByUserId(userId); 15 | const photos = await getMemberPhotosByUserId(userId); 16 | 17 | return ( 18 | <> 19 | 20 |
21 | Edit Profile 22 |
23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { auth } from './auth'; 3 | import { authRoutes, publicRoutes } from './routes'; 4 | import { url } from 'inspector'; 5 | 6 | export default auth((req) => { 7 | const {nextUrl} = req; 8 | const isLoggedIn = !!req.auth; 9 | 10 | const isPublic = publicRoutes.includes(nextUrl.pathname); 11 | const isAuthRoute = authRoutes.includes(nextUrl.pathname); 12 | const isProfileComplete = req.auth?.user.profileComplete; 13 | const isAdmin = req.auth?.user.role === 'ADMIN'; 14 | const isAdminRoute = nextUrl.pathname.startsWith('/admin'); 15 | 16 | if (isPublic || isAdmin) { 17 | return NextResponse.next(); 18 | } 19 | 20 | if (isAdminRoute && !isAdmin) { 21 | return NextResponse.redirect(new URL('/', nextUrl)); 22 | } 23 | 24 | if (isAuthRoute) { 25 | if (isLoggedIn) { 26 | return NextResponse.redirect(new URL('/members', nextUrl)) 27 | } 28 | return NextResponse.next(); 29 | } 30 | 31 | if (!isPublic && !isLoggedIn) { 32 | return NextResponse.redirect(new URL('/login', nextUrl)) 33 | } 34 | 35 | if (isLoggedIn && !isProfileComplete && nextUrl.pathname !== '/complete-profile') { 36 | return NextResponse.redirect(new URL('/complete-profile', nextUrl)); 37 | } 38 | 39 | return NextResponse.next(); 40 | }) 41 | 42 | export const config = { 43 | matcher: ['/((?!api|_next/static|_next/image|images|favicon.ico).*)'] 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client'; 2 | import { ZodIssue } from 'zod'; 3 | 4 | type ActionResult = 5 | {status: 'success', data: T} | {status: 'error', error: string | ZodIssue[]} 6 | 7 | type MessageWithSenderRecipient = Prisma.MessageGetPayload<{ 8 | select: { 9 | id: true, 10 | text: true, 11 | created: true, 12 | dateRead: true, 13 | sender: { 14 | select: {userId, name, image} 15 | }, 16 | recipient: { 17 | select: {userId, name, image} 18 | } 19 | } 20 | }> 21 | 22 | type MessageDto = { 23 | id: string; 24 | text: string; 25 | created: string; 26 | dateRead: string | null; 27 | senderId?: string; 28 | senderName?: string; 29 | senderImage?: string | null; 30 | recipientId?: string; 31 | recipientName?: string; 32 | recipientImage?: string | null 33 | } 34 | 35 | type UserFilters = { 36 | ageRange: number[]; 37 | orderBy: string; 38 | gender: string[]; 39 | withPhoto: boolean; 40 | } 41 | 42 | type PagingParams = { 43 | pageNumber: number; 44 | pageSize: number; 45 | } 46 | 47 | type PagingResult = { 48 | totalPages: number; 49 | totalCount: number; 50 | } & PagingParams 51 | 52 | type PaginatedResponse = { 53 | items: T[]; 54 | totalCount: number; 55 | } 56 | 57 | type GetMemberParams = { 58 | ageRange?: string; 59 | gender?: string; 60 | pageNumber?: string; 61 | pageSize?: string; 62 | orderBy?: string; 63 | withPhoto?: string; 64 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-match", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "vercel-build": "prisma generate && prisma migrate deploy && prisma db seed && next build" 11 | }, 12 | "dependencies": { 13 | "@auth/prisma-adapter": "^1.5.0", 14 | "@hookform/resolvers": "^3.3.4", 15 | "@nextui-org/react": "2.3.6", 16 | "@prisma/client": "^5.11.0", 17 | "bcryptjs": "^2.4.3", 18 | "cloudinary": "^2.0.3", 19 | "clsx": "^2.1.0", 20 | "date-fns": "^3.6.0", 21 | "framer-motion": "^11.0.20", 22 | "next": "14.2.1", 23 | "next-auth": "^5.0.0-beta.15", 24 | "next-cloudinary": "^6.3.0", 25 | "pusher": "^5.2.0", 26 | "pusher-js": "^8.4.0-rc2", 27 | "react": "^18", 28 | "react-dom": "^18", 29 | "react-hook-form": "^7.51.1", 30 | "react-icons": "^5.0.1", 31 | "react-toastify": "^10.0.5", 32 | "resend": "^3.2.0", 33 | "zod": "^3.22.4", 34 | "zustand": "^4.5.2" 35 | }, 36 | "devDependencies": { 37 | "@types/bcryptjs": "^2.4.6", 38 | "@types/node": "^20", 39 | "@types/react": "^18", 40 | "@types/react-dom": "^18", 41 | "autoprefixer": "^10.0.1", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.1.4", 44 | "postcss": "^8", 45 | "prisma": "^5.11.0", 46 | "tailwindcss": "^3.3.0", 47 | "ts-node": "^10.9.2", 48 | "typescript": "^5" 49 | }, 50 | "prisma": { 51 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/navbar/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { signOutUser } from '@/app/actions/authActions'; 4 | import { transformImageUrl } from '@/lib/util'; 5 | import { Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger } from '@nextui-org/react' 6 | import Link from 'next/link' 7 | import React from 'react' 8 | 9 | type Props = { 10 | userInfo: {name: string | null; image: string | null;} | null 11 | } 12 | 13 | export default function UserMenu({userInfo}: Props) { 14 | return ( 15 | 16 | 17 | 26 | 27 | 28 | 29 | 30 | Signed in as {userInfo?.name} 31 | 32 | 33 | 34 | Edit profile 35 | 36 | signOutUser()} > 37 | Log out 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { getUnreadMessageCount } from '@/app/actions/messageActions'; 4 | import useMessageStore from '@/hooks/useMessageStore'; 5 | import { useNotificationChannel } from '@/hooks/useNotificationChannel'; 6 | import { usePresenceChannel } from '@/hooks/usePresenceChannel'; 7 | import { NextUIProvider } from '@nextui-org/react' 8 | import { SessionProvider } from 'next-auth/react'; 9 | import React, { ReactNode, useCallback, useEffect, useRef } from 'react' 10 | import { ToastContainer } from 'react-toastify'; 11 | import 'react-toastify/dist/ReactToastify.css'; 12 | 13 | export default function Providers({ children, userId, profileComplete }: 14 | { children: ReactNode, userId: string | null, profileComplete: boolean }) { 15 | const isUnreadCountSet = useRef(false); 16 | const { updateUnreadCount } = useMessageStore(state => ({ 17 | updateUnreadCount: state.updateUnreadCount 18 | })); 19 | 20 | const setUnreadCount = useCallback((amount: number) => { 21 | updateUnreadCount(amount); 22 | }, [updateUnreadCount]) 23 | 24 | useEffect(() => { 25 | if (!isUnreadCountSet.current && userId) { 26 | getUnreadMessageCount().then(count => { 27 | setUnreadCount(count) 28 | }); 29 | isUnreadCountSet.current = true; 30 | } 31 | }, [setUnreadCount, userId]) 32 | 33 | usePresenceChannel(userId, profileComplete); 34 | useNotificationChannel(userId, profileComplete); 35 | return ( 36 | 37 | 38 | 39 | {children} 40 | 41 | 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /src/components/NotificationToast.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react' 3 | import {Image} from '@nextui-org/react'; 4 | import { transformImageUrl } from '@/lib/util'; 5 | import { MessageDto } from '@/types'; 6 | import { toast } from 'react-toastify'; 7 | 8 | type Props = { 9 | image?: string | null; 10 | href: string; 11 | title: string; 12 | subtitle?: string; 13 | } 14 | 15 | export default function NotificationToast({image, href, title, subtitle}: Props) { 16 | return ( 17 | 18 |
19 | Sender image 25 |
26 |
27 |
{title}
28 |
{subtitle || 'Click to view'}
29 |
30 | 31 | ) 32 | } 33 | 34 | export const newMessageToast = (message: MessageDto) => { 35 | toast( 36 | 41 | ) 42 | } 43 | 44 | export const newLikeToast = (name: string, image: string | null, userId: string) => { 45 | toast( 46 | 52 | ) 53 | } -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import {differenceInYears, format, formatDistance} from 'date-fns'; 2 | import { FieldValues, Path, UseFormSetError } from 'react-hook-form'; 3 | import { ZodIssue } from 'zod'; 4 | 5 | export function calculateAge(dob: Date) { 6 | return differenceInYears(new Date(), dob); 7 | } 8 | 9 | export function formatShortDateTime(date: Date) { 10 | return format(date, 'dd MMM yy h:mm:a') 11 | } 12 | 13 | export function timeAgo(date: string) { 14 | return formatDistance(new Date(date), new Date()) + ' ago'; 15 | } 16 | 17 | export function handleFormServerErrors( 18 | errorResponse: {error: string | ZodIssue[]}, 19 | setError: UseFormSetError 20 | ) { 21 | if (Array.isArray(errorResponse.error)) { 22 | errorResponse.error.forEach((e) => { 23 | const fieldName = e.path.join('.') as Path 24 | setError(fieldName, {message: e.message}) 25 | }) 26 | } else { 27 | setError('root.serverError', {message: errorResponse.error}); 28 | } 29 | } 30 | 31 | export function transformImageUrl(imageUrl?: string | null) { 32 | if (!imageUrl) return null; 33 | 34 | if (!imageUrl.includes('cloudinary')) return imageUrl; 35 | 36 | const uploadIndex = imageUrl.indexOf('/upload/') + '/upload/'.length; 37 | 38 | const transformation = 'c_fill,w_300,h_300,g_faces/'; 39 | 40 | return `${imageUrl.slice(0, uploadIndex)}${transformation}${imageUrl.slice(uploadIndex)}` 41 | } 42 | 43 | export function truncateString(text?: string | null, num = 50) { 44 | if (!text) return null; 45 | if (text.length <= num) { 46 | return text; 47 | } 48 | return text.slice(0, num) + '...'; 49 | } 50 | 51 | export function createChatId(a: string, b: string) { 52 | return a > b ? `${b}-${a}` : `${a}-${b}` 53 | } -------------------------------------------------------------------------------- /src/components/AppModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@nextui-org/react'; 2 | import { ReactNode } from 'react'; 3 | 4 | type Props = { 5 | isOpen: boolean; 6 | onClose: () => void; 7 | header?: string; 8 | body: ReactNode; 9 | footerButtons?: ButtonProps[]; 10 | imageModal?: boolean; 11 | } 12 | 13 | export default function AppModal({ isOpen, onClose, header, body, footerButtons, imageModal }: Props) { 14 | 15 | const handleClose = () => { 16 | setTimeout(() => onClose(), 10); 17 | } 18 | 19 | return ( 20 | 35 | 36 | {!imageModal && 37 | {header}} 38 | {body} 39 | {!imageModal && 40 | 41 | {footerButtons && footerButtons.map((props: ButtonProps, index) => ( 42 | 45 | ))} 46 | } 47 | 48 | 49 | ) 50 | } -------------------------------------------------------------------------------- /src/components/CardWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardHeader, CardBody, CardFooter, Button } from '@nextui-org/react'; 2 | import { ReactNode } from 'react'; 3 | import { GiPadlock } from 'react-icons/gi'; 4 | import { IconType } from 'react-icons/lib'; 5 | 6 | type Props = { 7 | body?: ReactNode; 8 | headerIcon: IconType; 9 | headerText: string; 10 | subHeaderText?: string; 11 | action?: () => void; 12 | actionLabel?: string; 13 | footer?: ReactNode; 14 | } 15 | 16 | export default function CardWrapper({ body, footer, headerIcon: Icon, headerText, subHeaderText, action, actionLabel }: Props) { 17 | return ( 18 |
19 | 20 | 21 |
22 |
23 | 24 |

{headerText}

25 |
26 | {subHeaderText && 27 |

{subHeaderText}

} 28 |
29 |
30 | {body && 31 | 32 | {body} 33 | } 34 | 35 | {action && ( 36 | 39 | )} 40 | {footer && ( 41 | <>{footer} 42 | )} 43 | 44 |
45 | 46 |
47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /src/app/(auth)/forgot-password/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { generateResetPasswordEmail } from '@/app/actions/authActions'; 4 | import CardWrapper from '@/components/CardWrapper'; 5 | import ResultMessage from '@/components/ResultMessage'; 6 | import { ActionResult } from '@/types'; 7 | import { Button, Input } from '@nextui-org/react'; 8 | import { useState } from 'react'; 9 | import { FieldValues, useForm } from 'react-hook-form' 10 | import { GiPadlock } from 'react-icons/gi'; 11 | 12 | export default function ForgotPasswordForm() { 13 | const [result, setResult] = useState | null>(null); 14 | const { register, handleSubmit, reset, formState: { errors, isSubmitting, isValid } } = useForm(); 15 | 16 | const onSubmit = async (data: FieldValues) => { 17 | setResult(await generateResetPasswordEmail(data.email)); 18 | reset(); 19 | } 20 | 21 | return ( 22 | 28 | 35 | 42 | 43 | } 44 | footer={ 45 | 46 | } 47 | /> 48 | ) 49 | } -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { membersData } from './membersData'; 3 | import { hash } from 'bcryptjs'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | async function seedMembers() { 8 | return membersData.map(async member => prisma.user.create({ 9 | data: { 10 | email: member.email, 11 | emailVerified: new Date(), 12 | name: member.name, 13 | passwordHash: await hash('password', 10), 14 | image: member.image, 15 | profileComplete: true, 16 | member: { 17 | create: { 18 | dateOfBirth: new Date(member.dateOfBirth), 19 | gender: member.gender, 20 | name: member.name, 21 | created: new Date(member.created), 22 | updated: new Date(member.lastActive), 23 | description: member.description, 24 | city: member.city, 25 | country: member.country, 26 | image: member.image, 27 | photos: { 28 | create: { 29 | url: member.image, 30 | isApproved: true 31 | } 32 | } 33 | } 34 | } 35 | } 36 | })) 37 | } 38 | 39 | async function seedAdmin() { 40 | return prisma.user.create({ 41 | data: { 42 | email: 'admin@test.com', 43 | emailVerified: new Date(), 44 | name: 'Admin', 45 | passwordHash: await hash('password', 10), 46 | role: 'ADMIN' 47 | } 48 | }) 49 | } 50 | 51 | async function main() { 52 | if (process.env.RUN_SEED === 'true' || process.env.NODE_ENV === 'development') { 53 | await seedMembers(); 54 | await seedAdmin(); 55 | } 56 | } 57 | 58 | main().catch(e => { 59 | console.error(e); 60 | process.exit(1); 61 | }).finally(async () => { 62 | await prisma.$disconnect(); 63 | }) -------------------------------------------------------------------------------- /src/app/messages/MessageSidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useMessageStore from '@/hooks/useMessageStore'; 4 | import { Chip } from '@nextui-org/react'; 5 | import clsx from 'clsx'; 6 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 7 | import React, { useState } from 'react' 8 | import {GoInbox} from 'react-icons/go'; 9 | import {MdOutlineOutbox} from 'react-icons/md'; 10 | 11 | export default function MessageSidebar() { 12 | const {unreadCount} = useMessageStore(state => ({ 13 | unreadCount: state.unreadCount 14 | })) 15 | const searchParams = useSearchParams(); 16 | const pathname = usePathname(); 17 | const router = useRouter(); 18 | const [selected, setSelected] = useState(searchParams.get('container') || 'inbox'); 19 | 20 | const items = [ 21 | {key: 'inbox', label: 'Inbox', icon: GoInbox, chip: true}, 22 | {key: 'outbox', label: 'Outbox', icon: MdOutlineOutbox, chip: false}, 23 | ] 24 | 25 | const handleSelect = (key: string) => { 26 | setSelected(key); 27 | const params = new URLSearchParams(); 28 | params.set('container', key); 29 | router.replace(`${pathname}?${params}`) 30 | } 31 | 32 | return ( 33 |
34 | {items.map(({key, icon: Icon, label, chip}) => ( 35 |
handleSelect(key)} 41 | > 42 | 43 |
44 | {label} 45 | {chip && {unreadCount}} 46 |
47 |
48 | ))} 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/PaginationComponent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import usePaginationStore from '@/hooks/usePaginationStore'; 4 | import { Pagination } from '@nextui-org/react' 5 | import clsx from 'clsx'; 6 | import React, { useEffect } from 'react' 7 | 8 | export default function PaginationComponent({totalCount}: {totalCount: number}) { 9 | 10 | const {setPage, setPageSize, setPagination, pagination} = usePaginationStore(state => ({ 11 | setPage: state.setPage, 12 | setPageSize: state.setPageSize, 13 | setPagination: state.setPagination, 14 | pagination: state.pagination 15 | })); 16 | 17 | const {pageNumber, pageSize, totalPages} = pagination; 18 | 19 | useEffect(() => { 20 | setPagination(totalCount) 21 | }, [setPagination, totalCount]) 22 | 23 | const start = (pageNumber - 1) * pageSize + 1; 24 | const end = Math.min(pageNumber * pageSize, totalCount); 25 | const resultText = `Showing ${start}-${end} of ${totalCount} results` 26 | 27 | return ( 28 |
29 |
30 |
{resultText}
31 | 38 |
39 | Page size: 40 | {[3, 6, 12].map(size => ( 41 |
setPageSize(size)} 44 | className={clsx('page-size-box', { 45 | 'bg-secondary text-white hover:bg-secondary hover:text-white' 46 | : pageSize === size 47 | })}> 48 | {size} 49 |
50 | ))} 51 |
52 |
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/hooks/useNotificationChannel.ts: -------------------------------------------------------------------------------- 1 | import { pusherClient } from '@/lib/pusher'; 2 | import { MessageDto } from '@/types'; 3 | import { usePathname, useSearchParams } from 'next/navigation'; 4 | import { Channel } from 'pusher-js' 5 | import { useCallback, useEffect, useRef } from 'react' 6 | import useMessageStore from './useMessageStore'; 7 | import { newLikeToast, newMessageToast } from '@/components/NotificationToast'; 8 | 9 | export const useNotificationChannel = (userId: string | null, profileComplete: boolean) => { 10 | const channelRef = useRef(null); 11 | const pathname = usePathname(); 12 | const searchParams = useSearchParams(); 13 | const {add, updateUnreadCount} = useMessageStore(state => ({ 14 | add: state.add, 15 | updateUnreadCount: state.updateUnreadCount 16 | })) 17 | 18 | const handleNewMessage = useCallback((message: MessageDto) => { 19 | if (pathname === '/messages' && searchParams.get('container') !== 'outbox') { 20 | add(message); 21 | updateUnreadCount(1); 22 | } else if (pathname !== `/members/${message.senderId}/chat`) { 23 | newMessageToast(message); 24 | updateUnreadCount(1); 25 | } 26 | }, [add, pathname, searchParams, updateUnreadCount]); 27 | 28 | const handleNewLike = useCallback((data: {name:string, image: string | null, userId: string}) => { 29 | newLikeToast(data.name, data.image, data.userId); 30 | }, []) 31 | 32 | useEffect(() => { 33 | if (!userId || !profileComplete) return; 34 | if (!channelRef.current) { 35 | channelRef.current = pusherClient.subscribe(`private-${userId}`); 36 | 37 | channelRef.current.bind('message:new', handleNewMessage); 38 | channelRef.current.bind('like:new', handleNewLike); 39 | } 40 | 41 | return () => { 42 | if (channelRef.current && channelRef.current.subscribed) { 43 | channelRef.current.unsubscribe(); 44 | channelRef.current.unbind('message:new', handleNewMessage); 45 | channelRef.current.unbind('like:new', handleNewLike); 46 | channelRef.current = null; 47 | } 48 | } 49 | }, [userId, handleNewMessage, profileComplete, handleNewLike]) 50 | } -------------------------------------------------------------------------------- /src/hooks/usePresenceChannel.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | import usePresenceStore from './usePresenceStore' 3 | import { Channel, Members } from 'pusher-js'; 4 | import { pusherClient } from '@/lib/pusher'; 5 | import { updateLastActive } from '@/app/actions/memberActions'; 6 | 7 | export const usePresenceChannel = (userId: string | null, profileComplete: boolean) => { 8 | const {set, add, remove} = usePresenceStore(state => ({ 9 | set: state.set, 10 | add: state.add, 11 | remove: state.remove 12 | })); 13 | const channelRef = useRef(null); 14 | 15 | const handleSetMembers = useCallback((memberIds: string[]) => { 16 | set(memberIds); 17 | }, [set]); 18 | 19 | const handleAddMember = useCallback((memberId: string) => { 20 | add(memberId); 21 | }, [add]); 22 | 23 | const handleRemoveMember = useCallback((memberId: string) => { 24 | remove(memberId); 25 | }, [remove]) 26 | 27 | useEffect(() => { 28 | if (!userId || !profileComplete) return; 29 | if (!channelRef.current) { 30 | channelRef.current = pusherClient.subscribe('presence-nm'); 31 | 32 | channelRef.current.bind('pusher:subscription_succeeded', async (members: Members) => { 33 | handleSetMembers(Object.keys(members.members)); 34 | await updateLastActive(); 35 | }) 36 | 37 | channelRef.current.bind('pusher:member_added', (member: Record) => { 38 | handleAddMember(member.id); 39 | }) 40 | 41 | channelRef.current.bind('pusher:member_removed', (member: Record) => { 42 | handleRemoveMember(member.id); 43 | }); 44 | } 45 | 46 | return () => { 47 | if (channelRef.current && channelRef.current.subscribed) { 48 | channelRef.current.unsubscribe(); 49 | channelRef.current.unbind('pusher:subscription_succeeded', handleSetMembers); 50 | channelRef.current.unbind('pusher:member_added', handleAddMember); 51 | channelRef.current.unbind('pusher:member_removed', handleRemoveMember); 52 | } 53 | } 54 | }, [handleAddMember, handleRemoveMember, handleSetMembers, userId, profileComplete]) 55 | } -------------------------------------------------------------------------------- /src/app/(auth)/complete-profile/CompleteProfileForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import CardWrapper from '@/components/CardWrapper'; 4 | import { ProfileSchema, profileSchema } from '@/lib/schemas/registerSchema'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { FormProvider, useForm } from 'react-hook-form'; 7 | import {RiProfileLine} from 'react-icons/ri'; 8 | import ProfileForm from '../register/ProfileForm'; 9 | import { Button } from '@nextui-org/react'; 10 | import { completeSocialLoginProfile } from '@/app/actions/authActions'; 11 | import { signIn } from 'next-auth/react'; 12 | 13 | export default function CompleteProfileForm() { 14 | const methods = useForm({ 15 | resolver: zodResolver(profileSchema), 16 | mode: 'onTouched' 17 | }) 18 | 19 | const {handleSubmit, formState: {errors, isSubmitting, isValid}} = methods; 20 | 21 | const onSubmit = async (data: ProfileSchema) => { 22 | const result = await completeSocialLoginProfile(data); 23 | 24 | if (result.status === 'success') { 25 | signIn(result.data, { 26 | callbackUrl: '/members' 27 | }) 28 | } 29 | } 30 | 31 | return ( 32 | 38 |
39 |
40 | 41 | {errors.root?.serverError && ( 42 |

{errors.root.serverError.message}

43 | )} 44 |
45 | 52 |
53 |
54 |
55 | 56 | } 57 | /> 58 | ) 59 | } -------------------------------------------------------------------------------- /src/app/members/[userId]/chat/ChatForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createMessage } from '@/app/actions/messageActions'; 4 | import { MessageSchema, messageSchema } from '@/lib/schemas/messageSchema' 5 | import { handleFormServerErrors } from '@/lib/util'; 6 | import { zodResolver } from '@hookform/resolvers/zod' 7 | import { Button, Input } from '@nextui-org/react' 8 | import { useParams, useRouter } from 'next/navigation'; 9 | import React from 'react' 10 | import { useEffect } from 'react'; 11 | import { useForm } from 'react-hook-form' 12 | import { HiPaperAirplane } from 'react-icons/hi2' 13 | 14 | export default function ChatForm() { 15 | const router = useRouter(); 16 | const params = useParams<{ userId: string }>(); 17 | const { register, handleSubmit, reset, setError, setFocus, 18 | formState: { isSubmitting, isValid, errors } } = useForm({ 19 | resolver: zodResolver(messageSchema) 20 | }) 21 | 22 | useEffect(() => { 23 | setFocus('text'); 24 | }, [setFocus]) 25 | 26 | 27 | const onSubmit = async (data: MessageSchema) => { 28 | const result = await createMessage(params.userId, data); 29 | if (result.status === 'error') { 30 | handleFormServerErrors(result, setError) 31 | } else { 32 | reset(); 33 | setTimeout(() => setFocus('text'), 50); 34 | } 35 | } 36 | 37 | return ( 38 |
39 |
40 | 48 | 58 |
59 |
60 | {errors.root?.serverError && ( 61 |

{errors.root.serverError.message}

62 | )} 63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/app/actions/adminActions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { prisma } from '@/lib/prisma'; 4 | import { getUserRole } from './authActions'; 5 | import { Photo } from '@prisma/client'; 6 | import { cloudinary } from '@/lib/cloudinary'; 7 | 8 | export async function getUnapprovedPhotos() { 9 | try { 10 | const role = await getUserRole(); 11 | 12 | if (role !== 'ADMIN') throw new Error('Forbidden'); 13 | 14 | return prisma.photo.findMany({ 15 | where: { 16 | isApproved: false 17 | } 18 | }) 19 | 20 | } catch (error) { 21 | console.log(error); 22 | throw error; 23 | } 24 | } 25 | 26 | export async function approvePhoto(photoId: string) { 27 | try { 28 | const role = await getUserRole(); 29 | 30 | if (role !== 'ADMIN') throw new Error('Forbidden'); 31 | 32 | const photo = await prisma.photo.findUnique({ 33 | where: {id: photoId}, 34 | include: {member: {include: {user: true}}} 35 | }); 36 | 37 | if (!photo || !photo.member || !photo.member.user) throw new Error('Cannot approve this image'); 38 | 39 | const {member} = photo; 40 | 41 | const userUpdate = member.user && member.user.image === null ? {image: photo.url} : {}; 42 | const memberUpdate = member.image === null ? {image: photo.url} : {}; 43 | 44 | if (Object.keys(userUpdate).length > 0) { 45 | await prisma.user.update({ 46 | where: {id: member.userId}, 47 | data: userUpdate 48 | }) 49 | } 50 | 51 | return prisma.member.update({ 52 | where: {id: member.id}, 53 | data: { 54 | ...memberUpdate, 55 | photos: { 56 | update: { 57 | where: {id: photo.id}, 58 | data: {isApproved: true} 59 | } 60 | } 61 | } 62 | }) 63 | } catch (error) { 64 | console.log(error); 65 | throw error; 66 | } 67 | } 68 | 69 | export async function rejectPhoto(photo: Photo) { 70 | try { 71 | const role = await getUserRole(); 72 | 73 | if (role !== 'ADMIN') throw new Error('Forbidden'); 74 | 75 | if (photo.publicId) { 76 | await cloudinary.v2.uploader.destroy(photo.publicId); 77 | } 78 | 79 | return prisma.photo.delete({ 80 | where: {id: photo.id} 81 | }) 82 | 83 | } catch (error) { 84 | console.log(error); 85 | throw error; 86 | } 87 | } -------------------------------------------------------------------------------- /src/app/members/MemberCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import LikeButton from '@/components/LikeButton' 4 | import PresenceDot from '@/components/PresenceDot'; 5 | import { calculateAge, transformImageUrl } from '@/lib/util' 6 | import { Card, CardFooter, Image } from '@nextui-org/react' 7 | import { Member } from '@prisma/client' 8 | import Link from 'next/link' 9 | import React, { useState } from 'react' 10 | import { toggleLikeMember } from '../actions/likeActions'; 11 | 12 | type Props = { 13 | member: Member 14 | likeIds: string[] 15 | } 16 | 17 | export default function MemberCard({ member, likeIds }: Props) { 18 | const [hasLiked, setHasLiked] = useState(likeIds.includes(member.userId)); 19 | const [loading, setLoading] = useState(false); 20 | 21 | async function toggleLike() { 22 | setLoading(true); 23 | try { 24 | await toggleLikeMember(member.userId, hasLiked); 25 | setHasLiked(!hasLiked); 26 | } catch (error) { 27 | console.log(error); 28 | } finally { 29 | setLoading(false); 30 | } 31 | 32 | } 33 | 34 | 35 | const preventLinkAction = (e: React.MouseEvent) => { 36 | e.preventDefault(); 37 | e.stopPropagation(); 38 | } 39 | 40 | return ( 41 | 47 | {member.name} 54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 |
62 | 63 | 65 |
66 | {member.name}, {calculateAge(member.dateOfBirth)} 67 | {member.city} 68 |
69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/app/lists/ListsTab.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Spinner, Tab, Tabs } from '@nextui-org/react'; 4 | import { Member } from '@prisma/client'; 5 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 6 | import React, { useTransition } from 'react' 7 | import { Key } from 'react'; 8 | import MemberCard from '../members/MemberCard'; 9 | import LoadingComponent from '@/components/LoadingComponent'; 10 | 11 | type Props = { 12 | members: Member[]; 13 | likeIds: string[]; 14 | } 15 | 16 | export default function ListsTab({ members, likeIds }: Props) { 17 | const searchParams = useSearchParams(); 18 | const router = useRouter(); 19 | const pathname = usePathname(); 20 | const [isPending, startTransition] = useTransition(); 21 | 22 | const tabs = [ 23 | { id: 'source', label: 'Members I have liked' }, 24 | { id: 'target', label: 'Members that like me' }, 25 | { id: 'mutual', label: 'Mutual likes' }, 26 | ] 27 | 28 | function handleTabChange(key: Key) { 29 | startTransition(() => { 30 | const params = new URLSearchParams(searchParams); 31 | params.set('type', key.toString()); 32 | router.replace(`${pathname}?${params.toString()}`); 33 | }) 34 | } 35 | 36 | return ( 37 |
38 |
39 | handleTabChange(key)} 43 | > 44 | {tabs.map((item) => ( 45 | 46 | ))} 47 | 48 | {isPending && } 49 |
50 | 51 | {tabs.map((item) => { 52 | const isSelected = searchParams.get('type') === item.id; 53 | return isSelected ? ( 54 |
55 | {members.length > 0 ? ( 56 |
57 | {members.map(member => ( 58 | 59 | ))} 60 |
61 | ) : ( 62 |
No members for this filter
63 | )} 64 |
65 | ) : null; 66 | })} 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/app/(auth)/register/ProfileForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Input, Select, SelectItem, Textarea } from '@nextui-org/react'; 4 | import { format, subYears } from 'date-fns'; 5 | import { useFormContext } from 'react-hook-form'; 6 | 7 | export default function ProfileForm() { 8 | const { register, getValues, setValue, formState: { errors } } = useFormContext(); 9 | 10 | const genderList = [ 11 | {label: 'Male', value: 'male'}, 12 | {label: 'Female', value: 'female'}, 13 | ] 14 | 15 | return ( 16 |
17 | 33 | 43 |