Skip to content

Com o Threads, os usuários podem postar um tópico, comentar e criar ou participar de comunidades públicas ou privadas.

Notifications You must be signed in to change notification settings

jvalvarenga/threads

Repository files navigation


Project Banner
nextdotjs mongodb tailwindcss clerk shadcnui zod typescript

Um clone full stack do Threads

📋 Índice

  1. 🤖 Introdução
  2. ⚙️ Tech Stack
  3. 🔋 Features
  4. 🤸 Quick Start
  5. 🕸️ 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}`
  }
}

About

Com o Threads, os usuários podem postar um tópico, comentar e criar ou participar de comunidades públicas ou privadas.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published