-
-
Save adrianhajdin/060e4c9d3d8d4274b7669e260dbbcc8e to your computer and use it in GitHub Desktop.
/* 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 } | |
); | |
} | |
} | |
}; |
"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; | |
} | |
} |
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; |
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" }, | |
]; |
@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 invert-[50%] brightness-200; | |
} | |
.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; | |
} |
/** @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; |
/** @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")], | |
}; |
"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"); | |
} | |
} |
// 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>(); |
"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 or 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; | |
} | |
} |
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}`; | |
} | |
} |
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.
Use file instead of fileUrl. Its deprecated.
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.
Use file instead of fileUrl. Its deprecated.
I get the same error.
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.
Use file instead of fileUrl. Its deprecated.
I get the same error.
Your issue is most likely in your uploadthing core.ts on line 12 it should read media: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.
Use file instead of fileUrl. Its deprecated.
I get the same error.
Your issue is most likely in your uploadthing core.ts on line 12 it should read media: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
I appreciate your response. Unfortunately I still get the same error : Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.ts(2339)
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.
Use file instead of fileUrl. Its deprecated.
I get the same error.
Your issue is most likely in your uploadthing core.ts on line 12 it should read media: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
I appreciate your response. Unfortunately I still get the same error : Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.ts(2339)
Sorry meant "url", try (.url) instead and remove File. It should fix your issue.
I got the same issue today in the AccountProfile. Property 'fileUrl' does not exist on type 'ClientUploadedFileData<{ uploadedBy: string; }>'.