diff --git a/package-lock.json b/package-lock.json index c52c7de..e592abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,16 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", "@tanstack/react-query": "^5.83.0", @@ -623,6 +625,34 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -837,6 +867,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", @@ -918,6 +977,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", diff --git a/package.json b/package.json index 8f8e98b..2e8d867 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,16 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", "@tanstack/react-query": "^5.83.0", diff --git a/src/pages/home/_components/ListItem.tsx b/src/components/common/ListItem.tsx similarity index 55% rename from src/pages/home/_components/ListItem.tsx rename to src/components/common/ListItem.tsx index 14924e2..96251bf 100644 --- a/src/pages/home/_components/ListItem.tsx +++ b/src/components/common/ListItem.tsx @@ -1,11 +1,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Heart, MessageCircle } from "lucide-react"; +import { Heart, MessageCircle, Users } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import type { Post } from "@/types/post.types"; +import type { PostListItem } from "@/types/post.types"; +import { formatPostListDate } from "@/lib/date"; interface ListItemProps { - post: Post; + post: PostListItem; } export const ListItem = ({ post }: ListItemProps) => { @@ -15,14 +16,6 @@ export const ListItem = ({ post }: ListItemProps) => { navigate(`/community/${post.id}`); }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("ko-KR", { - month: "short", - day: "numeric", - }); - }; - return ( { - {post.author.nickname} · {formatDate(post.createdAt)} + {post.author.nickname} · {formatPostListDate(post.createdAt)}
{post.nLikes}
-
- - {post.nComments} -
+ {/* 일반 게시판: 댓글 개수 표시 */} + {!post.board.isRecruit && ( +
+ + {post.nComments} +
+ )} + {/* 모집 게시판: 지원자 개수 표시. api에 관련 필드 없는 관계로 쿼리로 처리 중--개선 필요*/} + {post.board.isRecruit && ( +
+ + {post.nApplicants || 0} +
+ )}
diff --git a/src/components/common/PositionSelector.tsx b/src/components/common/PositionSelector.tsx index 8e04ad8..7bdbfea 100644 --- a/src/components/common/PositionSelector.tsx +++ b/src/components/common/PositionSelector.tsx @@ -12,14 +12,20 @@ import type { Position } from "@/types/tags.types"; interface PositionSelectorProps { selectedPosition: number | null; + selectedPositionIds?: number[]; onPositionChange: (positionId: number) => void; + onPositionIdsChange?: (positionIds: number[]) => void; label?: string; + multiple?: boolean; } export const PositionSelector = ({ selectedPosition, + selectedPositionIds = [], onPositionChange, + onPositionIdsChange, label = "포지션", + multiple = false, }: PositionSelectorProps) => { const { data: positionsInterestsData, isLoading } = usePositionsInterests(); @@ -59,21 +65,38 @@ export const PositionSelector = ({
- {fieldPositions.map((position) => ( - - ))} + {fieldPositions.map((position) => { + const isSelected = multiple + ? selectedPositionIds.includes(position.id) + : selectedPosition === position.id; + + const handleClick = () => { + if (multiple && onPositionIdsChange) { + const newPositionIds = isSelected + ? selectedPositionIds.filter((id) => id !== position.id) + : [...selectedPositionIds, position.id]; + onPositionIdsChange(newPositionIds); + } else { + onPositionChange(position.id); + } + }; + + return ( + + ); + })}
diff --git a/src/components/layout/GlobalLayout.tsx b/src/components/layout/GlobalLayout.tsx index b4cc8bf..ece9760 100644 --- a/src/components/layout/GlobalLayout.tsx +++ b/src/components/layout/GlobalLayout.tsx @@ -9,9 +9,10 @@ interface GlobalLayoutProps { function GlobalLayout({ children }: GlobalLayoutProps) { const location = useLocation(); - const shouldShowBottomNav = ["/board", "/explore", "/chat", "/my"].some( - (path) => location.pathname.startsWith(path) - ); + const shouldShowBottomNav = + ["/board", "/explore", "/chat", "/my"].some((path) => + location.pathname.startsWith(path) + ) && !location.pathname.startsWith("/my/edit"); return (
diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx index c380fcd..fa4c36e 100644 --- a/src/components/profile/ProfileSection.tsx +++ b/src/components/profile/ProfileSection.tsx @@ -1,71 +1,52 @@ -import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Camera } from "lucide-react"; +import type { GetProfileResponse } from "@/types/my.types"; interface ProfileSectionProps { + profile?: GetProfileResponse; isEditable?: boolean; + onEditClick?: () => void; } -export const ProfileSection = ({ isEditable = false }: ProfileSectionProps) => { - const fileInputRef = useRef(null); - const [imagePreview, setImagePreview] = useState(null); - - const handleImageClick = () => { - if (!isEditable) return; - fileInputRef.current?.click(); - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const reader = new FileReader(); - - reader.onloadend = () => { - if (typeof reader.result === "string") { - setImagePreview(reader.result); - } - }; - reader.readAsDataURL(file); - } - }; +export const ProfileSection = ({ + profile, + onEditClick, +}: ProfileSectionProps) => { + const displayImage = profile?.avatar; + const displayName = profile?.nickname || "이름"; + const displayInfo = profile + ? `${profile.age}세 · ${profile.position?.role || "포지션"}` + : "나이 · 포지션"; return (
-
- {imagePreview ? ( +
+ {displayImage ? ( Profile preview ) : ( - isEditable && +
+ + {displayName.charAt(0)} + +
)}
- {isEditable && ( - - )}
-

이름

-

나이 · 포지션

- {isEditable && ( - - )} +

{displayName}

+

{displayInfo}

+

+ 구독자 {profile?.nSubscribers || 0}명 +

+
); }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..1084119 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0d6741b --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/constants/my.ts b/src/constants/my.ts index 1e89c6c..0312d2b 100644 --- a/src/constants/my.ts +++ b/src/constants/my.ts @@ -1,8 +1,10 @@ export const MENU_ITEMS = [ { id: "posts", label: "작성한 글" }, { id: "history", label: "활동 이력" }, - { id: "likes", label: "좋아요 한 글" }, + { id: "comments", label: "내가 쓴 댓글" }, { id: "bookmarks", label: "북마크" }, + { id: "logout", label: "로그아웃" }, + { id: "delete", label: "계정 삭제", isDestructive: true }, ] as const; export type MenuItemId = (typeof MENU_ITEMS)[number]["id"]; diff --git a/src/constants/profile.ts b/src/constants/profile.ts index ae5f151..df547ca 100644 --- a/src/constants/profile.ts +++ b/src/constants/profile.ts @@ -5,7 +5,7 @@ export const DEVELOPER_TABS = [ }, { value: "posts", - label: "작성한 글 ", + label: "소셜 링크", }, ] as const; diff --git a/src/hooks/queries/useDevelopers.ts b/src/hooks/queries/useDevelopers.ts new file mode 100644 index 0000000..d8b2ce8 --- /dev/null +++ b/src/hooks/queries/useDevelopers.ts @@ -0,0 +1,50 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + fetchDevelopers, + fetchUserProfile, + toggleUserSubscription, +} from "@/lib/api/developers"; +import type { NearbyUser, NearbyUsersParams } from "@/types/developer.types"; +import type { ExploreTabValue } from "@/constants/explore"; + +export const useDevelopers = ( + category: ExploreTabValue, + enabled: boolean = true, + filterParams?: Partial +) => { + return useQuery({ + queryKey: ["developers", category, filterParams], + queryFn: () => fetchDevelopers(category, filterParams), + enabled, + }); +}; + +export const useUserProfile = (nickname: string, enabled: boolean = true) => { + return useQuery({ + queryKey: ["userProfile", nickname], + queryFn: () => fetchUserProfile(nickname), + enabled: enabled && !!nickname, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export const useToggleUserSubscription = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleUserSubscription, + onSuccess: (data, nickname) => { + queryClient.setQueryData(["userProfile", nickname], (oldData: any) => { + if (oldData) { + return { + ...oldData, + nSubscribers: data.nSubscribers, + subscribing: data.subscribing, + }; + } + return oldData; + }); + }, + }); +}; diff --git a/src/hooks/queries/useMy.ts b/src/hooks/queries/useMy.ts new file mode 100644 index 0000000..aa31863 --- /dev/null +++ b/src/hooks/queries/useMy.ts @@ -0,0 +1,100 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchMyProfile, updateMyProfile } from "@/lib/api/my"; +import type { + GetProfileResponse, + UpdateProfileRequest, +} from "@/types/my.types"; + +export const useMyProfile = () => { + return useQuery({ + queryKey: ["myProfile"], + queryFn: fetchMyProfile, + }); +}; + +// 내가 쓴 게시물 +export const useMyPosts = () => { + return useQuery({ + queryKey: ["myPosts"], + queryFn: async () => { + const { fetchMyPosts } = await import("@/lib/api/my"); + return fetchMyPosts(); + }, + }); +}; + +// 지원한 모집글 +export const useMyApplies = () => { + return useQuery({ + queryKey: ["myApplies"], + queryFn: async () => { + const { fetchMyApplies } = await import("@/lib/api/my"); + return fetchMyApplies(); + }, + }); +}; + +// 내가 쓴 댓글 +export const useMyComments = () => { + return useQuery({ + queryKey: ["myComments"], + queryFn: async () => { + const { fetchMyComments } = await import("@/lib/api/my"); + return fetchMyComments(); + }, + }); +}; + +// 북마크한 게시물 +export const useMyBookmarks = () => { + return useQuery({ + queryKey: ["myBookmarks"], + queryFn: async () => { + const { fetchMyBookmarks } = await import("@/lib/api/my"); + return fetchMyBookmarks(); + }, + }); +}; + +export const useUpdateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: UpdateProfileRequest) => updateMyProfile(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["myProfile"] }); + }, + }); +}; + +// 로그아웃 +export const useSignOut = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { signOut } = await import("@/lib/api/my"); + return signOut(); + }, + onSuccess: () => { + queryClient.clear(); + window.location.href = "/login"; + }, + }); +}; + +// 계정 삭제 +export const useDeleteAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { deleteAccount } = await import("@/lib/api/my"); + return deleteAccount(); + }, + onSuccess: () => { + queryClient.clear(); + window.location.href = "/login"; + }, + }); +}; diff --git a/src/hooks/queries/usePosts.ts b/src/hooks/queries/usePosts.ts index c309640..1c4517e 100644 --- a/src/hooks/queries/usePosts.ts +++ b/src/hooks/queries/usePosts.ts @@ -1,22 +1,84 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { fetchPosts, createPost, fetchBoards } from "@/lib/api/posts"; +import { + fetchPosts, + createPost, + fetchBoards, + fetchPostDetail, + searchPosts, + fetchComments, + createComment, + toggleLike, + toggleBookmark, + fetchApplicants, + toggleApply, + deletePost, + updateComment, + deleteComment, +} from "@/lib/api/posts"; import type { UICategory } from "@/constants/board"; +import type { + CommentResponse, + PostDetailResponse, + PostListItem, + PostDetailItem, + PostCommentResponse, + ApplicantResponse, +} from "@/types/post.types"; export const useBoards = () => { return useQuery({ queryKey: ["boards"], queryFn: fetchBoards, - staleTime: 10 * 60 * 1000, // 10분 - gcTime: 30 * 60 * 1000, // 30분 + staleTime: 10 * 60 * 1000, + gcTime: 30 * 60 * 1000, }); }; export const usePosts = (category: UICategory) => { - return useQuery({ + return useQuery({ queryKey: ["posts", category], - queryFn: () => fetchPosts(category), - staleTime: 5 * 60 * 1000, + queryFn: async () => { + const posts = await fetchPosts(category); + return posts.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, + staleTime: 0, + gcTime: 10 * 60 * 1000, + }); +}; + +export const usePostDetail = (postId: string) => { + return useQuery({ + queryKey: ["post", postId], + queryFn: async () => { + const result = await fetchPostDetail(postId); + if (!result) { + throw new Error("게시글 데이터가 없습니다."); + } + return result; + }, + enabled: !!postId, + staleTime: 0, gcTime: 10 * 60 * 1000, + retry: 1, + }); +}; + +export const useSearchPosts = (query: string) => { + return useQuery({ + queryKey: ["search", query], + queryFn: async () => { + const posts = await searchPosts(query); + return posts.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, + enabled: !!query && query.trim().length > 0, + staleTime: 0, + gcTime: 5 * 60 * 1000, }); }; @@ -30,3 +92,181 @@ export const useCreatePost = () => { }, }); }; + +export const useComments = (postId: string) => { + return useQuery({ + queryKey: ["comments", postId], + queryFn: () => fetchComments(postId), + enabled: !!postId, + staleTime: 0, + gcTime: 5 * 60 * 1000, + }); +}; + +export const useToggleLike = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleLike, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + nLikes: data.nLikes, + likeIt: data.likeIt, + }; + } + ); + }, + }); +}; + +export const useToggleBookmark = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleBookmark, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + bookmark: data.bookmark, + }; + } + ); + }, + }); +}; + +export const useCreateComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ postId, content }: { postId: string; content: string }) => + createComment(postId, content), + onSuccess: (data: PostCommentResponse, variables) => { + queryClient.invalidateQueries({ + queryKey: ["comments", variables.postId], + }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.setQueryData( + ["post", variables.postId], + (oldData: PostDetailResponse | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + nComments: data.nComments, + }; + } + ); + }, + }); +}; + +export const useApplicants = (postId: string) => { + return useQuery({ + queryKey: ["applicants", postId], + queryFn: () => fetchApplicants(postId), + enabled: !!postId, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); +}; + +export const useToggleApply = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: toggleApply, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ + queryKey: ["applicants", variables.toString()], + }); + + queryClient.setQueryData( + ["post", variables.toString()], + (oldData: PostDetailItem | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + applied: data.applied, + nApplicants: data.applied + ? (oldData.nApplicants || 0) + 1 + : Math.max((oldData.nApplicants || 0) - 1, 0), + }; + } + ); + + queryClient.setQueriesData( + { queryKey: ["posts"] }, + (oldData: PostListItem[] | undefined) => { + if (!oldData) return oldData; + return oldData.map((post) => { + if (post.id === variables) { + return { + ...post, + nApplicants: data.applied + ? (post.nApplicants || 0) + 1 + : Math.max((post.nApplicants || 0) - 1, 0), + }; + } + return post; + }); + } + ); + }, + }); +}; + +export const useDeletePost = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deletePost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }, + }); +}; + +export const useUpdateComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + commentId, + content, + }: { + commentId: number; + content: string; + }) => updateComment(commentId, { content }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comments"] }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + }, + }); +}; + +export const useDeleteComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteComment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comments"] }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + }, + }); +}; diff --git a/src/hooks/useAddPostModal.ts b/src/hooks/useAddPostModal.ts index f4f7d90..22ff88a 100644 --- a/src/hooks/useAddPostModal.ts +++ b/src/hooks/useAddPostModal.ts @@ -7,7 +7,9 @@ interface AddPostModalState { description: string; recruitCount: number; position: number | null; + selectedPositionIds: number[]; selectedStackIds: number[]; + location: { lat: number; lng: number } | null; } interface AddPostModalActions { @@ -17,7 +19,9 @@ interface AddPostModalActions { setDescription: (description: string) => void; setRecruitCount: (count: number) => void; setPosition: (position: number | null) => void; + setSelectedPositionIds: (positionIds: number[]) => void; setSelectedStackIds: (stackIds: number[]) => void; + setLocation: (location: { lat: number; lng: number } | null) => void; reset: () => void; } @@ -30,7 +34,9 @@ export const useAddPostModal = (): AddPostModalState & description: "", recruitCount: 1, position: null, + selectedPositionIds: [], selectedStackIds: [], + location: null, }); const setTitle = useCallback((title: string) => { @@ -57,10 +63,24 @@ export const useAddPostModal = (): AddPostModalState & setState((prev) => ({ ...prev, position })); }, []); + const setSelectedPositionIds = useCallback( + (selectedPositionIds: number[]) => { + setState((prev) => ({ ...prev, selectedPositionIds })); + }, + [] + ); + const setSelectedStackIds = useCallback((selectedStackIds: number[]) => { setState((prev) => ({ ...prev, selectedStackIds })); }, []); + const setLocation = useCallback( + (location: { lat: number; lng: number } | null) => { + setState((prev) => ({ ...prev, location })); + }, + [] + ); + const reset = useCallback(() => { setState({ title: "", @@ -69,7 +89,9 @@ export const useAddPostModal = (): AddPostModalState & description: "", recruitCount: 1, position: null, + selectedPositionIds: [], selectedStackIds: [], + location: null, }); }, []); @@ -84,7 +106,9 @@ export const useAddPostModal = (): AddPostModalState & setDescription, setRecruitCount, setPosition, + setSelectedPositionIds, setSelectedStackIds, + setLocation, reset, isRecruitBoard, }; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..fd87c63 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/usePost.ts b/src/hooks/usePost.ts deleted file mode 100644 index 6b428e4..0000000 --- a/src/hooks/usePost.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Post, Comment } from "@/types/community.types"; - -const fetchPost = async (postId: string): Promise => { - const res = await fetch(`/api/posts/${postId}`); - if (!res.ok) throw new Error("게시글을 불러오는데 실패했습니다."); - return res.json(); -}; - -const fetchComments = async (postId: string): Promise => { - const res = await fetch(`/api/posts/${postId}/comments`); - if (!res.ok) throw new Error("댓글을 불러오는데 실패했습니다."); - return res.json(); -}; - -export function usePost(postId: string) { - return useQuery({ - queryKey: ["post", postId], - queryFn: () => fetchPost(postId), - enabled: !!postId, - }); -} - -const togglePostLike = async (postId: string) => { - const res = await fetch(`/api/posts/${postId}/like`, { method: "PUT" }); - if (!res.ok) throw new Error("좋아요 클릭에 실패했습니다."); - return res.json(); -}; - -export function useTogglePostLike() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: togglePostLike, - onSuccess: (_data, postId) => { - queryClient.invalidateQueries({ queryKey: ["post", postId] }); - }, - }); -} - -const addComment = async ({ - postId, - content, -}: { - postId: string; - content: string; -}) => { - const res = await fetch(`/api/posts/${postId}/comments`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!res.ok) throw new Error("댓글 작성에 실패했습니다."); - return res.json(); -}; - -export function useAddComment() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: addComment, - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ["post", variables.postId] }); - }, - }); -} - -const toggleCommentLike = async (commentId: number) => { - const res = await fetch(`/api/comments/${commentId}/like`, { method: "PUT" }); - if (!res.ok) throw new Error("댓글 좋아요 클릭에 실패했습니다."); - return res.json(); -}; - -export function useToggleCommentLike() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: toggleCommentLike, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["post"] }); - }, - }); -} - -const toggleReplyLike = async (replyId: number) => { - const res = await fetch(`/api/replies/${replyId}/like`, { method: "PUT" }); - if (!res.ok) throw new Error("답글 좋아요 클릭에 실패했습니다."); - return res.json(); -}; - -export function useToggleReplyLike() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: toggleReplyLike, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["post"] }); - }, - }); -} diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..a888c5c --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { useSearchPosts } from "@/hooks/queries/usePosts"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + loadRecentSearches, + addRecentSearch, + removeRecentSearch as removeSearch, + isValidSearchQuery, +} from "@/lib/search"; + +export const useSearch = () => { + const [query, setQuery] = useState(""); + const [recentSearches, setRecentSearches] = useState([]); + const [manualSearch, setManualSearch] = useState(""); + + const debouncedQuery = useDebounce(query, 1000); + const searchQuery = manualSearch || debouncedQuery; + + const { + data: searchResults = [], + isLoading, + isError, + error, + } = useSearchPosts(searchQuery); + + useEffect(() => { + setRecentSearches(loadRecentSearches()); + }, []); + + const saveRecentSearch = (searchTerm: string) => { + setRecentSearches((prev) => addRecentSearch(searchTerm, prev)); + }; + + const handleRemoveRecentSearch = (index: number) => { + setRecentSearches((prev) => removeSearch(index, prev)); + }; + + useEffect(() => { + if (isValidSearchQuery(debouncedQuery)) { + saveRecentSearch(debouncedQuery.trim()); + if (manualSearch) { + setManualSearch(""); + } + } + }, [debouncedQuery, manualSearch]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (query.trim()) { + setManualSearch(query.trim()); + saveRecentSearch(query.trim()); + } + }; + + const handleRecentSearchClick = (search: string) => { + setQuery(search); + setManualSearch(search); + saveRecentSearch(search); + }; + + const hasSearchResults = searchQuery.trim().length > 0; + const hasQueryInput = query.trim().length > 0; + const isDebouncing = + hasQueryInput && query.trim() !== debouncedQuery.trim() && !manualSearch; + + return { + query, + recentSearches, + searchResults, + isLoading, + isError, + error, + hasSearchResults, + hasQueryInput, + isDebouncing, + searchQuery: hasSearchResults ? searchQuery : query, + setQuery, + handleSubmit, + handleRecentSearchClick, + removeRecentSearch: handleRemoveRecentSearch, + }; +}; diff --git a/src/lib/api/developers.ts b/src/lib/api/developers.ts index bc7a53e..85b561e 100644 --- a/src/lib/api/developers.ts +++ b/src/lib/api/developers.ts @@ -1,15 +1,113 @@ -import type { Developer } from "@/types/developer.types"; +import type { NearbyUser, NearbyUsersParams } from "@/types/developer.types"; import type { ExploreTabValue } from "@/constants/explore"; -import { createApiUrl } from "./config"; +import { createApiUrl, createAuthHeaders } from "./config"; export const fetchDevelopers = async ( - category: ExploreTabValue -): Promise => { - const response = await fetch( - `${createApiUrl("/developers")}?category=${category}` - ); + category: ExploreTabValue, + filterParams?: Partial +): Promise => { + let url: string; + + switch (category) { + case "nearby": + const searchParams = new URLSearchParams(); + const radius = filterParams?.radius || 10; + searchParams.append("radius", radius.toString()); + + if (filterParams?.positionIds?.length) { + filterParams.positionIds.forEach((id) => { + searchParams.append("positionIds", id.toString()); + }); + } + if (filterParams?.techIds?.length) { + filterParams.techIds.forEach((id) => { + searchParams.append("techIds", id.toString()); + }); + } + if (filterParams?.interestIds?.length) { + filterParams.interestIds.forEach((id) => { + searchParams.append("interestIds", id.toString()); + }); + } + + url = `${createApiUrl("/users/search/near")}?${searchParams.toString()}`; + if (filterParams?.age) { + const cleanAge = filterParams.age.trim(); + url += `&age=${cleanAge}`; + } + if (filterParams?.experience) { + const cleanExperience = filterParams.experience.trim(); + url += `&experience=${cleanExperience}`; + } + break; + case "popular": + url = `${createApiUrl("/users/search/hot")}`; + break; + case "favorite": + url = `${createApiUrl("/users/search/subscriptions")}`; + break; + default: + throw new Error(`알 수 없는 카테고리입니다: ${category}`); + } + + const response = await fetch(url, { + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error( + `개발자 목록을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data.results || []; +}; + +export const fetchUserProfile = async (nickname: string): Promise => { + const url = `${createApiUrl(`/users/${nickname}`)}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + headers, + }); + + console.log("📡 User Profile Response:", { + nickname, + status: response.status, + statusText: response.statusText, + }); + if (!response.ok) { - throw new Error("개발자 목록을 불러오는 데 실패했습니다."); + throw new Error( + `프로필을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); } - return response.json(); + + const data = await response.json(); + return data; +}; + +export const toggleUserSubscription = async ( + nickname: string +): Promise<{ + nSubscribers: number; + subscribing: boolean; +}> => { + const url = `${createApiUrl(`/users/${nickname}/subscriptions`)}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + method: "PUT", + headers, + }); + + if (!response.ok) { + throw new Error( + `구독 상태 변경에 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data; }; diff --git a/src/lib/api/my.ts b/src/lib/api/my.ts new file mode 100644 index 0000000..22bd761 --- /dev/null +++ b/src/lib/api/my.ts @@ -0,0 +1,152 @@ +import type { + GetProfileResponse, + UpdateProfileRequest, +} from "@/types/my.types"; +import { createApiUrl, createAuthHeaders } from "./config"; + +export const fetchMyProfile = async (): Promise => { + const url = `${createApiUrl("/users")}`; + const headers = createAuthHeaders(); + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `프로필을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + return data; +}; + +export const updateMyProfile = async ( + data: UpdateProfileRequest +): Promise => { + const url = `${createApiUrl("/users")}`; + const headers = createAuthHeaders(); + const response = await fetch(url, { + method: "PATCH", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error( + `프로필 수정에 실패했습니다: ${response.status} ${response.statusText}` + ); + } +}; + +// 내가 쓴 게시물 검색 +export const fetchMyPosts = async (): Promise => { + const url = `${createApiUrl("/posts/users")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `내 게시물을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data.results || []; +}; + +// 지원한 모집글 검색 +export const fetchMyApplies = async (): Promise => { + const url = `${createApiUrl("/posts/applies")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `지원 내역을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data.results || []; +}; + +// 내가 쓴 댓글 조회 +export const fetchMyComments = async (): Promise => { + const url = `${createApiUrl("/posts/comments/users")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `내 댓글을 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data.results || []; +}; + +// 북마크한 게시물 검색 +export const fetchMyBookmarks = async (): Promise => { + const url = `${createApiUrl("/posts/bookmarks")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + `북마크를 불러오는 데 실패했습니다: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data.results || []; +}; + +// 로그아웃 +export const signOut = async (): Promise => { + const url = `${createApiUrl("/auth/sign-out")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error( + `로그아웃에 실패했습니다: ${response.status} ${response.statusText}` + ); + } +}; + +// 계정 삭제 +export const deleteAccount = async (): Promise => { + const url = `${createApiUrl("/users")}`; + const headers = createAuthHeaders(); + + const response = await fetch(url, { + method: "DELETE", + headers, + }); + + if (!response.ok) { + throw new Error( + `계정 삭제에 실패했습니다: ${response.status} ${response.statusText}` + ); + } +}; diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index c772302..94bd12b 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -1,4 +1,15 @@ -import type { Post } from "@/types/post.types"; +import type { + CommentResponse, + PostDetailResponse, + CreatePostRequest, + LikeToggleResponse, + BookmarkToggleResponse, + PostCommentResponse, + ApplicantResponse, + ApplyToggleResponse, + UpdatePostRequest, + UpdateCommentRequest, +} from "@/types/post.types"; import type { UICategory } from "@/constants/board"; import { createApiUrl, createAuthHeaders } from "./config"; @@ -7,6 +18,7 @@ export interface Board { value: string; } +//게시판 카테고리 조회 export const fetchBoards = async (): Promise => { const response = await fetch(`${createApiUrl("/boards")}`, { headers: createAuthHeaders(), @@ -20,7 +32,10 @@ export const fetchBoards = async (): Promise => { return data.results || []; }; -export const fetchPosts = async (category: UICategory): Promise => { +//게시글 목록 조회 +export const fetchPosts = async ( + category: UICategory +): Promise => { const url = category === "all" ? `${createApiUrl("/posts/search")}` @@ -38,21 +53,15 @@ export const fetchPosts = async (category: UICategory): Promise => { return data.results || []; }; -export const createPost = async (data: { - title: string; - content: string; - boardId: number; -}): Promise => { +//게시글 생성 +export const createPost = async (data: CreatePostRequest): Promise => { const response = await fetch(`${createApiUrl(`/posts/${data.boardId}`)}`, { method: "POST", headers: createAuthHeaders(), body: JSON.stringify({ title: data.title, content: data.content, - location: { - lng: 127.0, - lat: 37.5, - }, + location: data.location, }), }); @@ -60,3 +69,212 @@ export const createPost = async (data: { throw new Error("게시글 작성에 실패했습니다."); } }; + +//게시글 상세 조회 +export const fetchPostDetail = async ( + postId: string +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/details/${postId}`)}`, { + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("게시글을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + return data; +}; + +// 댓글 조회 +export const fetchComments = async ( + postId: string +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/comments`)}`, { + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("댓글 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; + +// 댓글 생성 +export const createComment = async ( + postId: string, + content: string +): Promise => { + const url = `${createApiUrl(`/posts/${postId}/comments`)}`; + const headers = createAuthHeaders(); + const body = JSON.stringify({ content }); + + const response = await fetch(url, { + method: "POST", + headers, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `댓글 작성에 실패했습니다: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + const data = await response.json(); + return data; +}; + +// 게시글 검색 +export const searchPosts = async ( + query: string +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/search?keyword=${encodeURIComponent(query)}`)}`, + { + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("게시글 검색에 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; + +//좋아용용 +export const toggleLike = async ( + postId: number +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/likes`)}`, { + method: "PUT", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("좋아요 처리에 실패했습니다."); + } + + return response.json(); +}; + +//북마크 +export const toggleBookmark = async ( + postId: number +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/${postId}/bookmarks`)}`, + { + method: "PUT", + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("북마크 처리에 실패했습니다."); + } + + return response.json(); +}; + +// 지원자 목록 조회 +export const fetchApplicants = async ( + postId: string +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/${postId}/applicants`)}`, + { + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("지원자 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + return data.results || []; +}; + +// 지원 상태 반전 +export const toggleApply = async ( + postId: number +): Promise => { + const response = await fetch(`${createApiUrl(`/posts/${postId}/applies`)}`, { + method: "PUT", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("지원 처리에 실패했습니다."); + } + + return response.json(); +}; + +// // 게시글 수정 +// export const updatePost = async ( +// postId: number, +// data: UpdatePostRequest +// ): Promise => { +// const response = await fetch(`${createApiUrl(`/posts/posts/${postId}`)}`, { +// method: "PATCH", +// headers: createAuthHeaders(), +// body: JSON.stringify(data), +// }); + +// if (!response.ok) { +// throw new Error("게시글 수정에 실패했습니다."); +// } +// }; + +// 게시글 삭제 +export const deletePost = async (postId: number): Promise => { + const response = await fetch(`${createApiUrl(`/posts/posts/${postId}`)}`, { + method: "DELETE", + headers: createAuthHeaders(), + }); + + if (!response.ok) { + throw new Error("게시글 삭제에 실패했습니다."); + } +}; + +// 댓글 수정 +export const updateComment = async ( + commentId: number, + data: UpdateCommentRequest +): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/comments/${commentId}`)}`, + { + method: "PATCH", + headers: createAuthHeaders(), + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + throw new Error("댓글 수정에 실패했습니다."); + } +}; + +// 댓글 삭제 +export const deleteComment = async (commentId: number): Promise => { + const response = await fetch( + `${createApiUrl(`/posts/comments/${commentId}`)}`, + { + method: "DELETE", + headers: createAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error("댓글 삭제에 실패했습니다."); + } +}; diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..cb844ce --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,50 @@ +export const formatRelativeTime = (dateString: string): string => { + const now = new Date(); + const date = new Date(dateString); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return `${diffInSeconds}초 전`; + } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) { + return `${diffInMinutes}분 전`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours}시간 전`; + } + + return formatAbsoluteTime(dateString); +}; + +export const formatAbsoluteTime = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatPostDetailDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatCommentDate = (dateString: string): string => { + return formatRelativeTime(dateString); +}; + +export const formatPostListDate = (dateString: string): string => { + return formatRelativeTime(dateString); +}; diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..61d8ed1 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,46 @@ +const RECENT_SEARCHES_KEY = "recent_searches"; + +export const loadRecentSearches = (): string[] => { + try { + const saved = localStorage.getItem(RECENT_SEARCHES_KEY); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("최근 검색어 로드 실패:", error); + return []; + } +}; + +export const saveRecentSearches = (searches: string[]): void => { + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(searches)); + } catch (error) { + console.error("최근 검색어 저장 실패:", error); + } +}; + +export const addRecentSearch = ( + searchTerm: string, + currentSearches: string[] +): string[] => { + const trimmedSearch = searchTerm.trim(); + if (!trimmedSearch) return currentSearches; + + const filtered = currentSearches.filter((item) => item !== trimmedSearch); + const updated = [trimmedSearch, ...filtered].slice(0, 10); + + saveRecentSearches(updated); + return updated; +}; + +export const removeRecentSearch = ( + index: number, + currentSearches: string[] +): string[] => { + const updated = currentSearches.filter((_, i) => i !== index); + saveRecentSearches(updated); + return updated; +}; + +export const isValidSearchQuery = (query: string): boolean => { + return query.trim().length >= 2; +}; diff --git a/src/main.tsx b/src/main.tsx index d13da4a..be4ba9d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,10 +7,15 @@ import App from "./App.tsx"; import { LoginPage } from "./pages/auth/LoginPage.tsx"; import { BoardPage } from "./pages/home/index.tsx"; import { MyPage } from "./pages/my/index.tsx"; +import { EditProfilePage } from "./pages/my/edit/index.tsx"; +import { MyPostsPage } from "./pages/my/posts.tsx"; +import { MyHistoryPage } from "./pages/my/history.tsx"; +import { MyCommentsPage } from "./pages/my/comments.tsx"; +import { MyBookmarksPage } from "./pages/my/bookmarks.tsx"; import { DeveloperPage } from "./pages/developer/index.tsx"; import { ExplorePage } from "./pages/explore/index.tsx"; import { OnboardingPage } from "./pages/onboarding/index.tsx"; -import { PostDetailPage } from "./pages/community/PostDetailPage.tsx"; +import { PostDetailPage } from "./pages/community/index.tsx"; import AuthCallbackPage from "./pages/auth/AuthCallbackPage.tsx"; import ChatPage from "./pages/chat/index.tsx"; import SearchPage from "./pages/search/index.tsx"; @@ -58,7 +63,27 @@ const router = createBrowserRouter([ element: , }, { - path: "developer/:id", + path: "my/edit", + element: , + }, + { + path: "my/posts", + element: , + }, + { + path: "my/history", + element: , + }, + { + path: "my/comments", + element: , + }, + { + path: "my/bookmarks", + element: , + }, + { + path: "developer/:nickname", element: , }, { diff --git a/src/pages/community/PostDetailPage.tsx b/src/pages/community/PostDetailPage.tsx deleted file mode 100644 index d940d59..0000000 --- a/src/pages/community/PostDetailPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useParams, useNavigate } from "react-router-dom"; -import { PostContent } from "./__components/PostContent"; -import { CommentSection } from "./__components/CommentSection"; -import { CommentForm } from "./__components/CommentForm"; -import Header from "@/components/layout/Header"; -import { ArrowLeft, Bookmark } from "lucide-react"; -import { - usePost, - useTogglePostLike, - useAddComment, - useToggleCommentLike, - useToggleReplyLike, -} from "@/hooks/usePost"; - -export function PostDetailPage() { - const { postId } = useParams<{ postId: string }>(); - const navigate = useNavigate(); - - if (!postId) { - return
유효하지 않은 게시글 ID입니다.
; - } - - const { data: post, isLoading, isError, error } = usePost(postId); - const { mutate: toggleLike, isPending: isLikePending } = useTogglePostLike(); - const { mutate: addNewComment, isPending: isAddingComment } = useAddComment(); - const { mutate: toggleCommentLike } = useToggleCommentLike(); - const { mutate: toggleReplyLike } = useToggleReplyLike(); - - if (isLoading) { - return
로딩 중...
; - } - if (isError) { - return
에러 발생: {error.message}
; - } - if (!post) { - return
게시글이 없습니다.
; - } - - const handleToggleCommentLike = (commentId: number) => { - toggleCommentLike(commentId); - }; - - const handleToggleReplyLike = (replyId: number) => { - toggleReplyLike(replyId); - }; - - const handleBackClick = () => { - navigate(-1); - }; - - const handleBookmarkClick = () => { - console.log("북마크 클릭"); - }; - - return ( -
-
} - rightIcon={} - onLeftClick={handleBackClick} - onRightClick={handleBookmarkClick} - /> -
-
-
- toggleLike(postId)} - isLikePending={isLikePending} - /> - -
- addNewComment({ postId, content })} - isPending={isAddingComment} - /> -
-
-
- ); -} diff --git a/src/pages/community/__components/ApplicantSection.tsx b/src/pages/community/__components/ApplicantSection.tsx new file mode 100644 index 0000000..ea934fb --- /dev/null +++ b/src/pages/community/__components/ApplicantSection.tsx @@ -0,0 +1,43 @@ +import type { ApplicantResponse } from "@/types/post.types"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useNavigate } from "react-router-dom"; + +interface ApplicantSectionProps { + applicants: ApplicantResponse[]; +} + +export function ApplicantSection({ applicants }: ApplicantSectionProps) { + const navigate = useNavigate(); + const totalApplicants = applicants.length; + + const handleApplicantClick = (nickname: string) => { + navigate(`/developer/${nickname}`); + }; + + return ( +
+

지원자 {totalApplicants}

+ +
+ {applicants.length > 0 ? ( + applicants.map((applicant, index) => ( +
handleApplicantClick(applicant.nickname)}> + + + + {applicant.nickname.charAt(0)} + + + {applicant.nickname} +
+ )) + ) : ( +

아직 지원자가 없습니다.

+ )} +
+
+ ); +} diff --git a/src/pages/community/__components/CommentForm.tsx b/src/pages/community/__components/CommentForm.tsx index 92c2d48..4d32d1c 100644 --- a/src/pages/community/__components/CommentForm.tsx +++ b/src/pages/community/__components/CommentForm.tsx @@ -5,10 +5,19 @@ import { Input } from "@/components/ui/input"; interface CommentFormProps { onSubmit: (content: string) => void; isPending: boolean; + editingCommentId?: number | null; + initialContent?: string; + onCancel?: () => void; } -export function CommentForm({ onSubmit, isPending }: CommentFormProps) { - const [comment, setComment] = useState(""); +export function CommentForm({ + onSubmit, + isPending, + editingCommentId, + initialContent = "", + onCancel, +}: CommentFormProps) { + const [comment, setComment] = useState(initialContent); const handleSubmit = () => { if (!comment.trim()) return; @@ -16,21 +25,44 @@ export function CommentForm({ onSubmit, isPending }: CommentFormProps) { setComment(""); }; + const handleCancel = () => { + setComment(""); + onCancel?.(); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + return (
setComment(e.target.value)} + onKeyPress={handleKeyPress} className="flex-1 bg-brand-surface border-none rounded-md px-4 py-2 resize-none no-scrollbar text-sm text-white ring-0" disabled={isPending} /> + {editingCommentId && onCancel && ( + + )}
); diff --git a/src/pages/community/__components/CommentItem.tsx b/src/pages/community/__components/CommentItem.tsx index 238a431..04126e4 100644 --- a/src/pages/community/__components/CommentItem.tsx +++ b/src/pages/community/__components/CommentItem.tsx @@ -1,68 +1,63 @@ -import type { Comment } from "@/types/community.types"; +import type { CommentResponse } from "@/types/post.types"; import { Button } from "@/components/ui/button"; -import { Heart, MessageCircle } from "lucide-react"; -import { ReplyItem } from "./ReplyItem"; +import { MoreHorizontal } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { formatCommentDate } from "@/lib/date"; interface CommentItemProps { - comment: Comment; - onToggleLike: (commentId: number) => void; - onToggleReplyLike: (replyId: number) => void; + comment: CommentResponse; + onEditClick?: () => void; + onDeleteClick?: () => void; } export function CommentItem({ comment, - onToggleLike, - onToggleReplyLike, + onEditClick, + onDeleteClick, }: CommentItemProps) { return ( -
-
-

- {comment.content} -

+
+

+ {comment.content} +

-
-
- {comment.author.name} - - {comment.createdAt} -
- -
- - -
+
+
+ {comment.author.nickname} + + {formatCommentDate(comment.createdAt)}
-
- {comment.replies && comment.replies.length > 0 && ( -
- {comment.replies.map((reply) => ( - - ))} -
- )} + {comment.editable && ( + + + + + + + 수정 + + + 삭제 + + + + )} +
); } diff --git a/src/pages/community/__components/CommentSection.tsx b/src/pages/community/__components/CommentSection.tsx index d6a64be..2cf3fcc 100644 --- a/src/pages/community/__components/CommentSection.tsx +++ b/src/pages/community/__components/CommentSection.tsx @@ -1,16 +1,16 @@ -import type { Comment } from "@/types/community.types"; +import type { CommentResponse } from "@/types/post.types"; import { CommentItem } from "./CommentItem"; interface CommentSectionProps { - comments: Comment[]; - onToggleCommentLike: (commentId: number) => void; - onToggleReplyLike: (replyId: number) => void; + comments: CommentResponse[]; + onEditComment?: (commentId: number) => void; + onDeleteComment?: (commentId: number) => void; } export function CommentSection({ comments, - onToggleCommentLike, - onToggleReplyLike, + onEditComment, + onDeleteComment, }: CommentSectionProps) { const totalComments = comments.length; @@ -19,14 +19,17 @@ export function CommentSection({

댓글 {totalComments}

- {comments.map((comment) => ( - - ))} + {comments + .slice() + .reverse() + .map((comment) => ( + onEditComment?.(comment.id)} + onDeleteClick={() => onDeleteComment?.(comment.id)} + /> + ))}
); diff --git a/src/pages/community/__components/PostContent.tsx b/src/pages/community/__components/PostContent.tsx index c261db6..bcbffd6 100644 --- a/src/pages/community/__components/PostContent.tsx +++ b/src/pages/community/__components/PostContent.tsx @@ -1,27 +1,40 @@ -import type { Post } from "@/types/community.types"; +import type { PostDetailItem } from "@/types/post.types"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; -import { Heart } from "lucide-react"; +import { Heart, MoreHorizontal } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface PostContentProps { - post: Post; + post: PostDetailItem; onLikeClick: () => void; isLikePending: boolean; + onEditClick?: () => void; + onDeleteClick?: () => void; } -export function PostContent({ post, onLikeClick }: PostContentProps) { +export function PostContent({ + post, + onLikeClick, + onEditClick, + onDeleteClick, +}: PostContentProps) { return (
- {post.category} + {post.board.value}

{post.title}

- {post.author.name} + {post.author.nickname} {post.createdAt}
@@ -33,22 +46,49 @@ export function PostContent({ post, onLikeClick }: PostContentProps) {
{post.content}
-
- +
+
+ - - {post.likeCount} - + + {post.nLikes} + +
+ + {post.editable && ( + + + + + + + 수정 + + + 삭제 + + + + )}
diff --git a/src/pages/community/index.tsx b/src/pages/community/index.tsx new file mode 100644 index 0000000..ed40976 --- /dev/null +++ b/src/pages/community/index.tsx @@ -0,0 +1,391 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useState } from "react"; +import Header from "@/components/layout/Header"; +import { + ArrowLeft, + Bookmark, + Heart, + MessageCircle, + Users, + Trash2, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + usePostDetail, + useComments, + useToggleLike, + useToggleBookmark, + useCreateComment, + useApplicants, + useToggleApply, + useDeletePost, + useUpdateComment, + useDeleteComment, +} from "@/hooks/queries/usePosts"; +import { CommentForm } from "./__components/CommentForm"; +import { CommentSection } from "./__components/CommentSection"; +import { ApplicantSection } from "./__components/ApplicantSection"; + +import { formatPostDetailDate } from "@/lib/date"; + +export function PostDetailPage() { + const { postId } = useParams<{ postId: string }>(); + const navigate = useNavigate(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showCommentDeleteDialog, setShowCommentDeleteDialog] = useState(false); + const [commentToDelete, setCommentToDelete] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + + if (!postId) { + return
유효하지 않은 게시글 ID입니다.
; + } + + const { data: post, isLoading, isError, error } = usePostDetail(postId); + const { + data: comments = [], + isLoading: isCommentsLoading, + isError: isCommentsError, + } = useComments(postId); + + const { + data: applicants = [], + isLoading: isApplicantsLoading, + isError: isApplicantsError, + } = useApplicants(postId); + + const toggleLikeMutation = useToggleLike(); + const toggleBookmarkMutation = useToggleBookmark(); + const createCommentMutation = useCreateComment(); + const toggleApplyMutation = useToggleApply(); + const deletePostMutation = useDeletePost(); + const updateCommentMutation = useUpdateComment(); + const deleteCommentMutation = useDeleteComment(); + + if (isLoading) { + return
로딩 중...
; + } + if (isError) { + return
에러 발생: {error?.message}
; + } + if (!post) { + return
게시글이 없습니다.
; + } + + const handleBackClick = () => { + navigate(-1); + }; + + const handleLikeClick = async () => { + if (!post) return; + try { + await toggleLikeMutation.mutateAsync(post.id); + } catch (error) { + console.error("좋아요 처리 실패:", error); + } + }; + + const handleBookmarkClick = async () => { + if (!post) return; + try { + await toggleBookmarkMutation.mutateAsync(post.id); + } catch (error) { + console.error("북마크 처리 실패:", error); + } + }; + + const handleCommentSubmit = async (content: string) => { + if (!postId) return; + try { + await createCommentMutation.mutateAsync({ postId, content }); + } catch (error) { + console.error("댓글 작성 실패:", error); + alert("댓글 작성에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleApplyClick = async () => { + if (!postId) return; + try { + await toggleApplyMutation.mutateAsync(parseInt(postId)); + } catch (error) { + console.error("지원 처리 실패:", error); + alert("지원 처리에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleDeletePost = async () => { + if (!post) return; + try { + await deletePostMutation.mutateAsync(post.id); + setShowDeleteDialog(false); + navigate(-1); + } catch (error) { + console.error("게시글 삭제 실패:", error); + alert("게시글 삭제에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleEditComment = (commentId: number) => { + setEditingCommentId(commentId); + }; + + const handleCommentEditSubmit = async (content: string) => { + if (!editingCommentId) return; + try { + await updateCommentMutation.mutateAsync({ + commentId: editingCommentId, + content, + }); + setEditingCommentId(null); + } catch (error) { + console.error("댓글 수정 실패:", error); + alert("댓글 수정에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleCommentEditCancel = () => { + setEditingCommentId(null); + }; + + const handleDeleteComment = async () => { + if (!commentToDelete) return; + try { + await deleteCommentMutation.mutateAsync(commentToDelete); + setShowCommentDeleteDialog(false); + setCommentToDelete(null); + } catch (error) { + console.error("댓글 삭제 실패:", error); + alert("댓글 삭제에 실패했습니다. 다시 시도해주세요."); + } + }; + + const openCommentDeleteDialog = (commentId: number) => { + setCommentToDelete(commentId); + setShowCommentDeleteDialog(true); + }; + + return ( +
+
} + rightIcon={ + post.editable ? ( + + ) : ( + + ) + } + onLeftClick={handleBackClick} + onRightClick={ + post.editable ? () => setShowDeleteDialog(true) : handleBookmarkClick + } + /> +
+
+
+ + {post.board.value} + +

{post.title}

+
+ {post.author.nickname} + + {formatPostDetailDate(post.createdAt)} +
+
+ + + +
+
+ {post.content ? ( + <>{post.content} + ) : ( +

게시글 내용이 없습니다.

+ )} +
+ +
+
+ + {/* 일반 게시판만 댓글 개수 표시 */} + {!post.board.isRecruit && ( +
+ + {post.nComments} +
+ )} + {/* 모집 게시판은 지원자 개수 표시 */} + {post.board.isRecruit && ( +
+ + {post.nApplicants || applicants.length} +
+ )} +
+
+
+ + + {/* 일반 게시판: 댓글 표시 */} + {!post.board.isRecruit && ( + <> + {isCommentsLoading ? ( +
+ 댓글을 불러오는 중... +
+ ) : isCommentsError ? ( +
+ 댓글을 불러오는 데 실패했습니다. +
+ ) : ( + + )} + + )} + + {/* 모집 게시판: 지원자 목록 표시 */} + {post.board.isRecruit && ( + <> + {isApplicantsLoading ? ( +
+ 지원자 목록을 불러오는 중... +
+ ) : isApplicantsError ? ( +
+ 지원자 목록을 불러오는 데 실패했습니다. +
+ ) : ( + + )} + + )} +
+
+ + {/* 자유, 질문 게시판만 댓글 작성 가능 */} + {!post.board.isRecruit && ( +
+ c.id === editingCommentId)?.content || "" + : "" + } + onCancel={handleCommentEditCancel} + /> +
+ )} + + {/* 모각코, 프로젝트 게시판은 지원 버튼 표시 */} + {post.board.isRecruit && ( +
+ +
+ )} + + + + + 게시글 삭제 + + 이 게시글을 삭제하시겠습니까?

이 작업은 되돌릴 수 + 없습니다. +
+
+ + + 취소 + + + 삭제 + + +
+
+ + {/* 댓글 삭제 확인 다이얼로그 */} + + + + 댓글 삭제 + + 이 댓글을 삭제하시겠습니까?

이 작업은 되돌릴 수 없습니다. +
+
+ + + 취소 + + + 삭제 + + +
+
+
+ ); +} diff --git a/src/pages/developer/_components/Links.tsx b/src/pages/developer/_components/Links.tsx new file mode 100644 index 0000000..e15216b --- /dev/null +++ b/src/pages/developer/_components/Links.tsx @@ -0,0 +1,66 @@ +import { Github, Mail, Linkedin, Globe } from "lucide-react"; +import type { GetProfileResponse } from "@/types/my.types"; + +interface SocialLinksProps { + userProfile?: GetProfileResponse; +} + +const SocialLinks = ({ userProfile }: SocialLinksProps) => { + if (!userProfile) { + return
소셜 링크 정보가 없습니다.
; + } + + return ( +
+
+

소셜 링크

+ +
+
+ ); +}; + +export default SocialLinks; diff --git a/src/pages/developer/_components/Posts.tsx b/src/pages/developer/_components/Posts.tsx deleted file mode 100644 index 9ec48d4..0000000 --- a/src/pages/developer/_components/Posts.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Posts = () => { - return

작성글

; -}; - -export default Posts; diff --git a/src/pages/developer/_components/Profile.tsx b/src/pages/developer/_components/Profile.tsx index 8442bc3..932c5ca 100644 --- a/src/pages/developer/_components/Profile.tsx +++ b/src/pages/developer/_components/Profile.tsx @@ -1,5 +1,68 @@ -const Profile = () => { - return

프로필

; +import { Badge } from "@/components/ui/badge"; +import type { GetProfileResponse } from "@/types/my.types"; + +interface ProfileProps { + userProfile?: GetProfileResponse; +} + +const Profile = ({ userProfile }: ProfileProps) => { + if (!userProfile) { + return
프로필 정보가 없습니다.
; + } + + return ( +
+ {/* Bio */} + {userProfile.bio && ( +
+

소개

+

+ {userProfile.bio} +

+
+ )} + + {/* 경력 */} +
+

경력

+

{userProfile.experience}년

+
+ + {/* 테크스택 */} + {userProfile.techStack && userProfile.techStack.length > 0 && ( +
+

기술 스택

+
+ {userProfile.techStack.map((tech, index) => ( + + {tech.value} + + ))} +
+
+ )} + + {/* 관심사 */} + {userProfile.interests && userProfile.interests.length > 0 && ( +
+

관심사

+
+ {userProfile.interests.map((interest, index) => ( + + {interest.value} + + ))} +
+
+ )} +
+ ); }; export default Profile; diff --git a/src/pages/developer/index.tsx b/src/pages/developer/index.tsx index 73c01ac..74f3a3f 100644 --- a/src/pages/developer/index.tsx +++ b/src/pages/developer/index.tsx @@ -1,27 +1,107 @@ import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; import { AppTabs } from "@/components/common/AppTabs"; import { ProfileSection } from "@/components/profile/ProfileSection.tsx"; import type { DeveloperTabValue } from "@/constants/profile"; import { DEVELOPER_TABS } from "@/constants/profile"; -import Posts from "./_components/Posts.tsx"; +import Links from "./_components/Links.tsx"; import Profile from "./_components/Profile.tsx"; import Header from "@/components/layout/Header.tsx"; import { ArrowLeft, Heart } from "lucide-react"; +import { + useUserProfile, + useToggleUserSubscription, +} from "@/hooks/queries/useDevelopers"; export const DeveloperPage = () => { + const { nickname } = useParams<{ nickname: string }>(); + const navigate = useNavigate(); const [activeTab, setActiveTab] = useState("profile"); + console.log("🔍 Developer Page - nickname:", nickname); + + const { + data: userProfile, + isLoading, + error, + } = useUserProfile(nickname || ""); + + const toggleSubscriptionMutation = useToggleUserSubscription(); + const handleTabChange = (value: DeveloperTabValue) => { setActiveTab(value); }; + const handleBackClick = () => { + navigate(-1); + }; + + const handleHeartClick = async () => { + if (!nickname) return; + + try { + await toggleSubscriptionMutation.mutateAsync(nickname); + } catch (error) { + console.error("구독 상태 변경 실패:", error); + alert("구독 상태 변경에 실패했습니다."); + } + }; + + if (isLoading) { + return ( +
+
} + rightIcon={} + onLeftClick={handleBackClick} + /> +
+
로딩 중...
+
+
+ ); + } + + if (error) { + return ( +
+
} + rightIcon={} + onLeftClick={handleBackClick} + /> +
+
+ 프로필을 불러오는 중 오류가 발생했습니다. +
+
+
+ ); + } + return ( -
-
} rightIcon={} /> -
- +
+
} + rightIcon={ + + } + onLeftClick={handleBackClick} + onRightClick={handleHeartClick} + /> +
+
-
+
{ className="w-full mt-8" />
- {activeTab === "profile" && } - {activeTab === "posts" && } + {activeTab === "profile" && } + {activeTab === "posts" && }
diff --git a/src/pages/explore/_components/DeveloperProfileCard.tsx b/src/pages/explore/_components/DeveloperProfileCard.tsx index f1c8edd..76f67a1 100644 --- a/src/pages/explore/_components/DeveloperProfileCard.tsx +++ b/src/pages/explore/_components/DeveloperProfileCard.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from "react-router-dom"; import type { Developer } from "@/types/developer.types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -11,11 +12,29 @@ export const DeveloperProfileCard = ({ developer, viewMode, }: DeveloperProfileCardProps) => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/developer/${developer.nickname}`); + }; + if (viewMode === "grid") { return ( -
-
- {/* 이미지 영역 */} +
+
+ {developer.image ? ( + {developer.nickname} + ) : ( +
+ + {developer.nickname.charAt(0)} + +
+ )}
@@ -28,9 +47,25 @@ export const DeveloperProfileCard = ({ } return ( - +
-
+
+ {developer.image ? ( + {developer.nickname} + ) : ( +
+ + {developer.nickname.charAt(0)} + +
+ )} +
@@ -40,18 +75,22 @@ export const DeveloperProfileCard = ({
- {developer.position} + + {typeof developer.position === "string" + ? developer.position + : "포지션 정보 없음"} + · {developer.experience}
-
- {developer.stacks.map((stack, index) => ( +
+ {(developer.stacks || []).map((stack, index) => ( + className="rounded-full text-[10px] bg-brand-surface border-brand-primary text-brand-primary"> {stack} ))} diff --git a/src/pages/explore/index.tsx b/src/pages/explore/index.tsx index 5093dc3..736a062 100644 --- a/src/pages/explore/index.tsx +++ b/src/pages/explore/index.tsx @@ -1,48 +1,95 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Settings2, LayoutGrid, List } from "lucide-react"; import { Button } from "../../components/ui/button"; import { FilteringModal } from "./_components/FilteringModal"; import { EXPLORE_TABS, type ExploreTabValue } from "../../constants/explore"; -import type { Developer } from "../../types/developer.types"; +import type { + Developer, + NearbyUser, + NearbyUsersParams, +} from "../../types/developer.types"; import { ListView } from "./_components/layout/ListView"; import { GridView } from "./_components/layout/GridView"; import { AppTabs } from "../../components/common/AppTabs"; import { cn } from "../../lib/utils"; -import { fetchDevelopers } from "../../lib/api/developers"; +import { useDevelopers } from "../../hooks/queries/useDevelopers"; + +const transformNearbyUserToDeveloper = ( + user: NearbyUser, + index: number +): Developer => { + return { + id: Date.now() + index + Math.random(), + nickname: user.nickname, + distance: user.distance ? `${Math.round(user.distance * 10) / 10}km` : "", + position: user.position ? user.position.role : "포지션 정보 없음", + stacks: user.techStack?.map((stack) => stack.value) || [], + experience: `${user.experience}년`, + image: user.avatar || "", + category: "nearby" as const, + }; +}; export const ExplorePage = () => { const [activeTab, setActiveTab] = useState("nearby"); const [isModalOpen, setIsModalOpen] = useState(false); const [viewMode, setViewMode] = useState<"list" | "grid">("list"); - const [developers, setDevelopers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [filterParams, setFilterParams] = useState>( + {} + ); + + const { + data: developersData = [], + isLoading: isDevelopersLoading, + error: developersError, + } = useDevelopers( + activeTab, + true, + activeTab === "nearby" ? filterParams : undefined + ); + + const isLoading = isDevelopersLoading; + const error = developersError; - useEffect(() => { - const loadDevelopers = async () => { - setIsLoading(true); - setError(null); - try { - const fetchedDevelopers = await fetchDevelopers(activeTab); - setDevelopers(fetchedDevelopers); - } catch (err) { - setError(err as Error); - } finally { - setIsLoading(false); - } - }; - loadDevelopers(); - }, [activeTab]); + const developers = (developersData || []).map((user: any, index: number) => + transformNearbyUserToDeveloper(user, index) + ); const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); + const handleFilterSubmit = (params: Partial) => { + console.log("필터링 적용:", params); + setFilterParams(params); + closeModal(); + }; + const handleTabChange = (value: string) => { setActiveTab(value as ExploreTabValue); }; - if (isLoading) return
로딩 중...
; - if (error) return
에러 발생: {error.message}
; + if (isLoading) + return ( +
+
로딩 중...
+
+ ); + + if (error) { + return ( +
+
+ 개발자 정보를 불러오는 중 오류가 발생했습니다. +
+
{error.message}
+ {error.message.includes("인증") && ( +
+ 💡 팁: 로그인이 만료되었을 수 있습니다. 다시 로그인해주세요. +
+ )} +
+ ); + } return (
@@ -54,8 +101,8 @@ export const ExplorePage = () => { className="-mx-4" /> -
-
+
+
- + {activeTab === "nearby" && ( + + )}
{viewMode === "list" ? ( @@ -94,7 +143,9 @@ export const ExplorePage = () => { )}
- {isModalOpen && } + {isModalOpen && ( + + )}
); }; diff --git a/src/pages/home/_components/modal/AddPostModal.tsx b/src/pages/home/_components/modal/AddPostModal.tsx index 4e9f6b0..ee59447 100644 --- a/src/pages/home/_components/modal/AddPostModal.tsx +++ b/src/pages/home/_components/modal/AddPostModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Select, @@ -12,17 +13,33 @@ import { PostItem } from "./PostItem"; import { RecruitItem } from "./RecruitItem"; import { useAddPostModal } from "../../../../hooks/useAddPostModal"; import { useBoards } from "../../../../hooks/queries/usePosts"; +import { + usePositionsInterests, + useStacks, +} from "../../../../hooks/queries/useTags"; +import { useLocationQuery } from "../../../../hooks/queries/useLocationQuery"; interface AddPostModalProps { onClose: () => void; - onSubmit: (data: { title: string; content: string; boardId: number }) => void; + onSubmit: (data: { + title: string; + content: string; + boardId: number; + location: { lat: number; lng: number }; + }) => void; isSubmitting?: boolean; + initialData?: { + title: string; + content: string; + boardId: number; + }; } export const AddPostModal = ({ onClose, onSubmit, isSubmitting = false, + initialData, }: AddPostModalProps) => { const { title, @@ -31,7 +48,9 @@ export const AddPostModal = ({ description, recruitCount, position, + selectedPositionIds, selectedStackIds, + location, isRecruitBoard, setTitle, setContent, @@ -39,11 +58,51 @@ export const AddPostModal = ({ setDescription, setRecruitCount, setPosition, + setSelectedPositionIds, setSelectedStackIds, + setLocation, reset, } = useAddPostModal(); const { data: boards = [] } = useBoards(); + const { data: positionsInterestsData } = usePositionsInterests(); + const { data: stacks } = useStacks(); + const { location: currentLocation, refetchLocation } = useLocationQuery(); + + useEffect(() => { + if (currentLocation && !location) { + setLocation(currentLocation); + } + }, [currentLocation, location, setLocation]); + + useEffect(() => { + if (!location && currentLocation) { + setLocation(currentLocation); + } + }, [location, currentLocation, setLocation]); + + useEffect(() => { + if (initialData && !location) { + if (currentLocation) { + setLocation(currentLocation); + } else { + refetchLocation().then(() => { + console.log("위치 정보 다시 가져오기 완료"); + }); + } + } + }, [initialData, currentLocation, location, setLocation, refetchLocation]); + + useEffect(() => { + if (initialData) { + setTitle(initialData.title); + setContent(initialData.content); + const board = boards.find((b) => b.id === initialData.boardId); + if (board) { + setSelectedBoard(board.value); + } + } + }, [initialData, boards]); const handleSubmit = () => { if (!title.trim() || !selectedBoard) { @@ -51,7 +110,10 @@ export const AddPostModal = ({ } if (isRecruitBoard) { - if (!description.trim() || !position || selectedStackIds.length === 0) { + if (!description.trim() || selectedStackIds.length === 0) { + return; + } + if (selectedBoard === "프로젝트" && selectedPositionIds.length === 0) { return; } } else { @@ -60,15 +122,41 @@ export const AddPostModal = ({ } } + if (!location) { + alert("위치 정보가 필요합니다. 위치 정보를 설정해주세요."); + return; + } + const selectedBoardData = boards.find( (board) => board.value === selectedBoard ); if (!selectedBoardData) return; + let finalContent = isRecruitBoard ? description.trim() : content.trim(); + + if (isRecruitBoard) { + finalContent += `\n\n모집 인원: ${recruitCount}명`; + if (selectedBoard === "프로젝트" && selectedPositionIds.length > 0) { + const positions = positionsInterestsData?.positions || []; + const selectedPositions = positions.filter((pos) => + selectedPositionIds.includes(pos.id) + ); + const positionText = selectedPositions + .map((pos) => pos.role) + .join(", "); + finalContent += `\n포지션: ${positionText}`; + } + const selectedStacks = + stacks?.filter((stack) => selectedStackIds.includes(stack.id)) || []; + const stackText = selectedStacks.map((stack) => stack.value).join(", "); + finalContent += `\n기술스택: ${stackText}`; + } + const postData = { title: title.trim(), - content: isRecruitBoard ? description.trim() : content.trim(), + content: finalContent, boardId: selectedBoardData.id, + location, }; onSubmit(postData); @@ -79,6 +167,17 @@ export const AddPostModal = ({ onClose(); }; + const isDisabled = + isSubmitting || + !title.trim() || + !selectedBoard || + (isRecruitBoard + ? !description.trim() || + selectedStackIds.length === 0 || + (selectedBoard === "프로젝트" && selectedPositionIds.length === 0) + : !content.trim()) || + !location; + const footer = (
@@ -106,7 +198,6 @@ export const AddPostModal = ({ return (
- {/* 게시판 선택 */}
- - {/*포스트, 리크루트 조건부 렌더링*/} {isRecruitBoard ? ( ) : ( diff --git a/src/pages/home/_components/modal/RecruitItem.tsx b/src/pages/home/_components/modal/RecruitItem.tsx index 1948750..3d2be50 100644 --- a/src/pages/home/_components/modal/RecruitItem.tsx +++ b/src/pages/home/_components/modal/RecruitItem.tsx @@ -17,11 +17,14 @@ interface RecruitItemProps { description: string; recruitCount: number; position: number | null; + selectedPositionIds: number[]; selectedStackIds: number[]; + boardValue: string; onTitleChange: (title: string) => void; onDescriptionChange: (description: string) => void; onRecruitCountChange: (count: number) => void; onPositionChange: (position: number | null) => void; + onPositionIdsChange: (positionIds: number[]) => void; onStackIdsChange: (stackIds: number[]) => void; } @@ -30,11 +33,14 @@ export const RecruitItem = ({ description, recruitCount, position, + selectedPositionIds, selectedStackIds, + boardValue, onTitleChange, onDescriptionChange, onRecruitCountChange, onPositionChange, + onPositionIdsChange, onStackIdsChange, }: RecruitItemProps) => { const [isRecruitPopoverOpen, setIsRecruitPopoverOpen] = useState(false); @@ -111,11 +117,17 @@ export const RecruitItem = ({
- + {/* 모각코에서는 포지션 제외, 프로젝트에서는 포지션 다중 선택 가능 */} + {boardValue === "프로젝트" && ( + + )} { const [isModalOpen, setIsModalOpen] = useState(false); const { data: boards = [], isLoading: boardsLoading } = useBoards(); + const { refetchLocation } = useLocationQuery(); const validCategories = [ "all", @@ -50,7 +52,12 @@ export const BoardPage = () => { navigate("/search"); }; - const handlePlusClick = () => { + const handlePlusClick = async () => { + try { + await refetchLocation(); + } catch (error) { + console.error("위치 정보 가져오기 실패:", error); + } setIsModalOpen(true); }; @@ -58,12 +65,12 @@ export const BoardPage = () => { title: string; content: string; boardId: number; + location: { lat: number; lng: number }; }) => { try { await createPostMutation.mutateAsync(data); setIsModalOpen(false); } catch (err) { - console.error("게시글 작성 실패:", err); alert("게시글 작성에 실패했습니다."); } }; diff --git a/src/pages/my/bookmarks.tsx b/src/pages/my/bookmarks.tsx new file mode 100644 index 0000000..ab12652 --- /dev/null +++ b/src/pages/my/bookmarks.tsx @@ -0,0 +1,71 @@ +import { useNavigate } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import Header from "@/components/layout/Header"; +import { useMyBookmarks } from "@/hooks/queries/useMy"; +import { ListItem } from "@/components/common/ListItem"; + +export const MyBookmarksPage = () => { + const navigate = useNavigate(); + const { data: bookmarks, isLoading, error } = useMyBookmarks(); + + const handleBack = () => { + navigate("/my"); + }; + + if (isLoading) { + return ( +
+
+
+

북마크를 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

북마크를 불러올 수 없습니다.

+ +
+
+ ); + } + + return ( +
+
} + onLeftClick={handleBack} + /> +
+
+ {bookmarks && bookmarks.length > 0 ? ( +
+ {bookmarks + .sort( + (a: any, b: any) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + ) + .map((bookmark: any) => ( + + ))} +
+ ) : ( +
+

북마크한 게시물이 없습니다.

+
+ )} +
+
+
+ ); +}; diff --git a/src/pages/my/comments.tsx b/src/pages/my/comments.tsx new file mode 100644 index 0000000..1eebb54 --- /dev/null +++ b/src/pages/my/comments.tsx @@ -0,0 +1,90 @@ +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, MessageCircle } from "lucide-react"; +import Header from "@/components/layout/Header"; +import { useMyComments } from "@/hooks/queries/useMy"; +import { formatRelativeTime } from "@/lib/date"; + +export const MyCommentsPage = () => { + const navigate = useNavigate(); + const { data: comments, isLoading, error } = useMyComments(); + + const handleBack = () => { + navigate("/my"); + }; + + const handlePostClick = (postId: number) => { + navigate(`/community/${postId}`); + }; + + if (isLoading) { + return ( +
+
+
+

댓글을 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

댓글을 불러올 수 없습니다.

+ +
+
+ ); + } + + return ( +
+
} + onLeftClick={handleBack} + /> +
+
+ {comments && comments.length > 0 ? ( +
+ {comments + .sort( + (a: any, b: any) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + ) + .map((comment: any) => ( +
handlePostClick(comment.postId)} + className="bg-brand-surface rounded-lg p-4 cursor-pointer hover:bg-brand-surface/80 transition-colors"> +

+ {comment.content} +

+ +
+ {formatRelativeTime(comment.createdAt)} +
+ + 본문으로 이동 +
+
+
+ ))} +
+ ) : ( +
+

작성한 댓글이 없습니다.

+
+ )} +
+
+
+ ); +}; diff --git a/src/pages/my/edit/index.tsx b/src/pages/my/edit/index.tsx new file mode 100644 index 0000000..930f0a2 --- /dev/null +++ b/src/pages/my/edit/index.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowLeft, + Linkedin, + Mail, + Instagram, + Rss, + Github, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { PositionSelector } from "@/components/common/PositionSelector"; +import { StackSelector } from "@/components/common/StackSelector"; +import Header from "@/components/layout/Header"; +import { useMyProfile, useUpdateProfile } from "@/hooks/queries/useMy"; +import { usePositionsInterests } from "@/hooks/queries/useTags"; +import { cn } from "@/lib/utils"; +import type { UpdateProfileRequest } from "@/types/my.types"; + +export const EditProfilePage = () => { + const navigate = useNavigate(); + const { data: profile, isLoading } = useMyProfile(); + const { data: positionsInterestsData } = usePositionsInterests(); + const updateProfileMutation = useUpdateProfile(); + + const [formData, setFormData] = useState({ + nickname: "", + experience: 0, + bio: "", + positionId: undefined, + techIds: [], + interestIds: [], + github: "", + email: "", + instagram: "", + linkedIn: "", + blog: "", + }); + + useEffect(() => { + if (profile) { + setFormData({ + nickname: profile.nickname || "", + experience: profile.experience || 0, + bio: profile.bio || "", + positionId: profile.position?.id || undefined, + techIds: profile.techStack?.map((tech: any) => tech.id) || [], + interestIds: + profile.interests?.map((interest: any) => interest.id) || [], + github: profile.github || "", + email: profile.email || "", + instagram: profile.instagram || "", + linkedIn: profile.linkedIn || "", + blog: profile.blog || "", + }); + } + }, [profile]); + + const handleInputChange = (field: keyof UpdateProfileRequest, value: any) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleSubmit = async () => { + try { + const requestData = { + nickname: formData.nickname, + experience: formData.experience, + bio: formData.bio, + positionId: formData.positionId, + techIds: formData.techIds, + interestIds: formData.interestIds, + ...(formData.github && { github: formData.github }), + ...(formData.email && { email: formData.email }), + ...(formData.instagram && { instagram: formData.instagram }), + ...(formData.linkedIn && { linkedIn: formData.linkedIn }), + ...(formData.blog && { blog: formData.blog }), + }; + + await updateProfileMutation.mutateAsync(requestData); + navigate("/my"); + } catch (error) { + alert("프로필 수정에 실패했습니다. 다시 시도해주세요."); + } + }; + + const handleBack = () => { + navigate("/my"); + }; + + if (isLoading) { + return ( +
+
+
+

프로필을 불러오는 중...

+
+
+ ); + } + + return ( +
+
} + onLeftClick={handleBack} + /> + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+ + handleInputChange("nickname", e.target.value)} + className="bg-brand-surface border-brand-surface text-white" + placeholder="닉네임을 입력하세요" + /> +
+ +
+ + + handleInputChange("experience", parseInt(e.target.value) || 0) + } + className="bg-brand-surface border-brand-surface text-white" + placeholder="경력을 입력하세요" + /> +
+ +
+ +