+
{members.map((member, index) => (
@@ -60,7 +60,7 @@ function CommunityCard({ id, name, username, imgUrl, bio, members }: Props) {
/>
))}
{members.length > 3 && (
-
+
{members.length}+ Users
)}
@@ -68,7 +68,7 @@ function CommunityCard({ id, name, username, imgUrl, bio, members }: Props) {
)}
- );
+ )
}
-export default CommunityCard;
+export default CommunityCard
diff --git a/src/presentation/components/cards/ThreadCard.tsx b/src/presentation/components/cards/ThreadCard.tsx
new file mode 100644
index 0000000..8a76c33
--- /dev/null
+++ b/src/presentation/components/cards/ThreadCard.tsx
@@ -0,0 +1,169 @@
+import Image from "next/image"
+import Link from "next/link"
+
+import { formatDateString } from "@/lib/utils"
+import DeleteThread from "../forms/DeleteThread"
+
+interface Props {
+ id: string
+ currentUserId: string
+ parentId: string | null
+ content: string
+ author: {
+ name: string
+ image: string
+ id: string
+ }
+ community: {
+ id: string
+ name: string
+ image: string
+ } | null
+ createdAt: string
+ comments: {
+ author: {
+ image: string
+ }
+ }[]
+ isComment?: boolean
+}
+
+function ThreadCard({
+ id,
+ currentUserId,
+ parentId,
+ content,
+ author,
+ community,
+ createdAt,
+ comments,
+ isComment,
+}: Props) {
+ return (
+
+
+
+
+
+
+
+
+ {author.name}
+
+
+
+
{content}
+
+
+
+
+
+
+
+
+
+
+
+ {isComment && comments.length > 0 && (
+
+
+ {comments.length} repl{comments.length > 1 ? "ies" : "y"}
+
+
+ )}
+
+
+
+
+
+
+
+ {!isComment && comments.length > 0 && (
+
+ {comments.slice(0, 2).map((comment, index) => (
+
+ key={index}
+ src={comment.author.image}
+ alt={`user_${index}`}
+ width={24}
+ height={24}
+ className={`${index !== 0 && "-ml-5"} rounded-full object-cover`}
+ />
+ ))}
+
+
+
+ {comments.length} repl{comments.length > 1 ? "ies" : "y"}
+
+
+
+ )}
+
+ {!isComment && community && (
+
+
+ {formatDateString(createdAt)}
+ {community && ` - ${community.name} Community`}
+
+
+
+
+ )}
+
+ )
+}
+
+export default ThreadCard
diff --git a/src/presentation/components/cards/UserCard.tsx b/src/presentation/components/cards/UserCard.tsx
new file mode 100644
index 0000000..2e5c58a
--- /dev/null
+++ b/src/presentation/components/cards/UserCard.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import Image from "next/image"
+import { useRouter } from "next/navigation"
+
+import { Button } from "../ui/button"
+
+interface Props {
+ id: string
+ name: string
+ username: string
+ imgUrl: string
+ personType: string
+}
+
+function UserCard({ id, name, username, imgUrl, personType }: Props) {
+ const router = useRouter()
+
+ const isCommunity = personType === "Community"
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default UserCard
diff --git a/src/presentation/components/forms/AccountProfile.tsx b/src/presentation/components/forms/AccountProfile.tsx
new file mode 100644
index 0000000..b9c7a42
--- /dev/null
+++ b/src/presentation/components/forms/AccountProfile.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import { zodResolver } from "@hookform/resolvers/zod"
+import Image from "next/image"
+import { usePathname, useRouter } from "next/navigation"
+import { useState, type ChangeEvent } from "react"
+import { useForm } from "react-hook-form"
+import type * as z from "zod"
+
+import { Button } from "@/presentation/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/presentation/components/ui/form"
+import { Input } from "@/presentation/components/ui/input"
+import { Textarea } from "@/presentation/components/ui/textarea"
+
+import { useUploadThing } from "@/lib/uploadthing"
+import { isBase64Image } from "@/lib/utils"
+
+import { userService } from "@/application"
+import { UserValidation } from "@/lib/validations/user"
+
+interface Props {
+ user: {
+ id: string
+ objectId: string
+ username: string
+ name: string
+ bio: string
+ image: string
+ }
+ btnTitle: string
+}
+
+const AccountProfile = ({ user, btnTitle }: Props) => {
+ const router = useRouter()
+ const pathname = usePathname()
+ const { startUpload } = useUploadThing("media")
+
+ const [files, setFiles] = useState
([])
+
+ const form = useForm>({
+ resolver: zodResolver(UserValidation),
+ defaultValues: {
+ profile_photo: user?.image ? user.image : "",
+ name: user?.name ? user.name : "",
+ username: user?.username ? user.username : "",
+ bio: user?.bio ? user.bio : "",
+ },
+ })
+
+ const onSubmit = async (values: z.infer) => {
+ const blob = values.profile_photo
+
+ const hasImageChanged = isBase64Image(blob)
+ if (hasImageChanged) {
+ const imgRes = await startUpload(files)
+
+ if (imgRes?.[0].fileUrl) {
+ values.profile_photo = imgRes[0].fileUrl
+ }
+ }
+
+ await userService.updateUser({
+ name: values.name,
+ path: pathname,
+ username: values.username,
+ userId: user.id,
+ bio: values.bio,
+ image: values.profile_photo,
+ })
+
+ if (pathname === "/profile/edit") {
+ router.back()
+ } else {
+ router.push("/")
+ }
+ }
+
+ const handleImage = (
+ e: ChangeEvent,
+ fieldChange: (value: string) => void
+ ) => {
+ e.preventDefault()
+
+ const fileReader = new FileReader()
+
+ if (e.target.files && e.target.files.length > 0) {
+ const file = e.target.files[0]
+ setFiles(Array.from(e.target.files))
+
+ if (!file.type.includes("image")) return
+
+ fileReader.onload = async (event) => {
+ const imageDataUrl = event.target?.result?.toString() || ""
+ fieldChange(imageDataUrl)
+ }
+
+ fileReader.readAsDataURL(file)
+ }
+ }
+
+ return (
+
+
+ )
+}
+
+export default AccountProfile
diff --git a/src/presentation/components/forms/Comment.tsx b/src/presentation/components/forms/Comment.tsx
new file mode 100644
index 0000000..97dd330
--- /dev/null
+++ b/src/presentation/components/forms/Comment.tsx
@@ -0,0 +1,87 @@
+"use client"
+
+import { zodResolver } from "@hookform/resolvers/zod"
+import Image from "next/image"
+import { usePathname } from "next/navigation"
+import { useForm } from "react-hook-form"
+import type { z } from "zod"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@/presentation/components/ui/form"
+
+import { Button } from "../ui/button"
+import { Input } from "../ui/input"
+
+import { threadService } from "@/application"
+import { CommentValidation } from "@/lib/validations/thread"
+
+interface Props {
+ threadId: string
+ currentUserImg: string
+ currentUserId: string
+}
+
+function Comment({ threadId, currentUserImg, currentUserId }: Props) {
+ const pathname = usePathname()
+
+ const form = useForm>({
+ resolver: zodResolver(CommentValidation),
+ defaultValues: {
+ thread: "",
+ },
+ })
+
+ const onSubmit = async (values: z.infer) => {
+ await threadService.addCommentToThread(
+ threadId,
+ values.thread,
+ JSON.parse(currentUserId),
+ pathname
+ )
+
+ form.reset()
+ }
+
+ return (
+
+
+ )
+}
+
+export default Comment
diff --git a/src/presentation/components/forms/DeleteThread.tsx b/src/presentation/components/forms/DeleteThread.tsx
new file mode 100644
index 0000000..787c326
--- /dev/null
+++ b/src/presentation/components/forms/DeleteThread.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import { threadService } from "@/application"
+import Image from "next/image"
+import { usePathname, useRouter } from "next/navigation"
+
+interface Props {
+ threadId: string
+ currentUserId: string
+ authorId: string
+ parentId: string | null
+ isComment?: boolean
+}
+
+function DeleteThread({
+ threadId,
+ currentUserId,
+ authorId,
+ parentId,
+ isComment,
+}: Props) {
+ const pathname = usePathname()
+ const router = useRouter()
+
+ if (currentUserId !== authorId || pathname === "/") return null
+
+ return (
+ {
+ await threadService.deleteThread(JSON.parse(threadId), pathname)
+ if (!parentId || !isComment) {
+ router.push("/")
+ }
+ }}
+ />
+ )
+}
+
+export default DeleteThread
diff --git a/src/presentation/components/forms/PostThread.tsx b/src/presentation/components/forms/PostThread.tsx
new file mode 100644
index 0000000..0b0deaf
--- /dev/null
+++ b/src/presentation/components/forms/PostThread.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { useOrganization } from "@clerk/nextjs"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { usePathname, useRouter } from "next/navigation"
+import { useForm } from "react-hook-form"
+import type * as z from "zod"
+
+import { Button } from "@/presentation/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/presentation/components/ui/form"
+import { Textarea } from "@/presentation/components/ui/textarea"
+
+import { threadService } from "@/application"
+import { ThreadValidation } from "@/lib/validations/thread"
+
+interface Props {
+ userId: string
+}
+
+function PostThread({ userId }: Props) {
+ const router = useRouter()
+ const pathname = usePathname()
+
+ const { organization } = useOrganization()
+
+ const form = useForm>({
+ resolver: zodResolver(ThreadValidation),
+ defaultValues: {
+ thread: "",
+ accountId: userId,
+ },
+ })
+
+ const onSubmit = async (values: z.infer) => {
+ await threadService.createThread({
+ text: values.thread,
+ author: userId,
+ communityId: organization ? organization.id : null,
+ path: pathname,
+ })
+
+ router.push("/")
+ }
+
+ return (
+
+
+ )
+}
+
+export default PostThread
diff --git a/components/shared/Bottombar.tsx b/src/presentation/components/shared/Bottombar.tsx
similarity index 57%
rename from components/shared/Bottombar.tsx
rename to src/presentation/components/shared/Bottombar.tsx
index a9568fa..6cd7b33 100644
--- a/components/shared/Bottombar.tsx
+++ b/src/presentation/components/shared/Bottombar.tsx
@@ -1,21 +1,21 @@
-"use client";
+"use client"
-import Image from "next/image";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
+import Image from "next/image"
+import Link from "next/link"
+import { usePathname } from "next/navigation"
-import { sidebarLinks } from "@/constants";
+import { sidebarLinks } from "@/constants"
function Bottombar() {
- const pathname = usePathname();
+ const pathname = usePathname()
return (
-
-
+
+
{sidebarLinks.map((link) => {
const isActive =
(pathname.includes(link.route) && link.route.length > 1) ||
- pathname === link.route;
+ pathname === link.route
return (
-
+
{link.label.split(/\s+/)[0]}
- );
+ )
})}
- );
+ )
}
-export default Bottombar;
+export default Bottombar
diff --git a/components/shared/LeftSidebar.tsx b/src/presentation/components/shared/LeftSidebar.tsx
similarity index 55%
rename from components/shared/LeftSidebar.tsx
rename to src/presentation/components/shared/LeftSidebar.tsx
index ef68e4e..85d0c5b 100644
--- a/components/shared/LeftSidebar.tsx
+++ b/src/presentation/components/shared/LeftSidebar.tsx
@@ -1,27 +1,27 @@
-"use client";
+"use client"
-import Image from "next/image";
-import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
-import { SignOutButton, SignedIn, useAuth } from "@clerk/nextjs";
+import Image from "next/image"
+import Link from "next/link"
+import { usePathname, useRouter } from "next/navigation"
+import { SignOutButton, SignedIn, useAuth } from "@clerk/nextjs"
-import { sidebarLinks } from "@/constants";
+import { sidebarLinks } from "@/constants"
const LeftSidebar = () => {
- const router = useRouter();
- const pathname = usePathname();
+ const router = useRouter()
+ const pathname = usePathname()
- const { userId } = useAuth();
+ const { userId } = useAuth()
return (
-
-
+
+
{sidebarLinks.map((link) => {
const isActive =
(pathname.includes(link.route) && link.route.length > 1) ||
- pathname === link.route;
+ pathname === link.route
- if (link.route === "/profile") link.route = `${link.route}/${userId}`;
+ if (link.route === "/profile") link.route = `${link.route}/${userId}`
return (
{
height={24}
/>
-
{link.label}
+
{link.label}
- );
+ )
})}
-
+
router.push("/sign-in")}>
-
- );
-};
+ )
+}
-export default LeftSidebar;
+export default LeftSidebar
diff --git a/src/presentation/components/shared/Pagination.tsx b/src/presentation/components/shared/Pagination.tsx
new file mode 100644
index 0000000..8ade66e
--- /dev/null
+++ b/src/presentation/components/shared/Pagination.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+
+import { Button } from "../ui/button"
+
+interface Props {
+ pageNumber: number
+ isNext: boolean
+ path: string
+}
+
+function Pagination({ pageNumber, isNext, path }: Props) {
+ const router = useRouter()
+
+ const handleNavigation = (type: string) => {
+ let nextPageNumber = pageNumber
+
+ if (type === "prev") {
+ nextPageNumber = Math.max(1, pageNumber - 1)
+ } else if (type === "next") {
+ nextPageNumber = pageNumber + 1
+ }
+
+ if (nextPageNumber > 1) {
+ router.push(`/${path}?page=${nextPageNumber}`)
+ } else {
+ router.push(`/${path}`)
+ }
+ }
+
+ if (!isNext && pageNumber === 1) return null
+
+ return (
+
+
+
{pageNumber}
+
+
+ )
+}
+
+export default Pagination
diff --git a/src/presentation/components/shared/ProfileHeader.tsx b/src/presentation/components/shared/ProfileHeader.tsx
new file mode 100644
index 0000000..f9a4d82
--- /dev/null
+++ b/src/presentation/components/shared/ProfileHeader.tsx
@@ -0,0 +1,66 @@
+import Link from "next/link"
+import Image from "next/image"
+
+interface Props {
+ accountId: string
+ authUserId: string
+ name: string
+ username: string
+ imgUrl: string
+ bio: string
+ type?: string
+}
+
+function ProfileHeader({
+ accountId,
+ authUserId,
+ name,
+ username,
+ imgUrl,
+ bio,
+ type,
+}: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+ {name}
+
+
@{username}
+
+
+ {accountId === authUserId && type !== "Community" && (
+
+
+
+ )}
+
+
+
{bio}
+
+
+
+ )
+}
+
+export default ProfileHeader
diff --git a/src/presentation/components/shared/RightSidebar.tsx b/src/presentation/components/shared/RightSidebar.tsx
new file mode 100644
index 0000000..9ffd995
--- /dev/null
+++ b/src/presentation/components/shared/RightSidebar.tsx
@@ -0,0 +1,76 @@
+import { currentUser } from "@clerk/nextjs"
+
+import UserCard from "../cards/UserCard"
+
+import { communityService, userService } from "@/application"
+
+async function RightSidebar() {
+ const user = await currentUser()
+ if (!user) return null
+
+ const similarMinds = await userService.getUsers({
+ userId: user.id,
+ pageSize: 4,
+ })
+
+ const suggestedCOmmunities = await communityService.getCommunities({
+ pageSize: 4,
+ })
+
+ return (
+
+
+
+ Comunidades Sugeridas
+
+
+
+ {suggestedCOmmunities.communities.length > 0 ? (
+ <>
+ {suggestedCOmmunities.communities.map((community) => (
+
+ ))}
+ >
+ ) : (
+
+ Sem comunidades ainda
+
+ )}
+
+
+
+
+
Ideias Similares
+
+ {similarMinds.users.length > 0 ? (
+ <>
+ {similarMinds.users.map((person) => (
+
+ ))}
+ >
+ ) : (
+
+ Sem utilizadores ainda
+
+ )}
+
+
+
+ )
+}
+
+export default RightSidebar
diff --git a/src/presentation/components/shared/Searchbar.tsx b/src/presentation/components/shared/Searchbar.tsx
new file mode 100644
index 0000000..8bd6d04
--- /dev/null
+++ b/src/presentation/components/shared/Searchbar.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import Image from "next/image"
+import { useRouter } from "next/navigation"
+import { useEffect, useState } from "react"
+
+import { Input } from "../ui/input"
+
+interface Props {
+ routeType: string
+}
+
+function Searchbar({ routeType }: Props) {
+ const router = useRouter()
+ const [search, setSearch] = useState("")
+
+ // query after 0.3s of no input
+ // biome-ignore lint/correctness/useExhaustiveDependencies:
+ useEffect(() => {
+ const delayDebounceFn = setTimeout(() => {
+ if (search) {
+ router.push(`/${routeType}?q=${search}`)
+ } else {
+ router.push(`/${routeType}`)
+ }
+ }, 300)
+
+ return () => clearTimeout(delayDebounceFn)
+ }, [search, routeType])
+
+ return (
+
+
+ setSearch(e.target.value)}
+ placeholder={`${routeType !== "/search" ? "Pesquisar comunidade" : "Pesquisar desenvolvedores"}`}
+ className="no-focus searchbar_input"
+ />
+
+ )
+}
+
+export default Searchbar
diff --git a/src/presentation/components/shared/ThreadsTab.tsx b/src/presentation/components/shared/ThreadsTab.tsx
new file mode 100644
index 0000000..d29c225
--- /dev/null
+++ b/src/presentation/components/shared/ThreadsTab.tsx
@@ -0,0 +1,87 @@
+import { redirect } from "next/navigation"
+
+import { communityService, userService } from "@/application"
+import ThreadCard from "../cards/ThreadCard"
+
+interface Result {
+ name: string
+ image: string
+ id: string
+ threads: {
+ _id: string
+ text: string
+ parentId: string | null
+ author: {
+ name: string
+ image: string
+ id: string
+ }
+ community: {
+ id: string
+ name: string
+ image: string
+ } | null
+ createdAt: string
+ children: {
+ author: {
+ image: string
+ }
+ }[]
+ }[]
+}
+
+interface Props {
+ currentUserId: string
+ accountId: string
+ accountType: string
+}
+
+async function ThreadsTab({ currentUserId, accountId, accountType }: Props) {
+ let result: any // TODO: Add correctly type
+
+ if (accountType === "Community") {
+ result = await communityService.getCommunityPosts(accountId)
+ } else {
+ result = await userService.getUserPosts(accountId)
+ }
+
+ if (!result) {
+ redirect("/")
+ }
+
+ return (
+
+ {result.threads.map(
+ (
+ thread: any // TODO: Add correctly type
+ ) => (
+
+ )
+ )}
+
+ )
+}
+
+export default ThreadsTab
diff --git a/src/presentation/components/shared/Topbar.tsx b/src/presentation/components/shared/Topbar.tsx
new file mode 100644
index 0000000..9e7ab5f
--- /dev/null
+++ b/src/presentation/components/shared/Topbar.tsx
@@ -0,0 +1,35 @@
+import { OrganizationSwitcher, SignedIn, SignOutButton } from "@clerk/nextjs"
+import Image from "next/image"
+import Link from "next/link"
+
+function Topbar() {
+ return (
+
+ )
+}
+
+export default Topbar
diff --git a/src/presentation/components/ui/button.tsx b/src/presentation/components/ui/button.tsx
new file mode 100644
index 0000000..883c382
--- /dev/null
+++ b/src/presentation/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-800",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
+ destructive:
+ "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/90",
+ outline:
+ "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
+ secondary:
+ "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
+ ghost:
+ "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
+ link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/src/presentation/components/ui/form.tsx b/src/presentation/components/ui/form.tsx
new file mode 100644
index 0000000..7707d7f
--- /dev/null
+++ b/src/presentation/components/ui/form.tsx
@@ -0,0 +1,179 @@
+import type * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import * as React from "react"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/presentation/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ useFormField,
+}
diff --git a/src/presentation/components/ui/input.tsx b/src/presentation/components/ui/input.tsx
new file mode 100644
index 0000000..f385bc7
--- /dev/null
+++ b/src/presentation/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/src/presentation/components/ui/label.tsx b/src/presentation/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/src/presentation/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/presentation/components/ui/menubar.tsx b/src/presentation/components/ui/menubar.tsx
new file mode 100644
index 0000000..e51e0ae
--- /dev/null
+++ b/src/presentation/components/ui/menubar.tsx
@@ -0,0 +1,236 @@
+"use client"
+
+import * as React from "react"
+import * as MenubarPrimitive from "@radix-ui/react-menubar"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const MenubarMenu = MenubarPrimitive.Menu
+
+const MenubarGroup = MenubarPrimitive.Group
+
+const MenubarPortal = MenubarPrimitive.Portal
+
+const MenubarSub = MenubarPrimitive.Sub
+
+const MenubarRadioGroup = MenubarPrimitive.RadioGroup
+
+const Menubar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Menubar.displayName = MenubarPrimitive.Root.displayName
+
+const MenubarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
+
+const MenubarSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
+
+const MenubarSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
+
+const MenubarContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
+ ref
+ ) => (
+
+
+
+ )
+)
+MenubarContent.displayName = MenubarPrimitive.Content.displayName
+
+const MenubarItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+MenubarItem.displayName = MenubarPrimitive.Item.displayName
+
+const MenubarCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
+
+const MenubarRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
+
+const MenubarLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+MenubarLabel.displayName = MenubarPrimitive.Label.displayName
+
+const MenubarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
+
+const MenubarShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+MenubarShortcut.displayname = "MenubarShortcut"
+
+export {
+ Menubar,
+ MenubarMenu,
+ MenubarTrigger,
+ MenubarContent,
+ MenubarItem,
+ MenubarSeparator,
+ MenubarLabel,
+ MenubarCheckboxItem,
+ MenubarRadioGroup,
+ MenubarRadioItem,
+ MenubarPortal,
+ MenubarSubContent,
+ MenubarSubTrigger,
+ MenubarGroup,
+ MenubarSub,
+ MenubarShortcut,
+}
diff --git a/src/presentation/components/ui/select.tsx b/src/presentation/components/ui/select.tsx
new file mode 100644
index 0000000..17ac65d
--- /dev/null
+++ b/src/presentation/components/ui/select.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+}
diff --git a/src/presentation/components/ui/tabs.tsx b/src/presentation/components/ui/tabs.tsx
new file mode 100644
index 0000000..3751825
--- /dev/null
+++ b/src/presentation/components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/presentation/components/ui/textarea.tsx b/src/presentation/components/ui/textarea.tsx
new file mode 100644
index 0000000..3a882e4
--- /dev/null
+++ b/src/presentation/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/src/presentation/components/ui/toast.tsx b/src/presentation/components/ui/toast.tsx
new file mode 100644
index 0000000..efe793c
--- /dev/null
+++ b/src/presentation/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-slate-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-slate-800",
+ {
+ variants: {
+ variant: {
+ default: "border bg-white dark:bg-slate-950",
+ destructive:
+ "destructive group border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900 dark:text-red-50",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/src/presentation/components/ui/toaster.tsx b/src/presentation/components/ui/toaster.tsx
new file mode 100644
index 0000000..a3638c0
--- /dev/null
+++ b/src/presentation/components/ui/toaster.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/presentation/components/ui/toast"
+import { useToast } from "@/presentation/components/ui/useToast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(({ id, title, description, action, ...props }) => (
+
+
+ {title && {title}}
+ {description && {description}}
+
+ {action}
+
+
+ ))}
+
+
+ )
+}
diff --git a/components/ui/use-toast.ts b/src/presentation/components/ui/useToast.ts
similarity index 89%
rename from components/ui/use-toast.ts
rename to src/presentation/components/ui/useToast.ts
index 094e274..3c97233 100644
--- a/components/ui/use-toast.ts
+++ b/src/presentation/components/ui/useToast.ts
@@ -4,7 +4,7 @@ import * as React from "react"
import type {
ToastActionElement,
ToastProps,
-} from "@/components/ui/toast"
+} from "@/presentation/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
@@ -34,21 +34,21 @@ type ActionType = typeof actionTypes
type Action =
| {
- type: ActionType["ADD_TOAST"]
- toast: ToasterToast
- }
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
| {
- type: ActionType["UPDATE_TOAST"]
- toast: Partial
- }
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
| {
- type: ActionType["DISMISS_TOAST"]
- toastId?: ToasterToast["id"]
- }
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
| {
- type: ActionType["REMOVE_TOAST"]
- toastId?: ToasterToast["id"]
- }
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
interface State {
toasts: ToasterToast[]
@@ -107,9 +107,9 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
- ...t,
- open: false,
- }
+ ...t,
+ open: false,
+ }
: t
),
}
@@ -192,4 +192,4 @@ function useToast() {
}
}
-export { useToast, toast }
+export { toast, useToast }
diff --git a/src/presentation/contexts/RouteChangeContext.tsx b/src/presentation/contexts/RouteChangeContext.tsx
new file mode 100644
index 0000000..6138e60
--- /dev/null
+++ b/src/presentation/contexts/RouteChangeContext.tsx
@@ -0,0 +1,68 @@
+"use client"
+import { usePathname, useSearchParams } from "next/navigation"
+import {
+ Suspense,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react"
+
+type RouteChangeContextProps = {
+ routeChangeStartCallbacks: Function[]
+ routeChangeCompleteCallbacks: Function[]
+ onRouteChangeStart: () => void
+ onRouteChangeComplete: () => void
+}
+
+type RouteChangeProviderProps = {
+ children: React.ReactNode
+}
+
+const RouteChangeContext = createContext(
+ {} as RouteChangeContextProps
+)
+
+export const useRouteChangeContext = () => useContext(RouteChangeContext)
+
+function RouteChangeComplete() {
+ const { onRouteChangeComplete } = useRouteChangeContext()
+
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+ useEffect(() => onRouteChangeComplete(), [pathname, searchParams])
+
+ return null
+}
+
+export const RouteChangeProvider: React.FC = ({
+ children,
+}: RouteChangeProviderProps) => {
+ const [routeChangeStartCallbacks] = useState([])
+ const [routeChangeCompleteCallbacks] = useState([])
+
+ const onRouteChangeStart = useCallback(() => {
+ routeChangeStartCallbacks.forEach((callback) => callback())
+ }, [routeChangeStartCallbacks])
+
+ const onRouteChangeComplete = useCallback(() => {
+ routeChangeCompleteCallbacks.forEach((callback) => callback())
+ }, [routeChangeCompleteCallbacks])
+
+ return (
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/src/presentation/hooks/useOnClickOutside.ts b/src/presentation/hooks/useOnClickOutside.ts
new file mode 100644
index 0000000..5f24cb3
--- /dev/null
+++ b/src/presentation/hooks/useOnClickOutside.ts
@@ -0,0 +1,30 @@
+"use client"
+import { RefObject, useEffect } from "react"
+
+type Event = MouseEvent | TouchEvent
+
+const useOnClickOutside = (
+ ref: RefObject,
+ handler: (event: Event) => void
+) => {
+ useEffect(() => {
+ const listener = (event: Event) => {
+ const el = ref?.current
+ if (!el || el.contains((event?.target as Node) || null)) {
+ return
+ }
+
+ handler(event)
+ }
+
+ document.addEventListener("mousedown", listener)
+ document.addEventListener("touchstart", listener)
+
+ return () => {
+ document.removeEventListener("mousedown", listener)
+ document.removeEventListener("touchstart", listener)
+ }
+ }, [ref, handler])
+}
+
+export default useOnClickOutside
diff --git a/tailwind.config.js b/tailwind.config.js
index 1aaff91..592d797 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -213,4 +213,4 @@ module.exports = {
},
},
plugins: [require("tailwindcss-animate")],
-};
+}
diff --git a/tsconfig.json b/tsconfig.json
index e06a445..0c7555f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,7 +20,7 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],