📋 Índice
- 🤖 Introdução
- ⚙️ Tech Stack
- 🔋 Features
- 🤸 Quick Start
- 🕸️ Snippets
Threads clone é um app que tem funcionalidades do Threads como postar uma Thread comentar em threads de outros usuários, criar e participar de comunidades, pesquisar usuários e ver perfils, editar um perfil, tudo isso com uma interface de usuário bonita simples e rápida.
- Next.js
- MongoDB
- Shadcn UI
- TailwindCSS
- Clerk
- Webhooks
- Serverless APIs
- React Hook Form
- Zod
- TypeScript
👉 Autenticação: Autenticação usando Clerk para e-mail, senha e logins sociais (Google e GitHub) com um sistema abrangente de gerenciamento de perfis.
👉 Página inicial visualmente atraente: uma página inicial visualmente atraente que apresenta os tópicos mais recentes para uma experiência de usuário envolvente.
👉 Criar página de tópico: uma página dedicada para os usuários criarem tópicos, promovendo o envolvimento da comunidade
👉 Recurso de comentários: um recurso de comentários para facilitar discussões dentro de tópicos.
👉 Comentários aninhados: Sistema de comentários com threads aninhados, fornecendo um fluxo de conversa estruturado.
👉 Pesquisa de usuário com paginação: um recurso de pesquisa de usuário com paginação para fácil exploração e descoberta de outros usuários.
👉 Página de atividades: exibe notificações na página de atividades quando alguém comenta no tópico de um usuário, aumentando o envolvimento do usuário.
👉 Página de perfil: páginas de perfil de usuário para exibir informações e permitir a modificação das configurações do perfil.
👉 Crie e convide para comunidades: permita que os usuários criem novas comunidades e convidem outras pessoas usando modelos de e-mail personalizáveis.
👉 Gerenciamento de membros da comunidade: Uma interface amigável para gerenciar membros da comunidade, permitindo mudanças e remoções de funções.
👉 Tópicos da comunidade específicos do administrador: permita que os administradores criem tópicos especificamente para sua comunidade.
👉 Pesquisa de comunidade com paginação: um recurso de pesquisa de comunidade com paginação para explorar diferentes comunidades.
👉 Perfis da comunidade: exiba perfis da comunidade apresentando tópicos e membros para uma visão geral abrangente.
👉 Desempenho extremamente rápido: desempenho ideal e troca instantânea de páginas para uma experiência de usuário perfeita.
👉 Renderização do lado do servidor: Utilizando Next.js com renderização do lado do servidor para melhorar o desempenho e benefícios de SEO.
👉 MongoDB com esquemas complexos: Lide com esquemas complexos e múltiplas populações de dados usando MongoDB.
👉 Uploads de arquivos com UploadThing: uploads de arquivos usando UploadThing para uma experiência perfeita de compartilhamento de mídia.
👉 Listening de eventos em tempo real: Listening de eventos em tempo real com webhooks para manter os usuários atualizados.
👉 Middleware, ações de API e autorização: Utilizando middleware, ações de API e autorização para obter segurança robusta de aplicativos.
👉 Grupos de rotas de layout Next.js: novos grupos de rotas de layout Next.js para roteamento eficiente
👉 Validação de dados com Zod: Integridade de dados com validação de dados usando Zod
👉 Gerenciamento de formulários com React Hook Form: Gerenciamento eficiente de formulários com React Hook Form para uma experiência simplificada de entrada do usuário.
e muito mais, incluindo arquitetura de código e capacidade de reutilização
Siga estas etapas para configurar o projeto localmente em sua máquina.
Pré-requisitos
Certifique-se de ter o seguinte instalado em sua máquina:
Clonando o Repositório
git clone https://github.com/joao-alvar/threads.git
cd threads
Instalação
Instale as dependências do projeto usando npm:
npm install
Configurar variáveis de ambiente
Crie um novo arquivo chamado .env
na raiz do seu projeto e adicione o seguinte conteúdo:
MONGODB_URL=
CLERK_SECRET_KEY=
UPLOADTHING_SECRET=
UPLOADTHING_APP_ID=
NEXT_CLERK_WEBHOOK_SECRET=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=
NEXT_PUBLIC_CLERK_SIGN_UP_URL=
NEXT_PUBLIC_CLERK_CLERK_AFTER_SIGN_IN_URL=
NEXT_PUBLIC_CLERK_CLERK_AFTER_SIGN_UP_URL=
Substitua os valores do espaço reservado pelas suas credenciais reais. Você pode obter essas credenciais inscrevendo-se nos sites correspondentes em MongoDB, Clerk, e Uploadthing.
Rodando o Projeto
npm run dev
Abra http://localhost:3000 no seu navegador para visualizar o projeto.
clerk.route.ts
/* eslint-disable camelcase */
// Resource: https://clerk.com/docs/users/sync-data-to-your-backend
// Above article shows why we need webhooks i.e., to sync data to our backend
// Resource: https://docs.svix.com/receiving/verifying-payloads/why
// It's a good practice to verify webhooks. Above article shows why we should do it
import {Webhook, WebhookRequiredHeaders} from 'svix'
import {headers} from 'next/headers'
import {IncomingHttpHeaders} from 'http'
import {NextResponse} from 'next/server'
import {
addMemberToCommunity,
createCommunity,
deleteCommunity,
removeUserFromCommunity,
updateCommunityInfo,
} from '@/lib/actions/community.actions'
// Resource: https://clerk.com/docs/integration/webhooks#supported-events
// Above document lists the supported events
type EventType =
| 'organization.created'
| 'organizationInvitation.created'
| 'organizationMembership.created'
| 'organizationMembership.deleted'
| 'organization.updated'
| 'organization.deleted'
type Event = {
data: Record<string, string | number | Record<string, string>[]>
object: 'event'
type: EventType
}
export const POST = async (request: Request) => {
const payload = await request.json()
const header = headers()
const heads = {
'svix-id': header.get('svix-id'),
'svix-timestamp': header.get('svix-timestamp'),
'svix-signature': header.get('svix-signature'),
}
// Activitate Webhook in the Clerk Dashboard.
// After adding the endpoint, you'll see the secret on the right side.
const wh = new Webhook(process.env.NEXT_CLERK_WEBHOOK_SECRET || '')
let evnt: Event | null = null
try {
evnt = wh.verify(
JSON.stringify(payload),
heads as IncomingHttpHeaders & WebhookRequiredHeaders
) as Event
} catch (err) {
return NextResponse.json({message: err}, {status: 400})
}
const eventType: EventType = evnt?.type!
// Listen organization creation event
if (eventType === 'organization.created') {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/CreateOrganization
// Show what evnt?.data sends from above resource
const {id, name, slug, logo_url, image_url, created_by} = evnt?.data ?? {}
try {
// @ts-ignore
await createCommunity(
// @ts-ignore
id,
name,
slug,
logo_url || image_url,
'org bio',
created_by
)
return NextResponse.json({message: 'User created'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
// Listen organization invitation creation event.
// Just to show. You can avoid this or tell people that we can create a new mongoose action and
// add pending invites in the database.
if (eventType === 'organizationInvitation.created') {
try {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Invitations#operation/CreateOrganizationInvitation
console.log('Invitation created', evnt?.data)
return NextResponse.json({message: 'Invitation created'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
// Listen organization membership (member invite & accepted) creation
if (eventType === 'organizationMembership.created') {
try {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/CreateOrganizationMembership
// Show what evnt?.data sends from above resource
const {organization, public_user_data} = evnt?.data
console.log('created', evnt?.data)
// @ts-ignore
await addMemberToCommunity(organization.id, public_user_data.user_id)
return NextResponse.json({message: 'Invitation accepted'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
// Listen member deletion event
if (eventType === 'organizationMembership.deleted') {
try {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/DeleteOrganizationMembership
// Show what evnt?.data sends from above resource
const {organization, public_user_data} = evnt?.data
console.log('removed', evnt?.data)
// @ts-ignore
await removeUserFromCommunity(public_user_data.user_id, organization.id)
return NextResponse.json({message: 'Member removed'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
// Listen organization updation event
if (eventType === 'organization.updated') {
try {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/UpdateOrganization
// Show what evnt?.data sends from above resource
const {id, logo_url, name, slug} = evnt?.data
console.log('updated', evnt?.data)
// @ts-ignore
await updateCommunityInfo(id, name, slug, logo_url)
return NextResponse.json({message: 'Member removed'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
// Listen organization deletion event
if (eventType === 'organization.deleted') {
try {
// Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/DeleteOrganization
// Show what evnt?.data sends from above resource
const {id} = evnt?.data
console.log('deleted', evnt?.data)
// @ts-ignore
await deleteCommunity(id)
return NextResponse.json({message: 'Organization deleted'}, {status: 201})
} catch (err) {
console.log(err)
return NextResponse.json(
{message: 'Internal Server Error'},
{status: 500}
)
}
}
}
community.actions.ts
'use server'
import {FilterQuery, SortOrder} from 'mongoose'
import Community from '../models/community.model'
import Thread from '../models/thread.model'
import User from '../models/user.model'
import {connectToDB} from '../mongoose'
export async function createCommunity(
id: string,
name: string,
username: string,
image: string,
bio: string,
createdById: string // Change the parameter name to reflect it's an id
) {
try {
connectToDB()
// Find the user with the provided unique id
const user = await User.findOne({id: createdById})
if (!user) {
throw new Error('User not found') // Handle the case if the user with the id is not found
}
const newCommunity = new Community({
id,
name,
username,
image,
bio,
createdBy: user._id, // Use the mongoose ID of the user
})
const createdCommunity = await newCommunity.save()
// Update User model
user.communities.push(createdCommunity._id)
await user.save()
return createdCommunity
} catch (error) {
// Handle any errors
console.error('Error creating community:', error)
throw error
}
}
export async function fetchCommunityDetails(id: string) {
try {
connectToDB()
const communityDetails = await Community.findOne({id}).populate([
'createdBy',
{
path: 'members',
model: User,
select: 'name username image _id id',
},
])
return communityDetails
} catch (error) {
// Handle any errors
console.error('Error fetching community details:', error)
throw error
}
}
export async function fetchCommunityPosts(id: string) {
try {
connectToDB()
const communityPosts = await Community.findById(id).populate({
path: 'threads',
model: Thread,
populate: [
{
path: 'author',
model: User,
select: 'name image id', // Select the "name" and "_id" fields from the "User" model
},
{
path: 'children',
model: Thread,
populate: {
path: 'author',
model: User,
select: 'image _id', // Select the "name" and "_id" fields from the "User" model
},
},
],
})
return communityPosts
} catch (error) {
// Handle any errors
console.error('Error fetching community posts:', error)
throw error
}
}
export async function fetchCommunities({
searchString = '',
pageNumber = 1,
pageSize = 20,
sortBy = 'desc',
}: {
searchString?: string
pageNumber?: number
pageSize?: number
sortBy?: SortOrder
}) {
try {
connectToDB()
// Calculate the number of communities to skip based on the page number and page size.
const skipAmount = (pageNumber - 1) * pageSize
// Create a case-insensitive regular expression for the provided search string.
const regex = new RegExp(searchString, 'i')
// Create an initial query object to filter communities.
const query: FilterQuery<typeof Community> = {}
// If the search string is not empty, add the $or operator to match either username or name fields.
if (searchString.trim() !== '') {
query.$or = [{username: {$regex: regex}}, {name: {$regex: regex}}]
}
// Define the sort options for the fetched communities based on createdAt field and provided sort order.
const sortOptions = {createdAt: sortBy}
// Create a query to fetch the communities based on the search and sort criteria.
const communitiesQuery = Community.find(query)
.sort(sortOptions)
.skip(skipAmount)
.limit(pageSize)
.populate('members')
// Count the total number of communities that match the search criteria (without pagination).
const totalCommunitiesCount = await Community.countDocuments(query)
const communities = await communitiesQuery.exec()
// Check if there are more communities beyond the current page.
const isNext = totalCommunitiesCount > skipAmount + communities.length
return {communities, isNext}
} catch (error) {
console.error('Error fetching communities:', error)
throw error
}
}
export async function addMemberToCommunity(
communityId: string,
memberId: string
) {
try {
connectToDB()
// Find the community by its unique id
const community = await Community.findOne({id: communityId})
if (!community) {
throw new Error('Community not found')
}
// Find the user by their unique id
const user = await User.findOne({id: memberId})
if (!user) {
throw new Error('User not found')
}
// Check if the user is already a member of the community
if (community.members.includes(user._id)) {
throw new Error('User is already a member of the community')
}
// Add the user's _id to the members array in the community
community.members.push(user._id)
await community.save()
// Add the community's _id to the communities array in the user
user.communities.push(community._id)
await user.save()
return community
} catch (error) {
// Handle any errors
console.error('Error adding member to community:', error)
throw error
}
}
export async function removeUserFromCommunity(
userId: string,
communityId: string
) {
try {
connectToDB()
const userIdObject = await User.findOne({id: userId}, {_id: 1})
const communityIdObject = await Community.findOne(
{id: communityId},
{_id: 1}
)
if (!userIdObject) {
throw new Error('User not found')
}
if (!communityIdObject) {
throw new Error('Community not found')
}
// Remove the user's _id from the members array in the community
await Community.updateOne(
{_id: communityIdObject._id},
{$pull: {members: userIdObject._id}}
)
// Remove the community's _id from the communities array in the user
await User.updateOne(
{_id: userIdObject._id},
{$pull: {communities: communityIdObject._id}}
)
return {success: true}
} catch (error) {
// Handle any errors
console.error('Error removing user from community:', error)
throw error
}
}
export async function updateCommunityInfo(
communityId: string,
name: string,
username: string,
image: string
) {
try {
connectToDB()
// Find the community by its _id and update the information
const updatedCommunity = await Community.findOneAndUpdate(
{id: communityId},
{name, username, image}
)
if (!updatedCommunity) {
throw new Error('Community not found')
}
return updatedCommunity
} catch (error) {
// Handle any errors
console.error('Error updating community information:', error)
throw error
}
}
export async function deleteCommunity(communityId: string) {
try {
connectToDB()
// Find the community by its ID and delete it
const deletedCommunity = await Community.findOneAndDelete({
id: communityId,
})
if (!deletedCommunity) {
throw new Error('Community not found')
}
// Delete all threads associated with the community
await Thread.deleteMany({community: communityId})
// Find all users who are part of the community
const communityUsers = await User.find({communities: communityId})
// Remove the community from the 'communities' array for each user
const updateUserPromises = communityUsers.map((user) => {
user.communities.pull(communityId)
return user.save()
})
await Promise.all(updateUserPromises)
return deletedCommunity
} catch (error) {
console.error('Error deleting community: ', error)
throw error
}
}
CommunityCard.tsx
import Image from "next/image";
import Link from "next/link";
import { Button } from "../ui/button";
interface Props {
id: string;
name: string;
username: string;
imgUrl: string;
bio: string;
members: {
image: string;
}[];
}
function CommunityCard({ id, name, username, imgUrl, bio, members }: Props) {
return (
<article className='community-card'>
<div className='flex flex-wrap items-center gap-3'>
<Link href={`/communities/${id}`} className='relative h-12 w-12'>
<Image
src={imgUrl}
alt='community_logo'
fill
className='rounded-full object-cover'
/>
</Link>
<div>
<Link href={`/communities/${id}`}>
<h4 className='text-base-semibold text-light-1'>{name}</h4>
</Link>
<p className='text-small-medium text-gray-1'>@{username}</p>
</div>
</div>
<p className='mt-4 text-subtle-medium text-gray-1'>{bio}</p>
<div className='mt-5 flex flex-wrap items-center justify-between gap-3'>
<Link href={`/communities/${id}`}>
<Button size='sm' className='community-card_btn'>
View
</Button>
</Link>
{members.length > 0 && (
<div className='flex items-center'>
{members.map((member, index) => (
<Image
key={index}
src={member.image}
alt={`user_${index}`}
width={28}
height={28}
className={`${
index !== 0 && "-ml-2"
} rounded-full object-cover`}
/>
))}
{members.length > 3 && (
<p className='ml-1 text-subtle-medium text-gray-1'>
{members.length}+ Users
</p>
)}
</div>
)}
</div>
</article>
);
}
export default CommunityCard;
constants.index.ts
export const sidebarLinks = [
{
imgURL: '/assets/home.svg',
route: '/',
label: 'Home',
},
{
imgURL: '/assets/search.svg',
route: '/search',
label: 'Search',
},
{
imgURL: '/assets/heart.svg',
route: '/activity',
label: 'Activity',
},
{
imgURL: '/assets/create.svg',
route: '/create-thread',
label: 'Create Thread',
},
{
imgURL: '/assets/community.svg',
route: '/communities',
label: 'Communities',
},
{
imgURL: '/assets/user.svg',
route: '/profile',
label: 'Profile',
},
]
export const profileTabs = [
{value: 'threads', label: 'Threads', icon: '/assets/reply.svg'},
{value: 'replies', label: 'Replies', icon: '/assets/members.svg'},
{value: 'tagged', label: 'Tagged', icon: '/assets/tag.svg'},
]
export const communityTabs = [
{value: 'threads', label: 'Threads', icon: '/assets/reply.svg'},
{value: 'members', label: 'Members', icon: '/assets/members.svg'},
{value: 'requests', label: 'Requests', icon: '/assets/request.svg'},
]
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
/* main */
.main-container {
@apply flex min-h-screen flex-1 flex-col items-center bg-dark-1 px-6 pb-10 pt-28 max-md:pb-32 sm:px-10;
}
/* Head Text */
.head-text {
@apply text-heading2-bold text-light-1;
}
/* Activity */
.activity-card {
@apply flex items-center gap-2 rounded-md bg-dark-2 px-7 py-4;
}
/* No Result */
.no-result {
@apply text-center !text-base-regular text-light-3;
}
/* Community Card */
.community-card {
@apply w-full rounded-lg bg-dark-3 px-4 py-5 sm:w-96;
}
.community-card_btn {
@apply rounded-lg bg-primary-500 px-5 py-1.5 text-small-regular !text-light-1 !important;
}
/* thread card */
.thread-card_bar {
@apply relative mt-2 w-0.5 grow rounded-full bg-neutral-800;
}
/* User card */
.user-card {
@apply flex flex-col justify-between gap-4 max-xs:rounded-xl max-xs:bg-dark-3 max-xs:p-4 xs:flex-row xs:items-center;
}
.user-card_avatar {
@apply flex flex-1 items-start justify-start gap-3 xs:items-center;
}
.user-card_btn {
@apply h-auto min-w-[74px] rounded-lg bg-primary-500 text-[12px] text-light-1 !important;
}
.searchbar {
@apply flex gap-1 rounded-lg bg-dark-3 px-4 py-2;
}
.searchbar_input {
@apply border-none bg-dark-3 text-base-regular text-light-4 outline-none !important;
}
.topbar {
@apply fixed top-0 z-30 flex w-full items-center justify-between bg-dark-2 px-6 py-3;
}
.bottombar {
@apply fixed bottom-0 z-10 w-full rounded-t-3xl bg-glassmorphism p-4 backdrop-blur-lg xs:px-7 md:hidden;
}
.bottombar_container {
@apply flex items-center justify-between gap-3 xs:gap-5;
}
.bottombar_link {
@apply relative flex flex-col items-center gap-2 rounded-lg p-2 sm:flex-1 sm:px-2 sm:py-2.5;
}
.leftsidebar {
@apply sticky left-0 top-0 z-20 flex h-screen w-fit flex-col justify-between overflow-auto border-r border-r-dark-4 bg-dark-2 pb-5 pt-28 max-md:hidden;
}
.leftsidebar_link {
@apply relative flex justify-start gap-4 rounded-lg p-4;
}
.pagination {
@apply mt-10 flex w-full items-center justify-center gap-5;
}
.rightsidebar {
@apply sticky right-0 top-0 z-20 flex h-screen w-fit flex-col justify-between gap-12 overflow-auto border-l border-l-dark-4 bg-dark-2 px-10 pb-6 pt-28 max-xl:hidden;
}
}
@layer utilities {
.css-invert {
@apply brightness-200 invert-[50%];
}
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
height: 3px;
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #09090a;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #5c5c7b;
border-radius: 50px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #7878a3;
}
}
/* Clerk Responsive fix */
.cl-organizationSwitcherTrigger .cl-userPreview .cl-userPreviewTextContainer {
@apply max-sm:hidden;
}
.cl-organizationSwitcherTrigger
.cl-organizationPreview
.cl-organizationPreviewTextContainer {
@apply max-sm:hidden;
}
/* Shadcn Component Styles */
/* Tab */
.tab {
@apply flex min-h-[50px] flex-1 items-center gap-3 bg-dark-2 text-light-2 data-[state=active]:bg-[#0e0e12] data-[state=active]:text-light-2 !important;
}
.no-focus {
@apply focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
}
/* Account Profile */
.account-form_image-label {
@apply flex h-24 w-24 items-center justify-center rounded-full bg-dark-4 !important;
}
.account-form_image-input {
@apply cursor-pointer border-none bg-transparent outline-none file:text-blue !important;
}
.account-form_input {
@apply border border-dark-4 bg-dark-3 text-light-1 !important;
}
/* Comment Form */
.comment-form {
@apply mt-10 flex items-center gap-4 border-y border-y-dark-4 py-5 max-xs:flex-col !important;
}
.comment-form_btn {
@apply rounded-3xl bg-primary-500 px-8 py-2 !text-small-regular text-light-1 max-xs:w-full !important;
}
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
serverComponentsExternalPackages: ['mongoose'],
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.clerk.com',
},
{
protocol: 'https',
hostname: 'images.clerk.dev',
},
{
protocol: 'https',
hostname: 'uploadthing.com',
},
{
protocol: 'https',
hostname: 'placehold.co',
},
],
typescript: {
ignoreBuildErrors: true,
},
},
}
module.exports = nextConfig
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
fontSize: {
'heading1-bold': [
'36px',
{
lineHeight: '140%',
fontWeight: '700',
},
],
'heading1-semibold': [
'36px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'heading2-bold': [
'30px',
{
lineHeight: '140%',
fontWeight: '700',
},
],
'heading2-semibold': [
'30px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'heading3-bold': [
'24px',
{
lineHeight: '140%',
fontWeight: '700',
},
],
'heading4-medium': [
'20px',
{
lineHeight: '140%',
fontWeight: '500',
},
],
'body-bold': [
'18px',
{
lineHeight: '140%',
fontWeight: '700',
},
],
'body-semibold': [
'18px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'body-medium': [
'18px',
{
lineHeight: '140%',
fontWeight: '500',
},
],
'body-normal': [
'18px',
{
lineHeight: '140%',
fontWeight: '400',
},
],
'body1-bold': [
'18px',
{
lineHeight: '140%',
fontWeight: '700',
},
],
'base-regular': [
'16px',
{
lineHeight: '140%',
fontWeight: '400',
},
],
'base-medium': [
'16px',
{
lineHeight: '140%',
fontWeight: '500',
},
],
'base-semibold': [
'16px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'base1-semibold': [
'16px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'small-regular': [
'14px',
{
lineHeight: '140%',
fontWeight: '400',
},
],
'small-medium': [
'14px',
{
lineHeight: '140%',
fontWeight: '500',
},
],
'small-semibold': [
'14px',
{
lineHeight: '140%',
fontWeight: '600',
},
],
'subtle-medium': [
'12px',
{
lineHeight: '16px',
fontWeight: '500',
},
],
'subtle-semibold': [
'12px',
{
lineHeight: '16px',
fontWeight: '600',
},
],
'tiny-medium': [
'10px',
{
lineHeight: '140%',
fontWeight: '500',
},
],
'x-small-semibold': [
'7px',
{
lineHeight: '9.318px',
fontWeight: '600',
},
],
},
extend: {
colors: {
'primary-500': '#877EFF',
'secondary-500': '#FFB620',
blue: '#0095F6',
'logout-btn': '#FF5A5A',
'navbar-menu': 'rgba(16, 16, 18, 0.6)',
'dark-1': '#000000',
'dark-2': '#121417',
'dark-3': '#101012',
'dark-4': '#1F1F22',
'light-1': '#FFFFFF',
'light-2': '#EFEFEF',
'light-3': '#7878A3',
'light-4': '#5C5C7B',
'gray-1': '#697C89',
glassmorphism: 'rgba(16, 16, 18, 0.60)',
},
boxShadow: {
'count-badge': '0px 0px 6px 2px rgba(219, 188, 159, 0.30)',
'groups-sidebar': '-30px 0px 60px 0px rgba(28, 28, 31, 0.50)',
},
screens: {
xs: '400px',
},
keyframes: {
'accordion-down': {
from: {height: 0},
to: {height: 'var(--radix-accordion-content-height)'},
},
'accordion-up': {
from: {height: 'var(--radix-accordion-content-height)'},
to: {height: 0},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}
thread.actions.ts
'use server'
import {revalidatePath} from 'next/cache'
import {connectToDB} from '../mongoose'
import User from '../models/user.model'
import Thread from '../models/thread.model'
import Community from '../models/community.model'
export async function fetchPosts(pageNumber = 1, pageSize = 20) {
connectToDB()
// Calculate the number of posts to skip based on the page number and page size.
const skipAmount = (pageNumber - 1) * pageSize
// Create a query to fetch the posts that have no parent (top-level threads) (a thread that is not a comment/reply).
const postsQuery = Thread.find({parentId: {$in: [null, undefined]}})
.sort({createdAt: 'desc'})
.skip(skipAmount)
.limit(pageSize)
.populate({
path: 'author',
model: User,
})
.populate({
path: 'community',
model: Community,
})
.populate({
path: 'children', // Populate the children field
populate: {
path: 'author', // Populate the author field within children
model: User,
select: '_id name parentId image', // Select only _id and username fields of the author
},
})
// Count the total number of top-level posts (threads) i.e., threads that are not comments.
const totalPostsCount = await Thread.countDocuments({
parentId: {$in: [null, undefined]},
}) // Get the total count of posts
const posts = await postsQuery.exec()
const isNext = totalPostsCount > skipAmount + posts.length
return {posts, isNext}
}
interface Params {
text: string
author: string
communityId: string | null
path: string
}
export async function createThread({text, author, communityId, path}: Params) {
try {
connectToDB()
const communityIdObject = await Community.findOne(
{id: communityId},
{_id: 1}
)
const createdThread = await Thread.create({
text,
author,
community: communityIdObject, // Assign communityId if provided, or leave it null for personal account
})
// Update User model
await User.findByIdAndUpdate(author, {
$push: {threads: createdThread._id},
})
if (communityIdObject) {
// Update Community model
await Community.findByIdAndUpdate(communityIdObject, {
$push: {threads: createdThread._id},
})
}
revalidatePath(path)
} catch (error: any) {
throw new Error(`Failed to create thread: ${error.message}`)
}
}
async function fetchAllChildThreads(threadId: string): Promise<any[]> {
const childThreads = await Thread.find({parentId: threadId})
const descendantThreads = []
for (const childThread of childThreads) {
const descendants = await fetchAllChildThreads(childThread._id)
descendantThreads.push(childThread, ...descendants)
}
return descendantThreads
}
export async function deleteThread(id: string, path: string): Promise<void> {
try {
connectToDB()
// Find the thread to be deleted (the main thread)
const mainThread = await Thread.findById(id).populate('author community')
if (!mainThread) {
throw new Error('Thread not found')
}
// Fetch all child threads and their descendants recursively
const descendantThreads = await fetchAllChildThreads(id)
// Get all descendant thread IDs including the main thread ID and child thread IDs
const descendantThreadIds = [
id,
...descendantThreads.map((thread) => thread._id),
]
// Extract the authorIds and communityIds to update User and Community models respectively
const uniqueAuthorIds = new Set(
[
...descendantThreads.map((thread) => thread.author?._id?.toString()), // Use optional chaining to handle possible undefined values
mainThread.author?._id?.toString(),
].filter((id) => id !== undefined)
)
const uniqueCommunityIds = new Set(
[
...descendantThreads.map((thread) => thread.community?._id?.toString()), // Use optional chaining to handle possible undefined values
mainThread.community?._id?.toString(),
].filter((id) => id !== undefined)
)
// Recursively delete child threads and their descendants
await Thread.deleteMany({_id: {$in: descendantThreadIds}})
// Update User model
await User.updateMany(
{_id: {$in: Array.from(uniqueAuthorIds)}},
{$pull: {threads: {$in: descendantThreadIds}}}
)
// Update Community model
await Community.updateMany(
{_id: {$in: Array.from(uniqueCommunityIds)}},
{$pull: {threads: {$in: descendantThreadIds}}}
)
revalidatePath(path)
} catch (error: any) {
throw new Error(`Failed to delete thread: ${error.message}`)
}
}
export async function fetchThreadById(threadId: string) {
connectToDB()
try {
const thread = await Thread.findById(threadId)
.populate({
path: 'author',
model: User,
select: '_id id name image',
}) // Populate the author field with _id and username
.populate({
path: 'community',
model: Community,
select: '_id id name image',
}) // Populate the community field with _id and name
.populate({
path: 'children', // Populate the children field
populate: [
{
path: 'author', // Populate the author field within children
model: User,
select: '_id id name parentId image', // Select only _id and username fields of the author
},
{
path: 'children', // Populate the children field within children
model: Thread, // The model of the nested children (assuming it's the same "Thread" model)
populate: {
path: 'author', // Populate the author field within nested children
model: User,
select: '_id id name parentId image', // Select only _id and username fields of the author
},
},
],
})
.exec()
return thread
} catch (err) {
console.error('Error while fetching thread:', err)
throw new Error('Unable to fetch thread')
}
}
export async function addCommentToThread(
threadId: string,
commentText: string,
userId: string,
path: string
) {
connectToDB()
try {
// Find the original thread by its ID
const originalThread = await Thread.findById(threadId)
if (!originalThread) {
throw new Error('Thread not found')
}
// Create the new comment thread
const commentThread = new Thread({
text: commentText,
author: userId,
parentId: threadId, // Set the parentId to the original thread's ID
})
// Save the comment thread to the database
const savedCommentThread = await commentThread.save()
// Add the comment thread's ID to the original thread's children array
originalThread.children.push(savedCommentThread._id)
// Save the updated original thread to the database
await originalThread.save()
revalidatePath(path)
} catch (err) {
console.error('Error while adding comment:', err)
throw new Error('Unable to add comment')
}
}
uploadthing.ts
// Resource: https://docs.uploadthing.com/api-reference/react#generatereacthelpers
// Copy paste (be careful with imports)
import {generateReactHelpers} from '@uploadthing/react/hooks'
import type {OurFileRouter} from '@/app/api/uploadthing/core'
export const {useUploadThing, uploadFiles} =
generateReactHelpers<OurFileRouter>()
user.actions.ts
'use server'
import {FilterQuery, SortOrder} from 'mongoose'
import {revalidatePath} from 'next/cache'
import Community from '../models/community.model'
import Thread from '../models/thread.model'
import User from '../models/user.model'
import {connectToDB} from '../mongoose'
export async function fetchUser(userId: string) {
try {
connectToDB()
return await User.findOne({id: userId}).populate({
path: 'communities',
model: Community,
})
} catch (error: any) {
throw new Error(`Failed to fetch user: ${error.message}`)
}
}
interface Params {
userId: string
username: string
name: string
bio: string
image: string
path: string
}
export async function updateUser({
userId,
bio,
name,
path,
username,
image,
}: Params): Promise<void> {
try {
connectToDB()
await User.findOneAndUpdate(
{id: userId},
{
username: username.toLowerCase(),
name,
bio,
image,
onboarded: true,
},
{upsert: true}
)
if (path === '/profile/edit') {
revalidatePath(path)
}
} catch (error: any) {
throw new Error(`Failed to create/update user: ${error.message}`)
}
}
export async function fetchUserPosts(userId: string) {
try {
connectToDB()
// Find all threads authored by the user with the given userId
const threads = await User.findOne({id: userId}).populate({
path: 'threads',
model: Thread,
populate: [
{
path: 'community',
model: Community,
select: 'name id image _id', // Select the "name" and "_id" fields from the "Community" model
},
{
path: 'children',
model: Thread,
populate: {
path: 'author',
model: User,
select: 'name image id', // Select the "name" and "_id" fields from the "User" model
},
},
],
})
return threads
} catch (error) {
console.error('Error fetching user threads:', error)
throw error
}
}
// Almost similar to Thead (search + pagination) and Community (search + pagination)
export async function fetchUsers({
userId,
searchString = '',
pageNumber = 1,
pageSize = 20,
sortBy = 'desc',
}: {
userId: string
searchString?: string
pageNumber?: number
pageSize?: number
sortBy?: SortOrder
}) {
try {
connectToDB()
// Calculate the number of users to skip based on the page number and page size.
const skipAmount = (pageNumber - 1) * pageSize
// Create a case-insensitive regular expression for the provided search string.
const regex = new RegExp(searchString, 'i')
// Create an initial query object to filter users.
const query: FilterQuery<typeof User> = {
id: {$ne: userId}, // Exclude the current user from the results.
}
// If the search string is not empty, add the $or operator to match either username onextr name fields.
if (searchString.trim() !== '') {
query.$or = [{username: {$regex: regex}}, {name: {$regex: regex}}]
}
// Define the sort options for the fetched users based on createdAt field and provided sort order.
const sortOptions = {createdAt: sortBy}
const usersQuery = User.find(query)
.sort(sortOptions)
.skip(skipAmount)
.limit(pageSize)
// Count the total number of users that match the search criteria (without pagination).
const totalUsersCount = await User.countDocuments(query)
const users = await usersQuery.exec()
// Check if there are more users beyond the current page.
const isNext = totalUsersCount > skipAmount + users.length
return {users, isNext}
} catch (error) {
console.error('Error fetching users:', error)
throw error
}
}
export async function getActivity(userId: string) {
try {
connectToDB()
// Find all threads created by the user
const userThreads = await Thread.find({author: userId})
// Collect all the child thread ids (replies) from the 'children' field of each user thread
const childThreadIds = userThreads.reduce((acc, userThread) => {
return acc.concat(userThread.children)
}, [])
// Find and return the child threads (replies) excluding the ones created by the same user
const replies = await Thread.find({
_id: {$in: childThreadIds},
author: {$ne: userId}, // Exclude threads authored by the same user
}).populate({
path: 'author',
model: User,
select: 'name image _id',
})
return replies
} catch (error) {
console.error('Error fetching replies: ', error)
throw error
}
}
utils.ts
import {type ClassValue, clsx} from 'clsx'
import {twMerge} from 'tailwind-merge'
// generated by shadcn
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// created by chatgpt
export function isBase64Image(imageData: string) {
const base64Regex = /^data:image\/(png|jpe?g|gif|webp);base64,/
return base64Regex.test(imageData)
}
// created by chatgpt
export function formatDateString(dateString: string) {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
}
const date = new Date(dateString)
const formattedDate = date.toLocaleDateString(undefined, options)
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
})
return `${time} - ${formattedDate}`
}
// created by chatgpt
export function formatThreadCount(count: number): string {
if (count === 0) {
return 'No Threads'
} else {
const threadCount = count.toString().padStart(2, '0')
const threadWord = count === 1 ? 'Thread' : 'Threads'
return `${threadCount} ${threadWord}`
}
}