Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
189fdd8
fix: search 페이지에서 직접 GlobalLayout 사용하는 코드 삭제
Monixc Aug 3, 2025
c9cb4f8
refactor: 레이아웃 구조 개선
Monixc Aug 3, 2025
a25ec25
style: 검색 페이지 컴포넌트 구조 및 스타일 개선
Monixc Aug 3, 2025
8da4822
feat: 페이지 라우팅 확인용 임시 채팅 페이지
Monixc Aug 3, 2025
c2d3b59
style: 페이지 별 헤더 추가 및 레이아웃 개선
Monixc Aug 3, 2025
d7eff67
style: 온보딩 페이지 및 버튼 스타일 개선
Monixc Aug 3, 2025
c627cac
feat: 리액트 쿼리 프로바이더 추가
Monixc Aug 4, 2025
1fa1193
style: 댓글 입력 영역 textarea에서 input으로 변경
Monixc Aug 4, 2025
10d19b3
feat: 댓글 및 답글 좋아요 기능 추가 및 UI 개선
Monixc Aug 4, 2025
8267b15
feat: 커뮤니티 경로에 postId 파라미터 추가
Monixc Aug 4, 2025
b61d561
style: separator.tsx 파일의 코드 스타일 개선
Monixc Aug 4, 2025
a1c782e
feat: ListItem 컴포넌트에 클릭 이벤트 추가 및 네비게이션 기능 구현
Monixc Aug 4, 2025
678f153
feat: 댓글 인터페이스에 답글 및 좋아요 기능 추가
Monixc Aug 4, 2025
1a29889
feat: 커뮤니티 게시글 및 댓글 API 핸들러 추가
Monixc Aug 4, 2025
de701ce
feat: 게시글 및 댓글 API 핸들러 개선 및 좋아요 기능 추가
Monixc Aug 4, 2025
2f3ec64
feat: 전체 페이지 모달 래퍼 구현
Monixc Aug 6, 2025
5c51019
refactor: 조건 설정 모달 수정
Monixc Aug 6, 2025
23a9904
feat: 게시글 작성용 전체 페이지 모달 구현
Monixc Aug 6, 2025
8ca7100
feat: board 페이지 플로팅 버튼 클릭 시 AddPostModal open
Monixc Aug 6, 2025
42f6e9b
refactor: 포지션, 스택 선택 ㅇ컴포넌트 분리
Monixc Aug 6, 2025
0f4ff98
style: 기술 스택 선택 팝오버 avoidcollison 설정정
Monixc Aug 6, 2025
4817bb6
fix: AddPostModal 경로 수정
Monixc Aug 6, 2025
ce7069e
feat: 게시글 작성 모달 및 관련 훅 구현
Monixc Aug 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 8 additions & 32 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,16 @@
import { Outlet, useLocation } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import GlobalLayout from "./components/layout/GlobalLayout";

function App() {
const location = useLocation();

const layoutGroups = {
full: ["/", "/board", "/explore", "/my"], // header + bottom nav
headerOnly: ["/developer", "/community", "/onboarding"], // only header
none: ["/login", "/search"], // no header and bottom nav
};

const currentPath = location.pathname;

// no header and bottom nav
if (layoutGroups.none.some((path) => currentPath.startsWith(path))) {
return (
<GlobalLayout showHeader={false} showBottomNavigation={false}>
<Outlet />
</GlobalLayout>
);
}
const queryClient = new QueryClient();

// only header
if (layoutGroups.headerOnly.some((path) => currentPath.startsWith(path))) {
return (
<GlobalLayout showBottomNavigation={false}>
function App() {
return (
<QueryClientProvider client={queryClient}>
<GlobalLayout>
<Outlet />
</GlobalLayout>
);
}

// basic(header + bommon nav)
return (
<GlobalLayout>
<Outlet />
</GlobalLayout>
</QueryClientProvider>
);
}

Expand Down
36 changes: 36 additions & 0 deletions src/components/common/ModalWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ReactNode } from "react";

interface ModalWrapperProps {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}

export const ModalWrapper = ({
title,
onClose,
children,
footer,
}: ModalWrapperProps) => {
return (
<div className="fixed inset-0 z-50">
<div className="w-full mx-auto bg-brand-background flex flex-col relative text-white h-screen">
<div className="flex justify-between items-center py-2 px-4 border-b border-brand-surface">
<h2 className="text-lg font-bold">{title}</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="-mr-4 size-6" />
</Button>
</div>
<div className="p-4 flex-1 overflow-y-auto">{children}</div>
{footer && (
<div className="py-2 px-4 border-t border-brand-surface">
{footer}
</div>
)}
</div>
</div>
);
};
89 changes: 89 additions & 0 deletions src/components/common/PositionSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { fetchPositions } from "@/lib/api";
import type { PositionCategory } from "@/types/filter";

interface PositionSelectorProps {
selectedPosition: number | null;
onPositionChange: (positionId: number) => void;
label?: string;
}

export const PositionSelector = ({
selectedPosition,
onPositionChange,
label = "포지션",
}: PositionSelectorProps) => {
const [positions, setPositions] = useState<PositionCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const loadPositions = async () => {
try {
const fetchedPositions = await fetchPositions();
setPositions(fetchedPositions);
} catch (error) {
console.error("포지션 로딩 실패:", error);
} finally {
setIsLoading(false);
}
};

loadPositions();
}, []);

