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 ? (

) : (
- 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.charAt(0)}
+
+
+ )}
@@ -28,9 +47,25 @@ export const DeveloperProfileCard = ({
}
return (
-
+
-
+
+ {developer.image ? (
+

+ ) : (
+
+
+ {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 = (
diff --git a/src/pages/search/_components/SearchResults.tsx b/src/pages/search/_components/SearchResults.tsx
new file mode 100644
index 0000000..3f1f18c
--- /dev/null
+++ b/src/pages/search/_components/SearchResults.tsx
@@ -0,0 +1,102 @@
+import { useNavigate } from "react-router-dom";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Heart, MessageCircle } from "lucide-react";
+import type { PostListItem } from "@/types/post.types";
+import { formatPostListDate } from "@/lib/date";
+
+interface SearchResultsProps {
+ posts: PostListItem[];
+ isLoading: boolean;
+ isError: boolean;
+ error: Error | null;
+ query: string;
+ isDebouncing?: boolean;
+}
+
+export default function SearchResults({
+ posts,
+ isLoading,
+ isError,
+ error,
+ query,
+ isDebouncing = false,
+}: SearchResultsProps) {
+ const navigate = useNavigate();
+
+ const handlePostClick = (post: PostListItem) => {
+ navigate(`/community/${post.id}`);
+ };
+
+ if (isLoading || isDebouncing) {
+ return (
+
+
{isDebouncing ? "검색어 입력 중..." : "검색 중..."}
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
검색 중 오류가 발생했습니다:
+
{error?.message}
+
+ );
+ }
+
+ if (posts.length === 0 && query.trim()) {
+ return (
+
+
"{query}"에 대한 검색 결과가 없습니다.
+
+ );
+ }
+
+ if (!query.trim()) {
+ return null;
+ }
+
+ return (
+
+
+
+ "{query}" 검색 결과 {posts.length}개
+
+
+
+
+ {posts.map((post) => (
+
handlePostClick(post)}>
+
+
+ {post.board.value}
+
+
+ {post.title}
+
+
+
+
+ {post.author.nickname} · {formatPostListDate(post.createdAt)}
+
+
+
+
+ {post.nLikes}
+
+
+
+ {post.nComments}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/search/index.tsx b/src/pages/search/index.tsx
index b141572..758d966 100644
--- a/src/pages/search/index.tsx
+++ b/src/pages/search/index.tsx
@@ -1,42 +1,56 @@
-import { useState } from "react";
+import { useNavigate } from "react-router-dom";
import RecentSearchList from "@/pages/search/_components/RecentSearchList";
import SearchHeader from "@/pages/search/_components/SearchHeader";
+import SearchResults from "@/pages/search/_components/SearchResults";
+import { useSearch } from "@/hooks/useSearch";
export default function SearchPage() {
- const [query, setQuery] = useState("");
- const [recentSearches, setRecentSearches] = useState([
- "이전 검색 결과 1",
- "이전 검색 결과 2",
- "이전 검색 결과 3",
- ]);
+ const navigate = useNavigate();
+ const {
+ query,
+ recentSearches,
+ searchResults,
+ isLoading,
+ isError,
+ error,
+ hasQueryInput,
+ isDebouncing,
+ searchQuery,
+ setQuery,
+ handleSubmit,
+ handleRecentSearchClick,
+ removeRecentSearch,
+ } = useSearch();
- const handleRemoveSearch = (index: number) => {
- setRecentSearches((prev) => prev.filter((_, i) => i !== index));
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (query.trim()) {
- // TODO: 실제 검색 API 연동 후 검색 결과 페이지로 이동 또는 결과 표시
- console.log("검색:", query);
- }
+ const handleBack = () => {
+ navigate(-1);
};
return (
-
+
{}}
+ onBack={handleBack}
/>
-
+ {hasQueryInput ? (
+
+ ) : (
+
+ )}
);
diff --git a/src/types/community.types.ts b/src/types/community.types.ts
deleted file mode 100644
index 485700d..0000000
--- a/src/types/community.types.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export interface Reply {
- id: number;
- author: {
- name: string;
- avatarUrl: string;
- };
- content: string;
- createdAt: string;
- likeCount: number;
- isLiked: boolean;
-}
-
-export interface Comment {
- id: number;
- author: {
- name: string;
- avatarUrl: string;
- };
- content: string;
- createdAt: string;
- likeCount: number;
- isLiked: boolean;
- replyCount: number;
- replies?: Reply[];
-}
-
-export interface Post {
- id: number;
- category: string;
- title: string;
- author: {
- name: string;
- avatarUrl: string;
- };
- createdAt: string;
- content: string;
- likeCount: number;
- isLiked: boolean;
- comments: Comment[];
-}
diff --git a/src/types/developer.types.ts b/src/types/developer.types.ts
index 8b2cdc8..ee4fe0d 100644
--- a/src/types/developer.types.ts
+++ b/src/types/developer.types.ts
@@ -8,3 +8,42 @@ export interface Developer {
image: string;
category: "nearby" | "popular" | "favorite";
}
+
+export interface NearbyUser {
+ location: {
+ lng: number;
+ lat: number;
+ };
+ distance: number;
+ nickname: string;
+ avatar: string;
+ age: number;
+ experience: number;
+ position: {
+ id: number;
+ field: string;
+ role: string;
+ };
+ techStack: Array<{
+ id: number;
+ value: string;
+ }>;
+ interests: Array<{
+ id: number;
+ value: string;
+ }>;
+ nSubscribers: number;
+}
+
+export interface NearbyUsersResponse {
+ results: NearbyUser[];
+}
+
+export interface NearbyUsersParams {
+ radius: number;
+ age?: string;
+ experience?: string;
+ positionIds?: number[];
+ techIds?: number[];
+ interestIds?: number[];
+}
diff --git a/src/types/my.types.ts b/src/types/my.types.ts
new file mode 100644
index 0000000..fe6a85d
--- /dev/null
+++ b/src/types/my.types.ts
@@ -0,0 +1,45 @@
+export interface GetProfileResponse {
+ nickname: string;
+ avatar: string;
+ age: number;
+ experience: number;
+ bio: string;
+ position: {
+ id: number;
+ field: string;
+ role: string;
+ };
+ techStack: Array<{
+ id: number;
+ value: string;
+ }>;
+ interests: Array<{
+ id: number;
+ value: string;
+ }>;
+ location: {
+ lng: number;
+ lat: number;
+ };
+ github?: string;
+ email?: string;
+ instagram?: string;
+ linkedIn?: string;
+ blog?: string;
+ nSubscribers: number;
+ subscribing: boolean;
+}
+
+export interface UpdateProfileRequest {
+ nickname?: string;
+ experience?: number;
+ bio?: string;
+ positionId?: number;
+ techIds?: number[];
+ interestIds?: number[];
+ github?: string;
+ email?: string;
+ instagram?: string;
+ linkedIn?: string;
+ blog?: string;
+}
diff --git a/src/types/post.types.ts b/src/types/post.types.ts
index efad29d..aa8811e 100644
--- a/src/types/post.types.ts
+++ b/src/types/post.types.ts
@@ -1,4 +1,4 @@
-export interface Post {
+export interface PostListResponse {
id: number;
board: {
id: number;
@@ -14,3 +14,90 @@ export interface Post {
nLikes: number;
createdAt: string;
}
+
+export interface PostDetailResponse {
+ id: number;
+ board: {
+ id: number;
+ value: string;
+ isRecruit: boolean;
+ };
+ author: {
+ avatar: string;
+ nickname: string;
+ };
+ title: string;
+ content: string;
+ nComments: number;
+ nLikes: number;
+ createdAt: string;
+ editable: boolean;
+ likeIt: boolean;
+ bookmark: boolean;
+}
+
+export interface PostListItem extends PostListResponse {
+ nApplicants?: number;
+}
+
+export interface PostDetailItem extends PostDetailResponse {
+ nApplicants?: number;
+ applied?: boolean;
+}
+
+export interface CommentResponse {
+ id: number;
+ postId: number;
+ author: {
+ nickname: string;
+ avatar: string;
+ };
+ content: string;
+ editable: boolean;
+ createdAt: string;
+}
+
+export interface LikeToggleResponse {
+ nLikes: number;
+ likeIt: boolean;
+}
+
+export interface BookmarkToggleResponse {
+ bookmark: boolean;
+}
+
+export interface PostCommentResponse {
+ nComments: number;
+}
+
+export interface CreatePostRequest {
+ title: string;
+ content: string;
+ boardId: number;
+ location: {
+ lng: number;
+ lat: number;
+ };
+}
+
+export interface CreateCommentRequest {
+ content: string;
+}
+
+export interface UpdatePostRequest {
+ title: string;
+ content: string;
+}
+
+export interface UpdateCommentRequest {
+ content: string;
+}
+
+export interface ApplicantResponse {
+ nickname: string;
+ avatar: string;
+}
+
+export interface ApplyToggleResponse {
+ applied: boolean;
+}