if (isLoading) {
return <div className="text-center">로딩 중...</div>;
}

return (
<div className="space-y-3">
<Label>{label}</Label>
<Accordion
type="single"
collapsible
className="w-full border rounded-md border-brand-surface">
{positions.map((cat) => (
<AccordionItem
key={cat.category}
value={cat.category}
className="px-4 border-b-brand-surface last:border-b-0">
<AccordionTrigger className="hover:no-underline">
{cat.category}
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2 pt-2">
{cat.positions.map((pos) => (
<Button
key={pos.id}
variant="outline"
onClick={() => onPositionChange(pos.id)}
className={cn(
"h-auto justify-start text-left whitespace-normal border-transparent bg-brand-surface",
selectedPosition === pos.id &&
"border-brand-primary text-brand-primary border-1"
)}>
<div className="flex flex-col">
<span className="font-bold">{pos.name}</span>
<span className="text-xs text-brand-text">
{pos.description}
</span>
</div>
</Button>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
};
147 changes: 147 additions & 0 deletions src/components/common/StackSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { fetchStacks } from "@/lib/api";
import type { Stack } from "@/types/filter";

interface StackSelectorProps {
selectedStackIds: number[];
onStackChange: (stackIds: number[]) => void;
label?: string;
maxSelection?: number;
}

export const StackSelector = ({
selectedStackIds,
onStackChange,
label = "기술 스택 / 분야",
maxSelection,
}: StackSelectorProps) => {
const [stacks, setStacks] = useState<Stack[]>([]);
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const loadStacks = async () => {
try {
const fetchedStacks = await fetchStacks();
setStacks(fetchedStacks);
} catch (error) {
console.error("스택 로딩 실패:", error);
} finally {
setIsLoading(false);
}
};

loadStacks();
}, []);

const handleSelectStack = (stackId: number) => {
const newStackIds = selectedStackIds.includes(stackId)
? selectedStackIds.filter((id) => id !== stackId)
: maxSelection && selectedStackIds.length >= maxSelection
? selectedStackIds
: [...selectedStackIds, stackId];

onStackChange(newStackIds);
setOpen(false);
};

const handleRemoveStack = (stackIdToRemove: number) => {
const newStackIds = selectedStackIds.filter((id) => id !== stackIdToRemove);
onStackChange(newStackIds);
};

if (isLoading) {
return <div className="text-center">로딩 중...</div>;
}

return (
<div className="space-y-3">
<Label>{label}</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between bg-brand-surface border-transparent hover:bg-brand-primary hover:text-white">
{selectedStackIds.length > 0
? `${selectedStackIds.length}개 선택됨`
: "스택을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={4}
avoidCollisions={true}
className="w-[var(--radix-popover-trigger-width)] p-0 border-transparent z-[9999] bg-brand-surface max-h-[200px] overflow-y-auto scrollbar-hide">
<Command className="bg-brand-surface text-white">
<CommandInput placeholder="스택 검색..." />
<CommandEmpty className="text-white">
검색 결과가 없습니다.
</CommandEmpty>
<CommandGroup className="bg-brand-surface ">
{stacks.map((stack) => (
<CommandItem
key={stack.id}
value={stack.label}
onSelect={() => handleSelectStack(stack.id)}
className="aria-selected:bg-brand-primary text-white">
<Check
className={cn(
"mr-2 h-4 w-4",
selectedStackIds.includes(stack.id)
? "opacity-100"
: "opacity-0"
)}
/>
{stack.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 border border-brand-surface rounded-md">
{selectedStackIds.length > 0 ? (
selectedStackIds.map((stackId) => {
const stack = stacks.find((s) => s.id === stackId);
return (
<Badge
key={stackId}
variant="secondary"
className="flex items-center gap-x-1 bg-brand-primary text-black">
<span>{stack?.label}</span>
<button
onClick={() => handleRemoveStack(stackId)}
className="rounded-full hover:bg-black/20">
<X className="h-3 w-3" />
</button>
</Badge>
);
})
) : (
<span className="text-brand-text">기술 스택을 선택해주세요</span>
)}
</div>
</div>
);
};
18 changes: 10 additions & 8 deletions src/components/layout/BottomNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,36 @@
import { NAV_ITEMS } from '@/constants/navigation';
import { Button } from '../ui/button';
import { NAV_ITEMS } from "@/constants/navigation";
import { Button } from "../ui/button";
import { useLocation, useNavigate } from "react-router-dom";

export default function BottomNavigation() {
const location = useLocation();
const navigate = useNavigate();

return (
<nav className="w-full border-t border-brand-surface h-14 flex">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
// TODO: useLocation 훅 추가 후 실제 현재 경로와 비교하도록 수정 필요
const isActive = location.pathname === item.path;

return (
<Button
key={item.id}
variant="ghost"
onClick={() => { }} // TODO: 각 페이지(홈/주변탐색/채팅/프로필) 구현 완료 후 navigate(item.path) 기능 추가
onClick={() => navigate(item.path)}
className={`
flex-1
h-full
rounded-none
hover:text-brand-primary
hover:bg-brand-surface
cursor-pointer
${isActive ? 'text-brand-primary' : 'text-white'}
${isActive ? "text-brand-primary" : "text-white"}
`}
aria-label={`${item.id} 페이지로 이동`}
>
aria-label={`${item.id} 페이지로 이동`}>
<Icon />
</Button>
);
})}
</nav>
);
}
}
Loading