diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5ec364b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# ⚽️ ONLINE LOCKER-ROOM Platform ⚽️
+
+Maven 기반의 **Spring Boot 백엔드**와 **HTML/CSS/JavaScript 프론트엔드**로 구성된 웹 애플리케이션입니다.
+이 프로젝트는 **NCP 서버에 배포하여 실제 운영 환경에서 실행**할 예정이며, 추후 외부 API를 단계적으로 추가해나갈 계획입니다.
+
+---
+
+## 📌 프로젝트 개요
+이 프로젝트는 **Spring Boot를 활용한 백엔드 개발**과 **HTML/CSS/JavaScript를 이용한 프론트엔드 개발**을 포함합니다.
+또한, **MySQL을 사용한 데이터 관리** 및 **NCP 서버 배포 및 운영**을 실습하며, 실무에서 사용되는 기술 스택을 익히는 것을 목표로 합니다.
+
+---
+
+## 📌 주요 기능
+✅ **사용자 인증** (기본 로그인/회원가입)
+✅ **데이터 저장 및 관리** (MySQL)
+✅ **정적 페이지 렌더링** (HTML/CSS/JavaScript)
+✅ **NCP 서버 배포 및 운영** (Compute VM, Cloud DB)
+
+---
+
+## 💡 2025 단국대학교 창업 동아리 추진
+이 프로젝트는 **2025 단국대학교 창업 동아리의 출안 서비스**입니다.
+현재 **ONLINE LOCKER-ROOM 서비스**에서 시작하여 다음과 같은 기능을 확장할 계획입니다.
+
+### 🎯 **추진 목표**
+1️⃣ **팀 SNS 프로필 서비스 제공**
+ - **팀 스폰서 연결 지원**
+ - **AI 기반 매칭 시스템 구축**
+ - **구역 내 리그컵 개최 지원**
+
+2️⃣ **개인 SNS 프로필 서비스 제공**
+ - **특정 팀이 필요한 포지션의 개인 유저 검색 및 추천 기능**
+
+😊 이러한 기능을 통해 동아리 내에서 IT 기술을 활용한 스포츠 플랫폼을 실현하고자 합니다.
+
+---
+
+## 🛠 기술 스택
+| **분류** | **사용 기술** |
+|-------------|---------------------------------------------|
+| **Frontend** | HTML, CSS, JavaScript |
+| **Backend** | Spring Boot (Maven 프로젝트) |
+| **Database** | MySQL |
+| **Cloud** | NCP Compute VM, NCP Cloud DB |
+
+---
+
+## 🔧 설치 및 실행 방법
+
+### 1️⃣ 프로젝트 클론
+GitHub에서 프로젝트를 클론합니다.
+
+```sh
+git clone https://github.com/사용자명/저장소이름.git
+cd 저장소이름
diff --git a/front-end/.gitignore b/front-end/.gitignore
index a547bf3..df91727 100644
--- a/front-end/.gitignore
+++ b/front-end/.gitignore
@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+# Local env files
+.env
+.env.*
\ No newline at end of file
diff --git a/front-end/.prettierrc b/front-end/.prettierrc
new file mode 100644
index 0000000..4eb0a25
--- /dev/null
+++ b/front-end/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "useTabs": false,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 80
+}
\ No newline at end of file
diff --git a/front-end/index.html b/front-end/index.html
index 0c589ec..1961570 100644
--- a/front-end/index.html
+++ b/front-end/index.html
@@ -1,10 +1,10 @@
-
+
navigate(`/feed/${post.contentId}`)}
+ />
+ ))}
+ >
+ );
+};
+
+export default FeedList;
diff --git a/front-end/src/components/feed/FeedMatch.jsx b/front-end/src/components/feed/FeedMatch.jsx
new file mode 100644
index 0000000..143360f
--- /dev/null
+++ b/front-end/src/components/feed/FeedMatch.jsx
@@ -0,0 +1,145 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const FeedMatch = ({ post, userMail, onClose }) => {
+ const [myTeams, setMyTeams] = useState([]);
+ const [selectedTeamId, setSelectedTeamId] = useState('');
+ const [team, setTeam] = useState('');
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/teams/mail/${userMail}`,
+ );
+ const data = await res.json();
+ const filtered = data.filter(
+ (t) => t.teamManagerMail === userMail && t.teamId !== post.teamId,
+ );
+ setMyTeams(filtered);
+ if (filtered.length > 0) setSelectedTeamId(filtered[0].teamId);
+ const teamResponse = await fetch(
+ `http://52.78.12.127:8080/api/teams/${post.teamId}`,
+ );
+ const teamData = await teamResponse.json();
+ setTeam(teamData);
+ };
+
+ fetchTeams();
+ }, [userMail, post]);
+
+ const handleMatch = async () => {
+ const requesterTeam = myTeams.find(
+ (t) => t.teamId === Number(selectedTeamId),
+ );
+ const startDate = new Date(post.matchDay).toISOString().replace('Z', '');
+ const fetchLogo = async () => {
+ const res = await fetch('/img/alt_image.png');
+ const blob = await res.blob();
+ return new File([blob], 'default-logo.png', { type: blob.type });
+ };
+
+ const games = [
+ {
+ teamId: requesterTeam.teamId,
+ versus: team.teamName,
+ gameName: `${post.matchDay.slice(0, 10)} ${team.teamName} 매칭 신청`,
+ },
+ {
+ teamId: post.teamId,
+ versus: requesterTeam.teamName,
+ gameName: `${post.matchDay.slice(0, 10)} ${
+ requesterTeam.teamName
+ } 매칭 신청`,
+ },
+ ];
+
+ for (const game of games) {
+ const logoFile = await fetchLogo();
+ const formData = new FormData();
+ formData.append('teamId', String(game.teamId));
+ formData.append('versus', game.versus);
+ formData.append('gameName', game.gameName);
+ formData.append('startDate', startDate);
+ formData.append('oppoLogo', logoFile);
+
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/games/create-game',
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) {
+ const errText = await res.text();
+ alert(`매치 실패 : ${errText}`);
+ return;
+ }
+ }
+
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/community/${post.contentId}`,
+ { method: 'DELETE' },
+ );
+ if (!res.ok) return alert('매칭 후 삭제 실패');
+ alert('매칭 성공!');
+ onClose();
+ navigate(`/team/${requesterTeam.teamId}`);
+ };
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg"
+ >
+
매칭 신청
+ {myTeams.length > 0 ? (
+ <>
+
setSelectedTeamId(e.target.value)}
+ className="w-full mb-[2vh] p-[1.5vh] border border-gray-300 rounded-[1vh] text-[1.7vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ >
+ {myTeams.map((t) => (
+
+ {t.teamName}
+
+ ))}
+
+
+
+
+ 취소
+
+
+ 신청
+
+
+ >
+ ) : (
+ <>
+
+ 신청 가능한 팀이 없습니다.
+
+
+ 취소
+
+ >
+ )}
+
+
+ );
+};
+
+export default FeedMatch;
diff --git a/front-end/src/components/feed/FeedMercenary.jsx b/front-end/src/components/feed/FeedMercenary.jsx
new file mode 100644
index 0000000..4eb82ed
--- /dev/null
+++ b/front-end/src/components/feed/FeedMercenary.jsx
@@ -0,0 +1,53 @@
+import { useNavigate } from "react-router-dom";
+
+const FeedMercenary = ({ post, userMail, onClose }) => {
+ const gameId = post.game.gameId
+ const navigate = useNavigate();
+ const handleMercenary = async () => {
+ try {
+ const res = await fetch(`http://52.78.12.127:8080/api/games/${gameId}/insert-to-game`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail }),
+ });
+ if (res.ok) {
+ alert('신청 성공!');
+ onClose();
+ navigate(`/game/${gameId}`);
+ } else {
+ alert(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류가 발생했습니다.');
+ }
+ };
+
+
+ return (
+
+
e.stopPropagation()} className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg">
+
매칭 신청
+
용병 신청 하시겠습니까?
+
+
+
+ 취소
+
+
+ 신청
+
+
+
+
+
+ )
+}
+
+export default FeedMercenary;
\ No newline at end of file
diff --git a/front-end/src/components/game/CreateGame.jsx b/front-end/src/components/game/CreateGame.jsx
new file mode 100644
index 0000000..3ccb69a
--- /dev/null
+++ b/front-end/src/components/game/CreateGame.jsx
@@ -0,0 +1,46 @@
+import altImage from '../../img/alt_image.png';
+
+const CreateGame = async ({
+ versus,
+ gameName,
+ startDate,
+ oppoLogo,
+ teamId,
+}) => {
+ try {
+ let finalLogoFile = oppoLogo;
+
+ if (!finalLogoFile) {
+ const response = await fetch(altImage);
+ const blob = await response.blob();
+ finalLogoFile = new File([blob], 'default-logo.png', { type: blob.type });
+ }
+
+ const formData = new FormData();
+ formData.append('versus', versus);
+ formData.append('gameName', gameName);
+ formData.append('startDate', startDate);
+ formData.append('teamId', teamId);
+ formData.append('oppoLogo', finalLogoFile);
+
+ const response = await fetch(
+ 'http://52.78.12.127:8080/api/games/create-game',
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ const errText = await response.text();
+ throw new Error(errText || '경기 생성 실패');
+ }
+
+ return true; // 성공
+ } catch (err) {
+ console.error(err);
+ throw err; // 실패
+ }
+};
+
+export default CreateGame;
diff --git a/front-end/src/components/game/GameDelete.jsx b/front-end/src/components/game/GameDelete.jsx
new file mode 100644
index 0000000..f2dea44
--- /dev/null
+++ b/front-end/src/components/game/GameDelete.jsx
@@ -0,0 +1,43 @@
+import { useNavigate } from 'react-router-dom';
+
+const GameDelete = ({ gameId, teamId }) => {
+ const navigate = useNavigate();
+
+ const handleDeleteGame = async () => {
+ const confirmDelete = window.confirm('정말로 경기를 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/games/delete-game',
+ {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gameId: Number(gameId) }),
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 완료');
+ navigate(`/team/${teamId}`);
+ } else {
+ const error = await res.text();
+ alert('삭제 실패: ' + error);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ return (
+ handleDeleteGame()}
+ className="w-full max-w-[360px] bg-red-100 text-red-600 font-semibold py-[1.2vh] rounded-full hover:bg-red-200"
+ >
+ 🗑 경기 삭제
+
+ );
+};
+
+export default GameDelete;
diff --git a/front-end/src/components/game/GameFormation.jsx b/front-end/src/components/game/GameFormation.jsx
new file mode 100644
index 0000000..52f1a96
--- /dev/null
+++ b/front-end/src/components/game/GameFormation.jsx
@@ -0,0 +1,89 @@
+import field from '../../img/field.png';
+import playerIcon from '../../img/player.png';
+
+const GameFormation = ({
+ positionList,
+ selectedQuarter,
+ setSelectedQuarter,
+ quarters,
+ currentQuarter,
+ setCurrentQuarter,
+ currentQuarterIndex,
+ users,
+ setUsers,
+ getCount,
+}) => {
+ const count = getCount();
+
+ return (
+ <>
+
+ {quarters.map((quarter) => (
+
{
+ setSelectedQuarter(quarter.quarter);
+ setCurrentQuarter(quarter);
+ }}
+ >
+ {quarter.quarter}
+
+ ))}
+
+
+ {/* 인원 */}
+
+ Starting: {users.length}
+ Lineup: {count}
+
+
+
+
+ {positionList.map(({ key: positionKey, top, left }) => {
+ const player = currentQuarter ? currentQuarter[positionKey] : null;
+
+ return player ? (
+
+
+
+
+
+ {player.userName}
+
+
+ ) : null;
+ })}
+
+
+ >
+ );
+};
+
+export default GameFormation;
diff --git a/front-end/src/components/game/GameInfo.jsx b/front-end/src/components/game/GameInfo.jsx
new file mode 100644
index 0000000..445dd72
--- /dev/null
+++ b/front-end/src/components/game/GameInfo.jsx
@@ -0,0 +1,167 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import GameJoin from './GameJoin';
+import GameFormation from './GameFormation';
+
+const GameInfo = ({
+ setUpdate,
+ game,
+ setGame,
+ users,
+ setUsers,
+ positionList,
+ quarters,
+ setQuarters,
+ selectedQuarter,
+ setSelectedQuarter,
+ currentQuarter,
+ setCurrentQuarter,
+ currentQuarterIndex,
+ getCount,
+}) => {
+ const { gameId } = useParams();
+ const [hasPermission, setHasPermission] = useState(false);
+ const [checked, setChecked] = useState(false);
+ const [team, setTeam] = useState('');
+ const [teamManager, setTeamManager] = useState('');
+ const userId = sessionStorage.getItem('userId');
+ const userMail = sessionStorage.getItem('userMail');
+ sessionStorage.setItem('gameId', gameId);
+
+ useEffect(() => {
+ const checkPermission = async () => {
+ if (!userMail || !game.teamId) return;
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/teams/${game.teamId}`,
+ );
+ const data = await response.json();
+ setTeam(data);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setChecked(true);
+ }
+ };
+
+ checkPermission();
+ }, [userMail, game.teamId]);
+
+ useEffect(() => {
+ const fetchTeamManager = async () => {
+ if (!team) return;
+
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/check/id/${team.teamManagerId}`,
+ );
+ const data = await response.json();
+ setTeamManager(data);
+ if (data.userId == userId) {
+ setHasPermission(true);
+ }
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchTeamManager();
+ }, [team]);
+
+ const handleRemovePosition = async () => {
+ try {
+ const updated = Object.fromEntries(
+ Object.entries(game).map(([key, value]) =>
+ value?.userMail === userMail ? [key, null] : [key, value],
+ ),
+ );
+
+ const response = await fetch(
+ 'http://52.78.12.127:8080/api/games/update-game',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updated),
+ },
+ );
+ if (!response.ok) alert(await response.text());
+ } catch (err) {
+ console.error(err);
+ alert('포메이션 제거 중 오류');
+ }
+ };
+
+ if (!checked)
+ return 권한 확인 중...
;
+
+ return (
+
+
+
+ {/* 날짜 + VS 상대팀 */}
+
+ 경기 일정
+
+
+ {game.date.slice(0, 10)}
+
+ {team.teamName} VS {game.versus}
+
+
+ {/* 필드 */}
+
+
+ {/* 팀명 / 매니저명 + 톱니바퀴 (한 줄, 좌측 정렬) */}
+
+
+
+ {team?.teamName}
+
+
+ 매니저 : {teamManager.userName}
+
+
+ {hasPermission && (
+
setUpdate(true)}
+ className="text-gray-600 hover:text-black text-[2.2vh]"
+ title="경기 수정"
+ >
+ ⚙️
+
+ )}
+
+
+ {/* 참가/취소 버튼 */}
+
+
+
+
+
+
+ );
+};
+
+export default GameInfo;
diff --git a/front-end/src/components/game/GameJoin.jsx b/front-end/src/components/game/GameJoin.jsx
new file mode 100644
index 0000000..aafb67b
--- /dev/null
+++ b/front-end/src/components/game/GameJoin.jsx
@@ -0,0 +1,133 @@
+import { Link } from 'react-router-dom';
+import soccerFieldIcon from '../../img/field.png'; // 축구장 아이콘 이미지 경로
+
+const GameJoin = ({
+ game,
+ setGame,
+ userMail,
+ users,
+ gameId,
+ hasPermission,
+ handleRemovePosition,
+ positionList,
+ currentQuarter,
+ quarters,
+}) => {
+ const isAlreadyJoined = users?.some((user) => user.userMail === userMail);
+
+ const handleJoinGame = async () => {
+ if (isAlreadyJoined) return alert('이미 참가 중입니다.');
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/quarters/${currentQuarter.quarterId}/insert-to-quarter`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail }),
+ },
+ );
+ if (res.ok) {
+ alert('경기 참가가 완료되었습니다.');
+ window.location.reload();
+ } else {
+ alert(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류가 발생했습니다.');
+ }
+ };
+
+ const handleLeaveGame = async () => {
+ if (!isAlreadyJoined) return alert('참가 중이 아닙니다.');
+ const updatedGame = { ...currentQuarter };
+
+ positionList.map(({ key }) => {
+ if (currentQuarter[key]) {
+ const isJoining = currentQuarter[key].userMail === userMail;
+ if (isJoining) {
+ updatedGame[key] = null;
+ }
+ }
+ });
+
+ try {
+ if (hasPermission) {
+ await handleRemovePosition();
+ }
+
+ const response = await fetch(
+ 'http://52.78.12.127:8080/api/quarters/update-quarter',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updatedGame),
+ },
+ );
+
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/quarters/${currentQuarter.quarterId}/remove-from-quarter`,
+ {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail }),
+ },
+ );
+
+ if (res.ok && response.ok) {
+ alert('경기 참가 취소가 완료되었습니다.');
+ window.location.reload();
+ } else {
+ alert(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('경기 취소 중 서버 오류가 발생했습니다.');
+ }
+ };
+
+ if (!currentQuarter) return;
+
+ return (
+
+
+ {/* 경기 참가 버튼 */}
+
+ ⚽
+ 경기 참가
+
+
+ {/* 참가 취소 버튼 (연한 빨간색) */}
+
+ ❌
+ 참가 취소
+
+
+
+ {/* 포메이션 요청 버튼 */}
+ {!hasPermission && (
+
+
+
+ 포메이션 요청
+
+
+ )}
+
+ );
+};
+
+export default GameJoin;
diff --git a/front-end/src/components/game/GameUpdataFormation.jsx b/front-end/src/components/game/GameUpdataFormation.jsx
new file mode 100644
index 0000000..16e348c
--- /dev/null
+++ b/front-end/src/components/game/GameUpdataFormation.jsx
@@ -0,0 +1,102 @@
+import field from '../../img/field.png';
+import playerIcon from '../../img/player.png';
+import uniformIcon from '../../img/uniform.png';
+import grayUniformIcon from '../../img/grayUniform.png';
+
+const GameUpdateFormation = ({
+ users,
+ setSelectedPositionKey,
+ setIsOpen,
+ getCount,
+ positionList,
+ game,
+ quarters,
+ selectedQuarter,
+ setSelectedQuarter,
+ currentQuarter,
+ setCurrentQuarter,
+ team,
+}) => {
+ const count = getCount();
+
+ const handlePositionClick = (positionKey) => {
+ setSelectedPositionKey(positionKey);
+ setIsOpen(true);
+ };
+
+ if (!quarters) return;
+
+ return (
+ <>
+
+ {quarters.map((quarter) => (
+
{
+ setSelectedQuarter(quarter.quarter);
+ setCurrentQuarter(quarter);
+ }}
+ >
+ {quarter.quarter}
+
+ ))}
+
+
+ {/* 라인업 통계 */}
+
+ Starting: {users.length}
+ Lineup: {count}
+
+
+ {/* 필드 이미지 및 포지션 */}
+
+
+ {positionList.map(({ key, label, top, left }) => (
+
handlePositionClick(key)}>
+
+
+ user.userMail === currentQuarter[key].userMail,
+ )
+ ? grayUniformIcon
+ : uniformIcon
+ : playerIcon
+ }
+ alt="player"
+ className="w-[4.5vh] h-[4.5vh] object-contain"
+ />
+
+ {currentQuarter[key] ? currentQuarter[key].userName : label}
+
+
+
+ ))}
+
+
+ >
+ );
+};
+
+export default GameUpdateFormation;
diff --git a/front-end/src/components/game/GameUpdate.jsx b/front-end/src/components/game/GameUpdate.jsx
new file mode 100644
index 0000000..05d7643
--- /dev/null
+++ b/front-end/src/components/game/GameUpdate.jsx
@@ -0,0 +1,177 @@
+import { useEffect, useState } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import GameDelete from './GameDelete';
+import GameUpdateFormation from './GameUpdataFormation';
+import QuarterDelete from './QuarterDelete';
+
+const GameUpdate = ({
+ setUpdate,
+ setSelectedPositionKey,
+ setIsOpen,
+ game,
+ setGame,
+ positionList,
+ getCount,
+ users,
+ currentQuarter,
+ setCurrentQuarter,
+ quarters,
+ selectedQuarter,
+ setSelectedQuarter,
+ currentQuarterIndex,
+}) => {
+ const { gameId } = useParams();
+ const [teamManager, setTeamManager] = useState('');
+ const [team, setTeam] = useState('');
+
+ useEffect(() => {
+ const fetchGame = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/games/game/${gameId}`,
+ );
+ const data = await res.json();
+ setGame(data);
+ };
+ fetchGame();
+ }, [gameId]);
+
+ useEffect(() => {
+ const fetchTeamManager = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/teams/${game.teamId}`,
+ );
+ const data = await response.json();
+ setTeam(data);
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/check/id/${data.teamManagerId}`,
+ );
+ const d = await res.json();
+ setTeamManager(d);
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchTeamManager();
+ }, [game]);
+
+ const handleSubmit = async () => {
+ try {
+ const response = await fetch(
+ 'http://52.78.12.127:8080/api/quarters/update-quarter',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(currentQuarter),
+ },
+ );
+
+ if (response.ok) {
+ alert('포지션이 저장되었습니다.');
+ setUpdate(false);
+ } else {
+ alert('저장 실패: ' + (await response.text()));
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류 발생');
+ }
+ };
+
+ const insertQuarter = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/quarters/create-quarter`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ quarterOrdinalNum: quarters.length + 1,
+ gameId: gameId,
+ }),
+ },
+ );
+ if (response.ok) {
+ alert('쿼터 추가 성공');
+ window.location.reload();
+ } else {
+ const errorText = await response.text();
+ console.error(errorText);
+ console.error(response.status);
+ alert('쿼터 추가 실패');
+ }
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ if (!game) return 로딩 중...
;
+
+ return (
+
+ {/* 상단: 날짜 및 VS 정보 */}
+
+ {game.date.slice(0, 10)} VS {game.versus}
+
+
+
+
+
+
+ {team.teamName}
+
+
+ 매니저 : {teamManager.userName}
+
+
+
+ {/* 버튼 영역 */}
+
+ {/* 저장 + 요청 확인 */}
+
+
+ 💾 저장
+
+
+
+ 📋 요청 확인
+
+
+
+
+
+ {/* 삭제 버튼 */}
+ insertQuarter()}
+ className="w-full max-w-[360px] bg-green-100 text-green-600 font-semibold py-[1.2vh] rounded-full hover:bg-green-200"
+ >
+ 쿼터 추가
+
+
+
+
+
+
+ );
+};
+
+export default GameUpdate;
diff --git a/front-end/src/components/game/PopUp.jsx b/front-end/src/components/game/PopUp.jsx
new file mode 100644
index 0000000..3b8531c
--- /dev/null
+++ b/front-end/src/components/game/PopUp.jsx
@@ -0,0 +1,326 @@
+import styled from 'styled-components';
+
+const PopupBox = styled.div`
+ position: fixed;
+ width: 100%;
+ min-height: 7vh;
+ height: ${({ $open }) => ($open ? '50vh' : '7vh')};
+ background: white;
+ transition: height 0.3s ease-in-out;
+ bottom: 56px;
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+ border-top-left-radius: 12px;
+ border-top-right-radius: 20px;
+ padding: 1vh 2vh;
+ max-width: 460px;
+ z-index: 500;
+ overflow-y: scroll;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ box-sizing: border-box;
+ &::-webkit-scrollbar {
+ width: 0px;
+ background: transparent;
+ }
+`;
+
+const PopupButton = styled.button`
+ width: 100%;
+ background-color: white;
+ border: none;
+ font-size: 2.3vh;
+ cursor: pointer;
+ padding: 1vh 0;
+ font-weight: bold;
+ color: #2c3e50;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5vh;
+ &:hover {
+ color: #00b894;
+ }
+ &:active {
+ transform: scale(0.95);
+ }
+`;
+
+const PopupTitle = styled.h4`
+ margin-top: 2vh;
+ margin-bottom: 1vh;
+ font-weight: bold;
+ padding-left: 1vh;
+`;
+
+const UsersBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1vh;
+`;
+
+const UserCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 1.2vh 1.5vh;
+ border-radius: 1vh;
+ background-color: #f9f9f9;
+ border-left: 5px solid #dcdde1;
+ transition: all 0.2s ease;
+ &:hover {
+ background-color: #ecf0f1;
+ }
+`;
+
+const Badge = styled.span`
+ display: inline-block;
+ background-color: ${({ role }) => {
+ if (['ST', 'CF', 'LS', 'RS', 'LW', 'RW'].includes(role)) return '#ff7675'; // FW
+ if (
+ [
+ 'CAM',
+ 'CM',
+ 'CDM',
+ 'LAM',
+ 'RAM',
+ 'LCM',
+ 'RCM',
+ 'LDM',
+ 'RDM',
+ 'LM',
+ 'RM',
+ ].includes(role)
+ )
+ return '#55efc4'; // MF
+ if (['LB', 'RB', 'LCB', 'RCB', 'SW', 'LWB', 'RWB'].includes(role))
+ return '#74b9ff'; // DF
+ if (['GK'].includes(role)) return '#fdcb6e'; // GK
+ return '#b2bec3'; // fallback
+ }};
+ color: white;
+ border-radius: 1vh;
+ padding: 0.3vh 0.7vh;
+ font-size: 1.2vh;
+ margin-right: 0.4vh;
+`;
+
+const UserNameBox = styled.div`
+ font-size: 1.9vh;
+ font-weight: bold;
+ color: #2d3436;
+ margin-bottom: 0.5vh;
+ display: flex;
+ align-items: center;
+ gap: 0.6vh;
+`;
+
+const UserPositionBox = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5vh;
+`;
+
+const ChangeButton = styled.button`
+ background-color: white;
+ color: #c0392b;
+ border: 2px solid #c0392b;
+ width: 100%;
+ height: 5vh;
+ font-size: 1.8vh;
+ border-radius: 3vh;
+ margin-top: 2vh;
+ box-sizing: border-box;
+ transition: all 0.2s;
+ &:hover {
+ background-color: #c0392b;
+ color: white;
+ transform: scale(0.97);
+ }
+ &:active {
+ transform: scale(0.93);
+ }
+`;
+
+const PopUp = ({
+ isOpen,
+ selectedPositionKey,
+ setSelectedPositionKey,
+ users,
+ game,
+ setGame,
+ setIsOpen,
+ togglePopup,
+ currentQuarter,
+ setCurrentQuarter,
+ team,
+}) => {
+ const positionKeyToRole = {
+ stId: 'ST',
+ lsId: 'LS',
+ rsId: 'RS',
+ lwId: 'LW',
+ rwId: 'RW',
+ cfId: 'CF',
+ camId: 'CAM',
+ lamId: 'LAM',
+ ramId: 'RAM',
+ cmId: 'CM',
+ lcmId: 'LCM',
+ rcmId: 'RCM',
+ lmId: 'LM',
+ rmId: 'RM',
+ cdmId: 'CDM',
+ ldmId: 'LDM',
+ rdmId: 'RDM',
+ lwbId: 'LWB',
+ rwbId: 'RWB',
+ lbId: 'LB',
+ rbId: 'RB',
+ lcbId: 'LCB',
+ rcbId: 'RCB',
+ swId: 'SW',
+ gkId: 'GK',
+ };
+
+ const handleUserSelect = (user) => {
+ let targetPositionKey = selectedPositionKey;
+ if (!targetPositionKey) {
+ const emptyPosition = Object.entries(game || {}).find(
+ ([, value]) => !value,
+ );
+ if (!emptyPosition) {
+ alert('모든 포지션이 이미 배정되었습니다.');
+ return;
+ }
+ targetPositionKey = emptyPosition[0];
+ }
+ setCurrentQuarter((prevCurrentQuarter) => ({
+ ...prevCurrentQuarter,
+ [targetPositionKey]: user,
+ }));
+ setSelectedPositionKey(null);
+ setIsOpen(false);
+ };
+
+ const assignedUserMails = new Set(
+ currentQuarter
+ ? Object.values(currentQuarter)
+ .map((user) => user?.userMail)
+ .filter(Boolean)
+ : [],
+ );
+
+ const preferredUsers =
+ users && selectedPositionKey
+ ? users.filter(
+ (user) =>
+ !assignedUserMails.has(user.userMail) &&
+ [
+ user.firstPosition,
+ user.secondPosition,
+ user.thirdPosition,
+ ].includes(positionKeyToRole[selectedPositionKey]),
+ )
+ : [];
+
+ const otherUsers = users
+ ? users.filter((user) =>
+ selectedPositionKey
+ ? !assignedUserMails.has(user.userMail) &&
+ !preferredUsers.includes(user)
+ : !assignedUserMails.has(user.userMail),
+ )
+ : [];
+
+ const handleRemovePlayer = () => {
+ if (!selectedPositionKey) return;
+ setCurrentQuarter((prevCurrentQuarter) => ({
+ ...prevCurrentQuarter,
+ [selectedPositionKey]: null,
+ }));
+ setSelectedPositionKey(null);
+ setIsOpen(false);
+ };
+
+ const renderUserCard = (user) => {
+ const isGuest = !team?.users?.some(
+ (teamUser) => teamUser.userMail === user.userMail,
+ );
+
+ return (
+ handleUserSelect(user)}>
+
+
+ 👤
+
+ {user.userName}
+ {isGuest && (
+
+ 용병
+
+ )}
+
+ {[user.firstPosition, user.secondPosition, user.thirdPosition]
+ .filter(Boolean)
+ .map((pos, i) => (
+
+ {pos}
+
+ ))}
+
+
+
+ );
+ };
+
+ return (
+
+
+ {isOpen ? '▼ 닫기' : '▲ 참가자 명단'}
+
+ {isOpen && (
+ <>
+ {selectedPositionKey && (
+ <>
+ 추천 선수
+ {preferredUsers.length > 0 ? (
+
+ {preferredUsers.map((user) => renderUserCard(user))}
+
+ ) : (
+
+ 추천 선수가 없습니다
+
+ )}
+ >
+ )}
+
+ {selectedPositionKey && (
+ 선수 제거
+ )}
+
+ 참가자 명단
+ {otherUsers.length > 0 ? (
+
+ {otherUsers.map((user) => renderUserCard(user))}
+
+ ) : (
+
+ 참가자가 없습니다
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default PopUp;
diff --git a/front-end/src/components/game/QuarterDelete.jsx b/front-end/src/components/game/QuarterDelete.jsx
new file mode 100644
index 0000000..6b75473
--- /dev/null
+++ b/front-end/src/components/game/QuarterDelete.jsx
@@ -0,0 +1,39 @@
+const QuarterDelete = ({ quarterId }) => {
+ const handleDeleteGame = async () => {
+ const confirmDelete = window.confirm('정말로 경기를 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/quarters/delete-quarter',
+ {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ quarterId: Number(quarterId) }),
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 완료');
+ window.location.reload();
+ } else {
+ const error = await res.text();
+ alert('삭제 실패: ' + error);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ return (
+ handleDeleteGame()}
+ className="w-full max-w-[360px] bg-red-100 text-red-600 font-semibold py-[1.2vh] rounded-full hover:bg-red-200"
+ >
+ 🗑 쿼터 삭제
+
+ );
+};
+
+export default QuarterDelete;
diff --git a/front-end/src/components/lib/ExpertFeed.jsx b/front-end/src/components/lib/ExpertFeed.jsx
new file mode 100644
index 0000000..a6a0e5d
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeed.jsx
@@ -0,0 +1,148 @@
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import useUser from '../../hooks/api/get/useUser';
+import { useEffect } from 'react';
+
+const Card = styled.div`
+ background: #fff;
+ border-radius: 0;
+ border: 1px solid #e5e5e5;
+ overflow: hidden;
+ transition: box-shadow 0.2s;
+ &:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ }
+`;
+
+const Thumbnail = styled.div`
+ height: 180px;
+ background: #f1f3f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+`;
+
+const ThumbImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`;
+
+const ThumbVideo = styled.video`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`;
+
+const Content = styled.div`
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+`;
+
+const TitleRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+`;
+
+const Title = styled.h3`
+ font-size: 1rem;
+ font-weight: 700;
+ color: #222;
+ margin: 0;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const ProfileButton = styled.button`
+ background: #10b981;
+ color: #fff;
+ border: none;
+ font-size: 0.75rem;
+ padding: 6px 12px;
+ border-radius: 999px;
+ white-space: nowrap;
+ cursor: pointer;
+ &:hover {
+ background: #059669;
+ }
+`;
+
+const AuthorInfo = styled.div`
+ margin: 12px 0 8px;
+ font-size: 0.85rem;
+ color: #666;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ &::before {
+ content: '👨🏫';
+ }
+`;
+
+const Description = styled.p`
+ font-size: 0.9rem;
+ color: #444;
+ line-height: 1.5;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+`;
+
+const ExpertFeed = ({ expertFeed }) => {
+ const { user, fetchUser } = useUser();
+
+ useEffect(() => {
+ fetchUser(expertFeed.userId);
+ }, [expertFeed]);
+
+ return (
+
+
+
+ {expertFeed.fileType.startsWith('image/') ? (
+
+ ) : expertFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+ 📁 미디어 없음
+ )}
+
+
+
+ {expertFeed.title}
+ {
+ e.preventDefault();
+ window.location.href = `/profile/${expertFeed.userId}`;
+ }}
+ >
+ 전문가 프로필 보기
+
+
+ {user?.userName || '익명 전문가'}
+ {expertFeed.content}
+
+
+
+ );
+};
+
+export default ExpertFeed;
diff --git a/front-end/src/components/lib/ExpertFeedCreate.jsx b/front-end/src/components/lib/ExpertFeedCreate.jsx
new file mode 100644
index 0000000..32aa109
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeedCreate.jsx
@@ -0,0 +1,262 @@
+import { useEffect, useRef, useState } from 'react';
+import styled, { keyframes } from 'styled-components';
+
+/* 애니메이션 */
+const slideUp = keyframes`
+ from { transform: translateY(10%); opacity:0; }
+ to { transform: translateY(0); opacity:1; }
+`;
+
+/* 모달 백드롭 + 시트 */
+const Backdrop = styled.div`
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+`;
+
+const Sheet = styled.div`
+ width: 92%;
+ max-width: 430px;
+ background: #fff;
+ border-radius: 0;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+ display: flex;
+ flex-direction: column;
+ animation: ${slideUp} 0.25s ease-out;
+`;
+
+/* 상단바 */
+const TopBar = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #333;
+ button {
+ background: none;
+ border: none;
+ font-size: 14px;
+ color: #00c851;
+ font-weight: 500;
+ cursor: pointer;
+ }
+`;
+
+/* 제목 + 첨부 아이콘 */
+const TitleLine = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: 1px solid #e0e0e0;
+ input {
+ flex: 1;
+ border: none;
+ font-size: 15px;
+ background: transparent;
+ color: #333;
+ &:focus {
+ outline: none;
+ }
+ }
+ label {
+ cursor: pointer;
+ }
+ svg {
+ width: 20px;
+ height: 20px;
+ stroke: #388e3c;
+ }
+`;
+
+const HiddenFile = styled.input`
+ display: none;
+`;
+
+/* 본문 */
+const BodyInput = styled.textarea`
+ border: none;
+ resize: none;
+ height: 22vh;
+ font-size: 15px;
+ padding: 16px;
+ color: #333;
+ &::placeholder {
+ color: #bdbdbd;
+ }
+ &:focus {
+ outline: none;
+ }
+`;
+
+/* 미리보기 */
+const FilePreviewBox = styled.div`
+ display: flex;
+ justify-content: center;
+ padding: 16px 0;
+ border-bottom: 1px solid #e0e0e0;
+`;
+
+const PreviewImg = styled.img`
+ width: 100%;
+ max-height: 200px;
+ object-fit: cover;
+ border-radius: 0;
+`;
+
+const PreviewVideo = styled.video`
+ width: 100%;
+ max-height: 200px;
+ object-fit: cover;
+ border-radius: 0;
+`;
+
+/* 하단 등록 버튼 */
+const Footer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px;
+`;
+
+const Save = styled.button`
+ background: ${({ disabled }) => (disabled ? '#b2dfbc' : '#00c851')};
+ opacity: ${({ disabled }) => (disabled ? 0.7 : 1)};
+ border: none;
+ border-radius: 999px;
+ color: white;
+ font-size: 14px;
+ padding: 10px 20px;
+ cursor: pointer;
+ transition: background 0.2s;
+ &:hover:enabled {
+ background: #00b44a;
+ }
+`;
+
+const ExpertFeedCreate = ({ setShowModal }) => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [file, setFile] = useState(null);
+ const [fileURL, setFileURL] = useState(null);
+ const modalRef = useRef(null);
+ const userId = sessionStorage.getItem('userId');
+
+ useEffect(() => {
+ const keyHandler = (e) => {
+ if (e.key === 'Escape') setShowModal(false);
+ };
+ window.addEventListener('keydown', keyHandler);
+ return () => window.removeEventListener('keydown', keyHandler);
+ }, [setShowModal]);
+
+ const onSelect = (f) => {
+ if (f) {
+ setFile(f);
+ setFileURL(URL.createObjectURL(f));
+ }
+ };
+
+ const handleFile = (e) => onSelect(e.target.files?.[0]);
+
+ const submit = async () => {
+ if (!title.trim() || !content.trim())
+ return alert('제목과 내용을 입력하세요');
+
+ try {
+ const formData = new FormData();
+ if (file) formData.append('file', file);
+ formData.append('userId', userId);
+ formData.append('title', title);
+ formData.append('content', content);
+
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/users/files/expert/upload',
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) throw new Error('등록 실패');
+
+ alert('전문가 피드 등록 완료!');
+ setShowModal(false);
+ window.location.reload();
+ } catch (e) {
+ alert('에러가 발생했습니다.');
+ }
+ };
+
+ return (
+ setShowModal(false)}>
+ e.stopPropagation()}>
+
+ 전문가 게시글 작성
+ setShowModal(false)}>취소
+
+
+
+ setTitle(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+ {fileURL && (
+
+ {file.type.startsWith('image/') ? (
+
+ ) : file.type.startsWith('video/') ? (
+
+ ) : (
+ 지원하지 않는 파일 형식입니다.
+ )}
+
+ )}
+
+ setContent(e.target.value)}
+ />
+
+
+
+
+ );
+};
+
+export default ExpertFeedCreate;
diff --git a/front-end/src/components/lib/ExpertFeedDelete.jsx b/front-end/src/components/lib/ExpertFeedDelete.jsx
new file mode 100644
index 0000000..3beb6a7
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeedDelete.jsx
@@ -0,0 +1,40 @@
+import { useNavigate } from 'react-router-dom';
+
+const ExpertFeedDelete = ({ feedId, expertFeed, renderButton }) => {
+ const navigate = useNavigate();
+
+ const handleDelete = async () => {
+ const confirmDelete = window.confirm('정말로 게시글을 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/${feedId}`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 완료');
+ navigate(`/lib`);
+ } else {
+ const error = await res.text();
+ alert('삭제 실패: ' + error);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ // 🔥 여기서 renderButton로 넘겨받았으면 외부 커스텀 버튼 사용
+ if (renderButton) {
+ return renderButton({ onClick: handleDelete });
+ }
+
+ // 기본 버튼
+ return 삭제 ;
+};
+
+export default ExpertFeedDelete;
diff --git a/front-end/src/components/lib/ExpertFeedDetail.jsx b/front-end/src/components/lib/ExpertFeedDetail.jsx
new file mode 100644
index 0000000..46df445
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeedDetail.jsx
@@ -0,0 +1,174 @@
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import ExpertFeedUpdate from './ExpertFeedUpdate';
+import ExpertFeedDelete from './ExpertFeedDelete';
+import FeedCommentList from '../common/feedcomment/FeedCommentList';
+
+// 상단 고정 헤더
+const Header = () => {
+ const navigate = useNavigate();
+ return (
+
+ navigate(-1)}
+ className="absolute left-4 text-green-700 font-semibold text-xl"
+ type="button"
+ >
+ ←
+
+ 게시글 보기
+
+ );
+};
+
+const ExpertFeedDetail = ({ feedId }) => {
+ const [update, setUpdate] = useState(false);
+ const [expertFeed, setExpertFeed] = useState(null);
+ const [showMenu, setShowMenu] = useState(false);
+ const [isAuthor, setIsAuthor] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const videoRef = useRef(null);
+ const userId = sessionStorage.getItem('userId');
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const res = await fetch(`http://52.78.12.127:8080/api/users/files/file/${feedId}`);
+ if (!res.ok) throw new Error('네트워크 에러');
+ const data = await res.json();
+ setExpertFeed(data);
+ setIsAuthor(data.userId == userId);
+ } catch {
+ setExpertFeed(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchData();
+ }, [feedId, userId]);
+
+ const toExpertProfile = () => {
+ if (expertFeed) {
+ navigate(`/profile/${expertFeed.userId}`);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!expertFeed) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* 상단 고정 헤더 */}
+
+
+ {/* 헤더 높이만큼 여백 확보 */}
+
+
+ {/* 작성자 메뉴 */}
+ {isAuthor && (
+
+
setShowMenu((prev) => !prev)}
+ className="text-gray-400 hover:text-gray-700 text-xl"
+ aria-label="게시글 메뉴 열기"
+ type="button"
+ >
+ ⋯
+
+ {showMenu && (
+
+ {
+ setUpdate(true);
+ setShowMenu(false);
+ }}
+ className="w-full px-4 py-2 text-sm text-gray-800 hover:bg-gray-100"
+ type="button"
+ >
+ ✏️ 수정
+
+ (
+ {
+ onClick();
+ setShowMenu(false);
+ }}
+ className="w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
+ type="button"
+ >
+ 🗑️ 삭제
+
+ )}
+ />
+
+ )}
+
+ )}
+
+ {/* 제목 */}
+
+ {expertFeed.title}
+
+
+ {/* 미디어 */}
+
+ {expertFeed.fileType?.startsWith('image/') ? (
+
+ ) : expertFeed.fileType?.startsWith('video/') ? (
+
+ ) : (
+
+ 📁 지원되지 않는 파일입니다.
+
+ )}
+
+
+ {/* 본문 */}
+
+ {expertFeed.content}
+
+
+ {/* 댓글 + 좋아요 + 전문가 프로필 버튼 */}
+
+
+ {/* 수정 모달 */}
+ {update &&
}
+
+
+
+ );
+};
+
+export default ExpertFeedDetail;
diff --git a/front-end/src/components/lib/ExpertFeedList.jsx b/front-end/src/components/lib/ExpertFeedList.jsx
new file mode 100644
index 0000000..a045466
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeedList.jsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import ExpertFeed from './ExpertFeed';
+
+const GridContainer = styled.div`
+ max-width: 100vw;
+ margin: 2rem auto;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ justify-content: center;
+ gap: 1.8rem;
+ padding: 0 1rem;
+`;
+
+const ExpertFeedList = () => {
+ const [expertFeedList, setExpertFeedList] = useState([]);
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/users/files/expert',
+ );
+ if (!res.ok) throw new Error('Fetch error');
+ const data = await res.json();
+ setExpertFeedList(data);
+ } catch (e) {
+ console.error(e);
+ setExpertFeedList([]);
+ }
+ }
+ fetchData();
+ }, []);
+
+ return (
+
+ {expertFeedList.map((feed) => (
+
+ ))}
+
+ );
+};
+
+export default ExpertFeedList;
diff --git a/front-end/src/components/lib/ExpertFeedUpdate.jsx b/front-end/src/components/lib/ExpertFeedUpdate.jsx
new file mode 100644
index 0000000..1a9d617
--- /dev/null
+++ b/front-end/src/components/lib/ExpertFeedUpdate.jsx
@@ -0,0 +1,181 @@
+import { useState, useRef } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const ImagePreview = styled.img`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+`;
+
+const StyledVideo = styled.video`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+ border-radius: 1vh;
+`;
+
+const ExpertFeedUpdate = ({ setUpdate, expertFeed }) => {
+ const userId = expertFeed.userId;
+ const [title, setTitle] = useState(expertFeed.title);
+ const [content, setContent] = useState(expertFeed.content);
+ const [file, setFile] = useState(''); // 파일 객체 (image or video)
+ const [fileType, setFileType] = useState(expertFeed.fileType);
+ const [fileUrl, setFileUrl] = useState(
+ `http://52.78.12.127:8080/media/user/${expertFeed.realFileName}`,
+ ); // 미리보기 URL
+ const fileInputRef = useRef(null);
+
+ const handleFileUpload = (e) => {
+ const uploaded = e.target.files[0];
+ if (uploaded) {
+ setFile(uploaded);
+ setFileType(uploaded.type);
+ setFileUrl(URL.createObjectURL(uploaded));
+ }
+ };
+
+ const handleClickFileInput = () => {
+ fileInputRef.current?.click(); // 숨겨진 input을 트리거
+ };
+
+ const handleUpdate = async () => {
+ if (!title || !content) {
+ alert('내용을 전부 입력해주세요.');
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('userId', userId);
+ formData.append('title', title);
+ formData.append('content', content);
+
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileInfo/${expertFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (file) {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileData/${expertFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) {
+ alert((await res.text()) || '게시글 파일 수정 실패');
+ }
+ }
+
+ if (response.ok) {
+ alert('게시글 수정 완료!');
+ window.location.reload();
+ } else {
+ alert((await response.text()) || '게시글 수정 실패');
+ }
+ } catch (error) {
+ console.error('게시글 수정 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+ setUpdate(false)}
+ className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center z-[9999]"
+ >
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg relative animate-fadeIn"
+ >
+
+
게시글 수정
+ setUpdate(false)}
+ className="text-[2.4vh] bg-none border-none cursor-pointer absolute right-0 top-0"
+ >
+ ✖
+
+
+
+
+
+ 파일
+ 📎
+
+ 파일 변경
+
+
+
+ {fileType?.startsWith('video/') ? (
+
+ ) : (
+
{
+ e.target.src = altImage;
+ }}
+ />
+ )}
+
+
+
+ {/* 제목 */}
+
+
+ 제목 ✏️
+
+
setTitle(e.target.value)}
+ className="w-full text-[1.7vh] p-[1.5vh] border border-gray-300 rounded-[1vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ />
+
+
+ {/* 내용 */}
+
+
+ {/* 등록 버튼 */}
+
+ 수정
+
+
+
+ );
+};
+
+export default ExpertFeedUpdate;
diff --git a/front-end/src/components/main/FormationCarousel.jsx b/front-end/src/components/main/FormationCarousel.jsx
new file mode 100644
index 0000000..bd8f529
--- /dev/null
+++ b/front-end/src/components/main/FormationCarousel.jsx
@@ -0,0 +1,141 @@
+import { useEffect, useRef, useState } from 'react';
+import formations from '../../data/formation.json';
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+`;
+
+const FormationCarousel = () => {
+ const formationsLength = formations.length;
+ const loopedOnce = useRef(false);
+ const [currentIndex, setCurrentIndex] = useState(
+ Math.floor(Math.random() * formations.length)
+ );
+ const navigate = useNavigate();
+
+ const containerRef = useRef(null);
+ const startX = useRef(0);
+
+ const handleTouchStart = (e) => {
+ startX.current = e.touches[0].clientX;
+ };
+
+ const handleTouchEnd = (e) => {
+ const endX = e.changedTouches[0].clientX;
+ handleSwipe(endX);
+ };
+
+ const handleMouseDown = (e) => {
+ startX.current = e.clientX;
+ };
+
+ const handleMouseUp = (e) => {
+ const endX = e.clientX;
+ handleSwipe(endX);
+ };
+
+ const handleSwipe = (endX) => {
+ const diff = startX.current - endX;
+ const threshold = 10;
+
+ if (diff > threshold && currentIndex < formationsLength - 1) {
+ setCurrentIndex(currentIndex + 1);
+ } else if (diff > threshold && currentIndex === formationsLength - 1) {
+ setCurrentIndex(0);
+ } else if (diff < -threshold && currentIndex > 0) {
+ setCurrentIndex(currentIndex - 1);
+ } else if (diff < -threshold && currentIndex === 0) {
+ setCurrentIndex(formationsLength - 1);
+ }
+ };
+
+
+ const handleMove = () => {
+ navigate(`/lib/detail/formation/${formations[currentIndex].id}`);
+ };
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCurrentIndex((prev) => {
+ if (prev === formationsLength - 1) {
+ clearInterval(interval);
+ loopedOnce.current = true;
+ return 0;
+ }
+ return prev + 1;
+ });
+ }, 1); // 초기에 빠르게 순환 (무한 루프 방지용)
+
+ return () => clearInterval(interval);
+ }, [formationsLength]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCurrentIndex((prev) =>
+ prev === formationsLength - 1 ? 0 : prev + 1
+ );
+ }, 10000); // 5초마다 자동 슬라이드
+
+ return () => clearInterval(interval);
+ }, [formationsLength]);
+
+ const currentFormation = formations[currentIndex];
+
+ return (
+
+
+ {/* 카드 */}
+
+
+
+ {/* 텍스트 오버레이 */}
+
+
+ {currentFormation.title}
+
+
+ {currentFormation.summation}
+
+
+
+
+
+ {/* 인디케이터 */}
+
+ {Array.from({ length: formationsLength }).map((_, idx) => (
+ setCurrentIndex(idx)}
+ className={`w-[1vh] h-[1vh] rounded-full transition ${
+ currentIndex === idx ? 'bg-green-500' : 'bg-gray-300'
+ }`}
+ >
+ ))}
+
+
+ );
+};
+
+export default FormationCarousel;
diff --git a/front-end/src/components/main/MyTeamSection.jsx b/front-end/src/components/main/MyTeamSection.jsx
new file mode 100644
index 0000000..71a893a
--- /dev/null
+++ b/front-end/src/components/main/MyTeamSection.jsx
@@ -0,0 +1,130 @@
+// MyTeamSection.jsx
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+import ScrollContainer from 'react-indiana-drag-scroll';
+import { FaCrown, FaUser } from 'react-icons/fa';
+
+const MyTeamSection = () => {
+ const [teams, setTeams] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const userMail = sessionStorage.getItem('userMail');
+ const userId = sessionStorage.getItem('userId');
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/teams/mail/${userMail}`,
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setTeams(data);
+ } else {
+ console.log(await response.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchTeams();
+ }, [userMail]);
+
+ if (isLoading) {
+ return (
+
+
+
+ My Team
+
+
+
불러오는 중...
+
+ );
+ }
+
+ return (
+
+
+ {/* 라벨 추가 */}
+
+
+ TEAM
+
+
+ 🏆 My Team
+
+
+
+ 더보기
+
+
+
+ {teams.length === 0 ? (
+
+ 소속된 팀이 없습니다.
+
+ ) : (
+
+ {teams.map((team) => (
+
+ {/* 미니 뱃지 추가 */}
+
+ {team.teamManagerId == userId ? (
+ <>
+
+
+ 매니저
+
+ >
+ ) : (
+ <>
+
+
+ 팀원
+
+ >
+ )}
+
+
+ {
+ e.target.src = altImage;
+ }}
+ className="w-[7vh] h-[7vh] rounded-full object-cover mb-[0.5vh] border border-white"
+ alt="team logo"
+ />
+
+ {team.teamName}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default MyTeamSection;
diff --git a/front-end/src/components/main/ScheduleSection.jsx b/front-end/src/components/main/ScheduleSection.jsx
new file mode 100644
index 0000000..3d3bc7d
--- /dev/null
+++ b/front-end/src/components/main/ScheduleSection.jsx
@@ -0,0 +1,266 @@
+// ScheduleSection.jsx
+import { useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import 'dayjs/locale/ko';
+import { Link } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+import ScrollContainer from 'react-indiana-drag-scroll';
+
+dayjs.locale('ko');
+
+const ScheduleSection = () => {
+ const [currentDate] = useState(dayjs());
+ const [teams, setTeams] = useState([]);
+ const [games, setGames] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const userMail = sessionStorage.getItem('userMail');
+ const today = dayjs();
+
+ const getDaysInMonth = () => {
+ const start = currentDate.startOf('month').day();
+ const end = currentDate.daysInMonth();
+ const days = [];
+ for (let i = 0; i < start; i++) days.push('');
+ for (let i = 1; i <= end; i++) days.push(i);
+ return days;
+ };
+
+ const gamesByDate = games.reduce((acc, game) => {
+ const gameDate = dayjs(game.date);
+ if (
+ gameDate.month() === currentDate.month() &&
+ gameDate.year() === currentDate.year()
+ ) {
+ const day = gameDate.date();
+ if (!acc[day]) acc[day] = [];
+ const matchedTeam = teams.find((team) => team.teamId === game.teamId);
+ if (!acc[day].includes(matchedTeam.firstColor)) {
+ acc[day].push(matchedTeam.firstColor);
+ }
+ }
+ return acc;
+ }, {});
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/teams/mail/${userMail}`,
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setTeams(data);
+ } else {
+ console.log(await response.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ }
+ };
+ fetchTeams();
+ }, [userMail]);
+
+ useEffect(() => {
+ if (teams.length === 0) {
+ setIsLoading(false);
+ return;
+ }
+
+ const fetchGamesForAllTeams = async () => {
+ setIsLoading(true);
+
+ try {
+ const promises = teams.map(async (team) => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/games/team/${team.teamId}`,
+ );
+ if (res.ok) {
+ return res.json(); // 응답 JSON 반환
+ } else {
+ throw new Error(`팀 ${team.teamId} 데이터 가져오기 실패`);
+ }
+ });
+
+ const results = await Promise.all(promises);
+
+ setGames(results.flat());
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (teams.length > 0) {
+ fetchGamesForAllTeams();
+ }
+ }, [teams]);
+
+ if (isLoading) {
+ return (
+
+
+
+ 📅 Schedule
+
+
+
불러오는 중...
+
+ );
+ }
+
+ const sortedGames = [...games]
+ .filter((game) => dayjs(game.date).isAfter(today))
+ .sort((a, b) => (dayjs(a.date).isAfter(dayjs(b.date)) ? 1 : -1));
+
+ return (
+
+
+ {/* 라벨 추가 */}
+
+
+ GAME
+
+
+ 📅 Schedule
+
+
+
+ 더보기
+
+
+
+ {/* 스크롤 카드 */}
+
+ {games.length === 0 ? (
+
+ 예정된 경기가 없습니다.
+
+ ) : (
+ sortedGames.map((game) => (
+
+
+ {/* 팀 이름 */}
+
{
+ const matchedTeam = teams.find(
+ (team) => team.teamId === game.teamId,
+ );
+ return matchedTeam ? matchedTeam.firstColor : '#000'; // 기본값은 검정
+ })(),
+ }}
+ >
+ {(() => {
+ const matchedTeam = teams.find(
+ (team) => team.teamId === game.teamId,
+ );
+ return matchedTeam ? matchedTeam.teamName : '알 수 없음';
+ })()}
+
+
+ {dayjs(game.date).format('MM/DD (ddd)')}
+
+
{
+ e.target.src = altImage;
+ }}
+ className="w-[7vh] h-[7vh] rounded-full object-cover mb-[1vh] border border-white"
+ alt="match logo"
+ />
+
+ {(() => {
+ const matchedTeam = teams.find(
+ (team) => team.teamId === game.teamId,
+ );
+ return matchedTeam ? matchedTeam.teamName : '알 수 없음';
+ })()}{' '}
+ VS {game.versus}
+
+
+
+ ))
+ )}
+
+
+ {/* 달력 */}
+
+
+
+ {currentDate.format('YYYY년 M월')}
+
+
+ 더보기
+
+
+
+
+ {['일', '월', '화', '수', '목', '금', '토'].map((day) => (
+
+ {day}
+
+ ))}
+
+
+
+ {getDaysInMonth().map((d, i) => {
+ if (d === '') return
;
+
+ const isToday = d === currentDate.date();
+
+ return (
+
+
{d}
+ {gamesByDate[d] && (
+
+ {gamesByDate[d].map((color, idx) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default ScheduleSection;
diff --git a/front-end/src/components/myTeam/MyTeam.jsx b/front-end/src/components/myTeam/MyTeam.jsx
new file mode 100644
index 0000000..7d79c13
--- /dev/null
+++ b/front-end/src/components/myTeam/MyTeam.jsx
@@ -0,0 +1,35 @@
+import { Link } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+
+const MyTeam = ({ team }) => {
+ return (
+
+ {/* 팀 정보 */}
+
+
{ e.target.src = altImage; }}
+ />
+
+
{team.teamName}
+
{team.location} · {team.users.length}명
+
+
+
+ {/* 자세히 버튼 */}
+
+
+ );
+};
+
+export default MyTeam;
diff --git a/front-end/src/components/myTeam/MyTeamList.jsx b/front-end/src/components/myTeam/MyTeamList.jsx
new file mode 100644
index 0000000..483085b
--- /dev/null
+++ b/front-end/src/components/myTeam/MyTeamList.jsx
@@ -0,0 +1,17 @@
+import MyTeam from './MyTeam';
+
+const MyTeamList = ({ teams }) => {
+ if (teams.length === 0) {
+ return 참여 중인 팀이 없습니다.
;
+ }
+
+ return (
+
+ {teams.map((team, index) => (
+
+ ))}
+
+ );
+};
+
+export default MyTeamList;
diff --git a/front-end/src/components/prgame/PRGame.jsx b/front-end/src/components/prgame/PRGame.jsx
new file mode 100644
index 0000000..ea99057
--- /dev/null
+++ b/front-end/src/components/prgame/PRGame.jsx
@@ -0,0 +1,102 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const PRGame = ({ prGame }) => {
+ const [userName, setUserName] = useState('');
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const cUserMail = prGame?.userMail;
+ if (!cUserMail) return;
+
+ const fetchUserName = async () => {
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/check/${cUserMail}`,
+ );
+
+ const data = await res.json();
+ setUserName(data?.userName); // ✅ API 응답에서 userName 사용
+ } catch (err) {
+ console.error('사용자 이름 불러오기 실패:', err);
+ }
+ };
+
+ fetchUserName();
+ }, [prGame]);
+
+ const handleDelete = async (prGameId) => {
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/pr-games/remove/${prGameId}`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 성공!');
+ window.location.reload();
+ } else {
+ console.error(res);
+ }
+ } catch (err) {
+ console.log(err);
+ }
+ };
+
+ return (
+
+ {/* 좌측 포인트 라인 */}
+
+
+ {/* ‘포메이션’ 라벨 */}
+
+ 포메이션
+
+
+ {/* 카드 본문 */}
+
+
+ {prGame?.prGameName || '제목 없음'}
+
+
+ 작성자: {userName || '익명'}
+
+
+
+ {/* 액션 버튼들 */}
+
+ navigate(`/pr/${prGame.prGameId}`)}
+ className="h-[3.8vh] px-[2.4vh] rounded-full border-2 border-emerald-500
+ text-emerald-600 font-semibold text-[1.55vh]
+ hover:bg-emerald-500 hover:text-white transition
+ mr-[1vh]"
+ >
+ 포메이션 보기
+
+ handleDelete(prGame.prGameId)}
+ className="h-[3.8vh] px-[2.4vh] rounded-full border-2 border-red-500
+ text-red-600 font-semibold text-[1.55vh]
+ hover:bg-red-500 hover:text-white transition
+ "
+ >
+ 삭제
+
+
+
+ );
+};
+
+export default PRGame;
diff --git a/front-end/src/components/prgame/PRGameCreate.jsx b/front-end/src/components/prgame/PRGameCreate.jsx
new file mode 100644
index 0000000..1066d13
--- /dev/null
+++ b/front-end/src/components/prgame/PRGameCreate.jsx
@@ -0,0 +1,162 @@
+import { useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import field from '../../img/field.png';
+import playerIcon from '../../img/player.png';
+import uniformIcon from '../../img/uniform.png';
+import grayUniformIcon from '../../img/grayUniform.png';
+
+const PRGameCreate = ({
+ game,
+ setGame,
+ users,
+ setIsOpen,
+ setSelectedPositionKey,
+ positionList,
+ getCount,
+ currentQuarter,
+ setCurrentQuarter,
+ team,
+}) => {
+ const { quarterId } = useParams();
+ const count = getCount();
+ const userMail = sessionStorage.getItem('userMail');
+ const [title, setTitle] = useState('');
+ const navigate = useNavigate();
+
+ const handlePositionClick = (positionKey) => {
+ setSelectedPositionKey(positionKey);
+ setIsOpen(true);
+ };
+
+ const handleResetFormation = () => {
+ positionList.forEach(({ key }) => {
+ setCurrentQuarter((prev) => ({ ...prev, [key]: null }));
+ });
+ };
+
+ const handleRequestPRGame = async () => {
+ if (!game) return;
+
+ const payload = {
+ prGameName: title,
+ quarterId: Number(quarterId),
+ userMail,
+ };
+
+ positionList.forEach(({ key }) => {
+ const u = currentQuarter[key];
+ if (u?.userMail) payload[key] = u.userMail;
+ });
+
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/pr-games/create', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ alert(`요청 실패: ${data.message || '서버 오류'}`);
+ } else {
+ alert('PR 경기가 성공적으로 저장되었습니다.');
+ navigate(`/pr/list/${quarterId}`);
+ }
+ } catch {
+ alert('요청 중 예외가 발생했습니다.');
+ }
+ };
+
+ if (!game) return 로딩 중...
;
+
+ return (
+
+
+ {/* 제목 입력 */}
+
setTitle(e.target.value)}
+ className="w-full h-[5.5vh] mb-[2vh] text-center text-[2.2vh] rounded-[1vh] border border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#00b894]"
+ />
+
+ {/* 참석 인원 */}
+
+ Starting : {users.length} |{' '}
+ Lineup: {count}
+
+
+ {/* 필드 */}
+
+
+
+ {currentQuarter &&
+ positionList.map(({ key, label, top, left }) => (
+
handlePositionClick(key)}>
+
+
+ user.userMail ===
+ currentQuarter[key].userMail,
+ )
+ ? grayUniformIcon
+ : uniformIcon
+ : playerIcon
+ }
+ alt="player"
+ className="w-[4.5vh] h-[4.5vh] object-contain"
+ />
+
+ {currentQuarter[key]
+ ? currentQuarter[key].userName
+ : label}
+
+
+
+ ))}
+
+
+
+
+ {/* 버튼 */}
+ {/* 포메이션 요청 버튼 */}
+
+ 📌
+ 포메이션 요청
+
+
+ {/* 포메이션 초기화 버튼 */}
+
+ ♻️
+ 포메이션 초기화
+
+
+
+ );
+};
+
+export default PRGameCreate;
diff --git a/front-end/src/components/prgame/PRGameDetail.jsx b/front-end/src/components/prgame/PRGameDetail.jsx
new file mode 100644
index 0000000..32ebf4c
--- /dev/null
+++ b/front-end/src/components/prgame/PRGameDetail.jsx
@@ -0,0 +1,189 @@
+import styled from 'styled-components';
+import field from '../../img/field.png';
+import { useEffect, useState } from 'react';
+import playerIcon from '../../img/player.png';
+import grayUniformIcon from '../../img/grayUniform.png';
+import uniformIcon from '../../img/uniform.png';
+import { useNavigate } from 'react-router-dom';
+
+const PRGameUpdatePageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 8vh;
+`;
+
+/* ───── 새 캡슐 버튼 ───── */
+const ChangeButton = styled.button`
+ width: 40vh;
+ height: 5.5vh;
+ border-radius: 3vh;
+ font-size: 1.8vh;
+ font-weight: 600;
+ background-color: ${({ variant }) =>
+ variant === 'primary' ? '#00C851' : '#000'};
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6vh;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s, background-color 0.15s;
+ margin-bottom: 2vh;
+
+ &:hover {
+ background-color: ${({ variant }) =>
+ variant === 'primary' ? '#00b44b' : '#222'};
+ transform: translateY(-0.3vh) scale(1.05);
+ cursor: pointer;
+ }
+ &:active {
+ transform: scale(0.95);
+ }
+ &:disabled {
+ background-color: #999;
+ cursor: not-allowed;
+ transform: none;
+ }
+`;
+
+const PRGameDetail = ({
+ prGameId,
+ setUpdate,
+ setSelectedPositionKey,
+ setIsOpen,
+ prGame,
+ game,
+ setGame,
+ getPRCount,
+ users,
+ positionList,
+}) => {
+ const gameId = sessionStorage.getItem('gameId');
+ const userMail = sessionStorage.getItem('userMail');
+ const [team, setTeam] = useState('');
+ const count = getPRCount();
+ const navigate = useNavigate();
+
+ /* 초기 포메이션 세팅 */
+ useEffect(() => {
+ if (!prGame) return; // prGame 없으면 실행 안 함
+ const resetFormation = () => {
+ positionList.forEach(({ key }) =>
+ setGame((prev) => ({ ...(prev || {}), [key]: prGame[key] })),
+ );
+ };
+ resetFormation();
+ }, [prGame]);
+
+ useEffect(() => {
+ const fetchTeam = async () => {
+ const gameRes = await fetch(
+ `http://52.78.12.127:8080/api/games/game/${gameId}`,
+ );
+ const gameData = await gameRes.json();
+
+ const teamRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${gameData.teamId}`,
+ );
+ const teamData = await teamRes.json();
+ setTeam(teamData);
+ };
+
+ fetchTeam();
+ }, [gameId]);
+
+ const handleMergeFormation = async () => {
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/quarters/change-from-pr-to-game`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ prGameId: prGameId,
+ }),
+ },
+ );
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ alert(`변환 실패: ${data.message || '서버 오류'}`);
+ } else {
+ alert('PR 포메이션이 실제 경기 포지션으로 적용되었습니다.');
+ navigate(`/game/${gameId}`);
+ }
+ } catch (error) {
+ console.error('예외 발생:', error);
+ alert('요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ const isManager = team.teamManagerMail == userMail;
+
+ if (!prGame || !game) return 로딩 중...
;
+
+ return (
+
+ {prGame.prGameName}
+
+ Starting : {users.length} |{' '}
+ Lineup: {count}
+
+
+
+
+ {positionList.map(({ key, label, top, left }) => {
+ const player = prGame[key] ? prGame[key] : null;
+
+ return player ? (
+
+
+
u.userMail === prGame[key]?.userMail,
+ )
+ ? grayUniformIcon
+ : uniformIcon
+ : playerIcon
+ }
+ alt="player"
+ className="w-[4.5vh] h-[4.5vh] object-contain"
+ />
+
+ {prGame?.[key]?.userName || label}
+
+
+
+ ) : null;
+ })}
+
+
+
+ setUpdate(true)}>
+ ♻️ 포메이션 수정
+
+ {isManager && (
+ handleMergeFormation()}>
+ 포메이션 병합
+
+ )}
+
+ );
+};
+
+export default PRGameDetail;
diff --git a/front-end/src/components/prgame/PRGameList.jsx b/front-end/src/components/prgame/PRGameList.jsx
new file mode 100644
index 0000000..19b3108
--- /dev/null
+++ b/front-end/src/components/prgame/PRGameList.jsx
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import PRGame from './PRGame';
+
+const PRGameList = () => {
+ const { quarterId } = useParams();
+ const gameId = sessionStorage.getItem('gameId');
+ const userMail = sessionStorage.getItem('userMail');
+ const [data, setData] = useState([]);
+
+ /* 데이터 로딩 */
+ useEffect(() => {
+ (async () => {
+ const prRes = await fetch(
+ `http://52.78.12.127:8080/api/pr-games/findByQuarterId/${quarterId}`,
+ );
+ const gRes = await fetch(
+ `http://52.78.12.127:8080/api/games/game/${gameId}`,
+ );
+ if (!prRes.ok || !gRes) return;
+ const pr = await prRes.json();
+ const gm = await gRes.json();
+ const tRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${gm.teamId}`,
+ );
+ const team = await tRes.json();
+ const isManager = userMail == team.teamManagerMail;
+ setData(isManager ? pr : pr.filter((p) => p.userMail == userMail));
+ })();
+ }, [gameId, userMail]);
+
+ /* 삭제 */
+ const handleDelete = async (id) => {
+ if (!confirm('정말 삭제할까요?')) return;
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/pr-games/remove/${id}`,
+ { method: 'DELETE' },
+ );
+ if (res.ok) setData((p) => p.filter((g) => g.prGameId !== id));
+ else alert(await res.text());
+ };
+
+ return (
+
+ {data.map((prGame) => (
+
+ ))}
+
+ );
+};
+
+export default PRGameList;
diff --git a/front-end/src/components/prgame/PRGamePopUp.jsx b/front-end/src/components/prgame/PRGamePopUp.jsx
new file mode 100644
index 0000000..cbc5406
--- /dev/null
+++ b/front-end/src/components/prgame/PRGamePopUp.jsx
@@ -0,0 +1,357 @@
+import styled from 'styled-components';
+import { useEffect, useMemo, useState } from 'react';
+
+const PopupBox = styled.div`
+ position: fixed;
+ width: 100%;
+ min-height: 7vh;
+ height: ${({ $open }) => ($open ? '50vh' : '7vh')};
+ background: white;
+ transition: height 0.3s ease-in-out;
+ bottom: 56px;
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+ border-top-left-radius: 12px;
+ border-top-right-radius: 20px;
+ padding: 1vh 2vh;
+ max-width: 460px;
+ z-index: 500;
+ overflow-y: scroll;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ box-sizing: border-box;
+ &::-webkit-scrollbar {
+ width: 0px;
+ background: transparent;
+ }
+`;
+
+const PopupButton = styled.button`
+ width: 100%;
+ background-color: white;
+ border: none;
+ font-size: 2.3vh;
+ cursor: pointer;
+ padding: 1vh 0;
+ font-weight: bold;
+ color: #2c3e50;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5vh;
+ &:hover {
+ color: #00b894;
+ }
+ &:active {
+ transform: scale(0.95);
+ }
+`;
+
+const PopupTitle = styled.h4`
+ margin-top: 2vh;
+ margin-bottom: 1vh;
+ font-weight: bold;
+ padding-left: 1vh;
+`;
+
+const UsersBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1vh;
+`;
+
+const UserCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 1.2vh 1.5vh;
+ border-radius: 1vh;
+ background-color: #f9f9f9;
+ border-left: 5px solid #dcdde1;
+ transition: all 0.2s ease;
+ &:hover {
+ background-color: #ecf0f1;
+ }
+`;
+
+const Badge = styled.span`
+ display: inline-block;
+ background-color: ${({ role }) => {
+ if (['ST', 'CF', 'LS', 'RS', 'LW', 'RW'].includes(role)) return '#ff7675';
+ if (
+ [
+ 'CAM',
+ 'CM',
+ 'CDM',
+ 'LAM',
+ 'RAM',
+ 'LCM',
+ 'RCM',
+ 'LDM',
+ 'RDM',
+ 'LM',
+ 'RM',
+ ].includes(role)
+ )
+ return '#55efc4';
+ if (['LB', 'RB', 'LCB', 'RCB', 'SW', 'LWB', 'RWB'].includes(role))
+ return '#74b9ff';
+ if (['GK'].includes(role)) return '#fdcb6e';
+ return '#b2bec3';
+ }};
+ color: white;
+ border-radius: 1vh;
+ padding: 0.3vh 0.7vh;
+ font-size: 1.2vh;
+ margin-right: 0.4vh;
+`;
+
+const UserNameBox = styled.div`
+ font-size: 1.9vh;
+ font-weight: bold;
+ color: #2d3436;
+ margin-bottom: 0.5vh;
+ display: flex;
+ align-items: center;
+ gap: 0.6vh;
+`;
+
+const UserPositionBox = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5vh;
+`;
+
+const ChangeButton = styled.button`
+ background-color: white;
+ color: #c0392b;
+ border: 2px solid #c0392b;
+ width: 100%;
+ height: 5vh;
+ font-size: 1.8vh;
+ border-radius: 3vh;
+ margin-top: 2vh;
+ box-sizing: border-box;
+ transition: all 0.2s;
+ &:hover {
+ background-color: #c0392b;
+ color: white;
+ transform: scale(0.97);
+ }
+ &:active {
+ transform: scale(0.93);
+ }
+`;
+
+const PRGamePopUp = ({
+ isOpen,
+ selectedPositionKey,
+ setSelectedPositionKey,
+ users,
+ prGame,
+ setPRGame,
+ setIsOpen,
+ togglePopup,
+ setUsers,
+ prGameId,
+ game,
+ setGame,
+ quarterId,
+}) => {
+ const gameId = sessionStorage.getItem('gameId');
+ const [team, setTeam] = useState();
+
+ const positionKeyToRole = useMemo(
+ () => ({
+ stId: 'ST',
+ lsId: 'LS',
+ rsId: 'RS',
+ lwId: 'LW',
+ rwId: 'RW',
+ cfId: 'CF',
+ camId: 'CAM',
+ lamId: 'LAM',
+ ramId: 'RAM',
+ cmId: 'CM',
+ lcmId: 'LCM',
+ rcmId: 'RCM',
+ lmId: 'LM',
+ rmId: 'RM',
+ cdmId: 'CDM',
+ ldmId: 'LDM',
+ rdmId: 'RDM',
+ lwbId: 'LWB',
+ rwbId: 'RWB',
+ lbId: 'LB',
+ rbId: 'RB',
+ lcbId: 'LCB',
+ rcbId: 'RCB',
+ swId: 'SW',
+ gkId: 'GK',
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ if (!quarterId) return;
+
+ const fetchGame = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/pr-games/findByPRGameId/${prGameId}`,
+ );
+ const prData = await res.json();
+ setPRGame(prData);
+
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/quarters/saved-formation/${quarterId}`,
+ );
+ const quarterData = await response.json();
+
+ const positionKeys = Object.keys(positionKeyToRole);
+ positionKeys.forEach((key) => {
+ if (prData[key]) {
+ quarterData[key] = prData[key];
+ }
+ });
+
+ const gameRes = await fetch(
+ `http://52.78.12.127:8080/api/games/game/${quarterData.gameId}`,
+ );
+ const gameData = await gameRes.json();
+
+ const teamRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${gameData.teamId}`,
+ );
+ const teamData = await teamRes.json();
+ setTeam(teamData);
+ setUsers(quarterData.playersMail);
+ };
+
+ fetchGame();
+ }, [prGameId, quarterId, gameId, positionKeyToRole]);
+
+ // 포메이션에 들어간 선수 목록
+ const assignedUserMails = new Set(
+ Object.values(prGame || {})
+ .map((user) => user?.userMail)
+ .filter(Boolean),
+ );
+
+ // 선수 선택 시
+ const handleUserSelect = (user) => {
+ if (!selectedPositionKey) return;
+
+ setGame((prev) => ({ ...prev, [selectedPositionKey]: user }));
+ setPRGame((prev) => ({ ...prev, [selectedPositionKey]: user }));
+
+ setSelectedPositionKey(null);
+ setIsOpen(false);
+ };
+
+ const handleRemovePlayer = () => {
+ if (!selectedPositionKey) return;
+
+ setGame((prev) => ({ ...prev, [selectedPositionKey]: null }));
+ setPRGame((prev) => ({ ...prev, [selectedPositionKey]: null }));
+
+ setSelectedPositionKey(null);
+ setIsOpen(false);
+ };
+
+ // 추천 선수
+ const preferredUsers =
+ users?.filter(
+ (user) =>
+ !assignedUserMails.has(user.userMail) &&
+ [user.firstPosition, user.secondPosition, user.thirdPosition].includes(
+ positionKeyToRole[selectedPositionKey],
+ ),
+ ) || [];
+
+ // 추천 아닌 선수
+ const otherUsers =
+ users?.filter(
+ (user) =>
+ !assignedUserMails.has(user.userMail) && !preferredUsers.includes(user),
+ ) || [];
+
+ const renderUserCard = (user) => {
+ const isGuest = !team?.users?.some(
+ (teamUser) => teamUser.userMail === user.userMail,
+ );
+
+ return (
+ handleUserSelect(user)}>
+
+
+ 👤
+ {' '}
+ {user.userName}
+ {isGuest && (
+
+ 용병
+
+ )}
+
+
+ {[user.firstPosition, user.secondPosition, user.thirdPosition]
+ .filter(Boolean)
+ .map((pos, i) => (
+
+ {pos}
+
+ ))}
+
+
+ );
+ };
+
+ return (
+
+
+ {isOpen ? '▼ 닫기' : '▲ 참가자 명단'}
+
+
+ {isOpen && (
+ <>
+ {selectedPositionKey && (
+ <>
+ 추천 선수
+ {preferredUsers.length > 0 ? (
+ {preferredUsers.map(renderUserCard)}
+ ) : (
+
+ 추천 선수가 없습니다
+
+ )}
+ >
+ )}
+
+ {selectedPositionKey && (
+ 선수 제거
+ )}
+
+ 참가자 명단
+ {otherUsers.length > 0 ? (
+
+ {otherUsers.map((user) => renderUserCard(user))}
+
+ ) : (
+
+ 참가자가 없습니다
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default PRGamePopUp;
diff --git a/front-end/src/components/prgame/PRGameUpdate.jsx b/front-end/src/components/prgame/PRGameUpdate.jsx
new file mode 100644
index 0000000..c1ddb8a
--- /dev/null
+++ b/front-end/src/components/prgame/PRGameUpdate.jsx
@@ -0,0 +1,201 @@
+import styled from 'styled-components';
+import field from '../../img/field.png';
+import { useEffect, useState } from 'react';
+import playerIcon from '../../img/player.png';
+import grayUniformIcon from '../../img/grayUniform.png';
+import uniformIcon from '../../img/uniform.png';
+import { useParams } from 'react-router-dom';
+
+const PRGameUpdatePageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 8vh;
+`;
+
+const TitleInput = styled.input`
+ width: 60%;
+ height: 4vh;
+ margin: 2vh auto;
+ text-align: center;
+ font-size: 2.5vh;
+`;
+
+/* ───── 새 캡슐 버튼 ───── */
+const ChangeButton = styled.button`
+ width: 40vh;
+ height: 5.5vh;
+ border-radius: 3vh;
+ font-size: 1.8vh;
+ font-weight: 600;
+ background-color: ${({ variant }) =>
+ variant === 'primary' ? '#00C851' : '#000'};
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6vh;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s, background-color 0.15s;
+
+ &:hover {
+ background-color: ${({ variant }) =>
+ variant === 'primary' ? '#00b44b' : '#222'};
+ transform: translateY(-0.3vh) scale(1.05);
+ cursor: pointer;
+ }
+ &:active {
+ transform: scale(0.95);
+ }
+ &:disabled {
+ background-color: #999;
+ cursor: not-allowed;
+ transform: none;
+ }
+`;
+
+const PRGameUpdate = ({
+ prGameId,
+ setUpdate,
+ setSelectedPositionKey,
+ setIsOpen,
+ prGame,
+ game,
+ setGame,
+ getPRCount,
+ users,
+ positionList,
+}) => {
+ const gameId = sessionStorage.getItem('gameId');
+ const userMail = sessionStorage.getItem('userMail');
+ const [team, setTeam] = useState('');
+ const count = getPRCount();
+ const [title, setTitle] = useState(prGame.prGameName);
+ console.log(prGame);
+
+ /* 초기 포메이션 세팅 */
+ useEffect(() => {
+ const resetFormation = () => {
+ positionList.forEach(({ key }) =>
+ setGame((prev) => ({ ...prev, [key]: prGame[key] })),
+ );
+ };
+ resetFormation();
+ }, [prGame]);
+
+ useEffect(() => {
+ const fetchTeam = async () => {
+ const gameRes = await fetch(
+ `http://52.78.12.127:8080/api/games/game/${gameId}`,
+ );
+ const gameData = await gameRes.json();
+
+ const teamRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${gameData.teamId}`,
+ );
+ const teamData = await teamRes.json();
+ setTeam(teamData);
+ };
+
+ fetchTeam();
+ }, [gameId]);
+
+ const handlePositionClick = (posKey) => {
+ setSelectedPositionKey(posKey);
+ setIsOpen(true);
+ };
+
+ const handleRequestPRGame = async () => {
+ if (!prGame) return;
+
+ const payload = {
+ prGameId,
+ prGameName: title,
+ quarter: { quarterId: Number(prGame.quarter.quarterId) },
+ userMail,
+ };
+
+ positionList.forEach(({ key }) => {
+ const u = prGame[key];
+ if (u?.userMail) payload[key] = { userMail: u.userMail };
+ });
+
+ console.log(payload);
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/pr-games/update', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ alert(`요청 실패: ${data.message || '서버 오류'}`);
+ } else {
+ alert('PR 경기가 성공적으로 저장되었습니다.');
+ setUpdate(false);
+ }
+ } catch (e) {
+ console.error(e);
+ alert('요청 중 예외가 발생했습니다.');
+ }
+ };
+
+ if (!game) return 로딩 중...
;
+
+ return (
+
+ setTitle(e.target.value)}
+ />
+
+ Starting : {users.length} |{' '}
+ Lineup: {count}
+
+
+
+
+ {positionList.map(({ key, label, top, left }) => (
+
handlePositionClick(key)}>
+ {/* 아이콘+이름 래퍼 하나만 absolute */}
+
+
u.userMail === game[key].userMail,
+ )
+ ? grayUniformIcon
+ : uniformIcon
+ : playerIcon
+ }
+ alt="player"
+ className="w-[4.5vh] h-[4.5vh] object-contain"
+ />
+
+ {game[key] ? game[key].userName : label}
+
+
+
+ ))}
+
+
+
+
+ ♻️ 포메이션 수정 완료
+
+
+ );
+};
+
+export default PRGameUpdate;
diff --git a/front-end/src/components/profile/Career.jsx b/front-end/src/components/profile/Career.jsx
new file mode 100644
index 0000000..18b98aa
--- /dev/null
+++ b/front-end/src/components/profile/Career.jsx
@@ -0,0 +1,95 @@
+import { useState } from 'react';
+import UserFeedDelete from './UserFeedDelete';
+import CareerUpdate from './CareerUpdate';
+
+const Career = ({ career }) => {
+ const [showFile, setShowFile] = useState(false);
+ const [showMenu, setShowMenu] = useState(false);
+ const [update, setUpdate] = useState(false);
+
+ const userId = sessionStorage.getItem('userId');
+ const isAuthor = String(userId) === String(career.userId); // 타입 변환하여 비교 오류 방지
+
+ return (
+
+ {/* 상단 영역 */}
+
+
+
{career.title}
+
setShowFile(true)}
+ className="text-blue-600 hover:underline"
+ >
+ 파일 보기
+
+
+
+ {/* ... 버튼 및 메뉴 */}
+ {isAuthor && (
+
+
setShowMenu((prev) => !prev)}
+ className="text-gray-500 hover:text-gray-800 text-xl"
+ >
+ ⋯
+
+
+ {showMenu && (
+
+ {
+ setUpdate(true);
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-800 hover:bg-gray-100"
+ >
+ ✏️ 수정
+
+ (
+ {
+ onClick();
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
+ >
+ 🗑️ 삭제
+
+ )}
+ />
+
+ )}
+
+ )}
+
+
+ {/* 파일 보기 모달 */}
+ {showFile && (
+
+
+
+
경력 증명 사진
+ setShowFile(false)} className="text-xl">
+ ✕
+
+
+
+
+
+ )}
+ {update &&
}
+
+ );
+};
+
+export default Career;
diff --git a/front-end/src/components/profile/CareerUpdate.jsx b/front-end/src/components/profile/CareerUpdate.jsx
new file mode 100644
index 0000000..7978434
--- /dev/null
+++ b/front-end/src/components/profile/CareerUpdate.jsx
@@ -0,0 +1,167 @@
+import { useState, useRef } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const ImagePreview = styled.img`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+`;
+
+const StyledVideo = styled.video`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+ border-radius: 1vh;
+`;
+
+const CareerUpdate = ({ setUpdate, career }) => {
+ const userId = career.userId;
+ const [title, setTitle] = useState(career.title);
+ const [file, setFile] = useState(''); // 파일 객체 (image or video)
+ const [fileType, setFileType] = useState(career.fileType);
+ const [fileUrl, setFileUrl] = useState(
+ `http://52.78.12.127:8080/media/user/${career.realFileName}`,
+ ); // 미리보기 URL
+ const fileInputRef = useRef(null);
+
+ const handleFileUpload = (e) => {
+ const uploaded = e.target.files[0];
+ if (uploaded) {
+ setFile(uploaded);
+ setFileType(uploaded.type);
+ setFileUrl(URL.createObjectURL(uploaded));
+ }
+ };
+
+ const handleClickFileInput = () => {
+ fileInputRef.current?.click(); // 숨겨진 input을 트리거
+ };
+
+ const handleUpdate = async () => {
+ if (!title) {
+ alert('제목을 입력해주세요.');
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('userId', userId);
+ formData.append('title', title);
+ formData.append('content', null);
+
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileInfo/${career.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (file) {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileData/${career.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) {
+ alert((await res.text()) || '게시글 파일 수정 실패');
+ }
+ }
+
+ if (response.ok) {
+ alert('게시글 수정 완료!');
+ window.location.reload();
+ } else {
+ alert((await response.text()) || '게시글 수정 실패');
+ }
+ } catch (error) {
+ console.error('게시글 수정 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+ setUpdate(false)}
+ className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center z-[9999]"
+ >
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg relative animate-fadeIn"
+ >
+
+
경력 수정
+ setUpdate(false)}
+ className="text-[2.4vh] bg-none border-none cursor-pointer absolute right-0 top-0"
+ >
+ ✖
+
+
+
+
+
+ 파일
+ 📎
+
+ 파일 변경
+
+
+
+ {fileType?.startsWith('video/') ? (
+
+ ) : (
+
{
+ e.target.src = altImage;
+ }}
+ />
+ )}
+
+
+
+ {/* 제목 */}
+
+
+ 제목 ✏️
+
+
setTitle(e.target.value)}
+ className="w-full text-[1.7vh] p-[1.5vh] border border-gray-300 rounded-[1vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ />
+
+
+ {/* 등록 버튼 */}
+
+ 수정
+
+
+
+ );
+};
+
+export default CareerUpdate;
diff --git a/front-end/src/components/profile/ChangePassword.jsx b/front-end/src/components/profile/ChangePassword.jsx
new file mode 100644
index 0000000..f0365d8
--- /dev/null
+++ b/front-end/src/components/profile/ChangePassword.jsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const ChangePassword = () => {
+ const [newPassword, setNewPassword] = useState('');
+ const [newPasswordCheck, setNewPasswordCheck] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const userMail = sessionStorage.getItem('userMail');
+ const navigate = useNavigate();
+
+ const handleSubmit = async () => {
+ if (!newPassword || !newPasswordCheck) {
+ alert('모든 항목을 입력해주세요.');
+ return;
+ }
+
+ if (newPassword !== newPasswordCheck) {
+ alert('새 비밀번호가 일치하지 않습니다.');
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/users/update/password', { // 비밀번호 전용 API라면 이렇게 분리 추천
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userMail,
+ password: newPassword,
+ }),
+ });
+
+ if (res.ok) {
+ alert('비밀번호 변경 완료!');
+ sessionStorage.setItem('password', newPassword);
+ navigate('/profile');
+ } else {
+ alert(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('비밀번호 변경 중 오류가 발생했습니다.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
비밀번호 변경
+
+ {/* 새 비밀번호 */}
+
+
setNewPassword(e.target.value)}
+ className="w-full border border-gray-300 rounded-[0.7vh] p-[1.5vh] text-[1.8vh] focus:outline-green-500"
+ />
+ {newPassword && (
+
setNewPassword('')}
+ className="absolute right-[1.5vh] top-1/2 transform -translate-y-1/2 p-[0.8vh] rounded-full hover:bg-gray-100 transition"
+ >
+
+
+
+
+ )}
+
+
+ {/* 새 비밀번호 확인 */}
+
+
setNewPasswordCheck(e.target.value)}
+ className="w-full border border-gray-300 rounded-[0.7vh] p-[1.5vh] text-[1.8vh] focus:outline-green-500"
+ />
+ {newPasswordCheck && (
+
setNewPasswordCheck('')}
+ className="absolute right-[1.5vh] top-1/2 transform -translate-y-1/2 p-[0.8vh] rounded-full hover:bg-gray-100 transition"
+ >
+
+
+
+
+ )}
+
+
+ {/* 변경 버튼 */}
+
+ {isLoading ? '변경 중...' : '변경'}
+
+
+
+ );
+};
+
+export default ChangePassword;
diff --git a/front-end/src/components/profile/CheckPassword.jsx b/front-end/src/components/profile/CheckPassword.jsx
new file mode 100644
index 0000000..deaaf4f
--- /dev/null
+++ b/front-end/src/components/profile/CheckPassword.jsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const CheckPassword = ({ onSuccess, mode }) => {
+ const userMail = sessionStorage.getItem('userMail');
+ const [password, setPassword] = useState('');
+ const [isLoading, setLoading] = useState(false);
+ const navigate = useNavigate();
+
+ const handleCheckPassword = async (event) => {
+ event.preventDefault();
+
+ if (!userMail) {
+ alert('다시 로그인 하세요.');
+ navigate('/');
+ return;
+ }
+
+ if (!password) {
+ alert('비밀번호를 입력하세요.');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const response = await fetch('http://52.78.12.127:8080/api/users/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail, password }),
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ sessionStorage.setItem('userData', JSON.stringify(data));
+ sessionStorage.setItem('userMail', userMail);
+ sessionStorage.setItem('password', password);
+ onSuccess(mode);
+ } else {
+ alert('잘못된 비밀번호입니다.');
+ }
+ } catch (err) {
+ console.error('로그인 오류:', err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CheckPassword;
diff --git a/front-end/src/components/profile/CreateCareer.jsx b/front-end/src/components/profile/CreateCareer.jsx
new file mode 100644
index 0000000..50f968a
--- /dev/null
+++ b/front-end/src/components/profile/CreateCareer.jsx
@@ -0,0 +1,278 @@
+import { useEffect, useRef, useState } from 'react';
+import styled, { keyframes } from 'styled-components';
+
+/* ── 애니메이션 & 기본 레이아웃 ───────────────────────────── */
+const slideUp = keyframes`
+ from { transform: translateY(10%); opacity:0; }
+ to { transform: translateY(0); opacity:1; }
+`;
+const Backdrop = styled.div`
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+`;
+const Sheet = styled.div`
+ width: 92%;
+ max-width: 430px;
+ background: #fff;
+ border-radius: 1.6vh;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+ display: flex;
+ flex-direction: column;
+ animation: ${slideUp} 0.25s ease-out;
+`;
+
+/* ── 상단바 (닫기·제목·저장) ─────────────────────────────── */
+const TopBar = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 2.2vh 2vh 2.2vh 1.2vh;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 2vh;
+ font-weight: 600;
+ svg {
+ width: 2.4vh;
+ height: 2.4vh;
+ stroke: #333;
+ cursor: pointer;
+ }
+ button {
+ background: none;
+ border: none;
+ font-size: 1.8vh;
+ color: #00c851;
+ }
+`;
+
+/* ── 제목 + 첨부 아이콘 라인 ────────────────────────────── */
+const TitleLine = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 1.4vh 2vh;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 1.8vh;
+ input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ font-size: 1.8vh;
+ padding: 0;
+ &:focus {
+ outline: none;
+ }
+ }
+ label {
+ cursor: pointer;
+ }
+ svg {
+ width: 2.2vh;
+ height: 2.2vh;
+ stroke: #1976d2;
+ }
+`;
+
+/* ── 내용 입력 ─────────────────────────────────────────── */
+const BodyInput = styled.textarea`
+ border: none;
+ resize: none;
+ height: 22vh;
+ font-size: 1.7vh;
+ padding: 2vh;
+ ::placeholder {
+ color: #b5b5b5;
+ }
+ &:focus {
+ outline: none;
+ }
+`;
+
+/* ── 하단 저장 버튼 ────────────────────────────────────── */
+const Footer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ padding: 1.6vh 2vh 2.4vh;
+`;
+const Save = styled.button`
+ background: ${({ disabled }) => (disabled ? '#b2dfbc' : '#00c851')};
+ opacity: ${({ disabled }) => (disabled ? 0.65 : 1)};
+ border: none;
+ border-radius: 1.6vh;
+ color: #fff;
+ font-size: 1.8vh;
+ padding: 1.2vh 3.6vh;
+ cursor: ${({ disabled }) => 'pointer'};
+ transition: transform 0.1s;
+ &:active:not(:disabled) {
+ transform: scale(0.97);
+ }
+`;
+
+/* ── 미리보기 (작은 정사각형) ──────────────────────────── */
+const FilePreviewBox = styled.div`
+ display: flex;
+ justify-content: center;
+ padding: 1.5vh 0 1.5vh; /* 하단 여백 줄 추가 */
+ border-bottom: 1px solid #e0e0e0;
+`;
+
+const PreviewImg = styled.img`
+ width: 40vh;
+ height: 20vh;
+ object-fit: cover;
+ border-radius: 1vh;
+`;
+
+const PreviewVideo = styled.video`
+ width: 40vh;
+ height: 20vh;
+ object-fit: cover;
+ border-radius: 1vh;
+`;
+/* 숨겨진 파일 input */
+const HiddenFile = styled.input`
+ display: none;
+`;
+
+/* ── Main Component ───────────────────────────────────── */
+const CreateCareer = ({ setShowCreateCareer }) => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [file, setFile] = useState(null);
+ const [fileURL, setFileURL] = useState(null);
+ const modalRef = useRef(null);
+ const userId = sessionStorage.getItem('userId');
+
+ /* ESC 닫기 + TAB 포커스 루프 */
+ useEffect(() => {
+ const key = (e) => {
+ if (e.key === 'Escape') setShowCreateCareer(false);
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusables = modalRef.current.querySelectorAll(
+ 'button,input,textarea',
+ );
+ const f = focusables[0],
+ l = focusables[focusables.length - 1];
+ if (!focusables.length) return;
+ if (
+ e.shiftKey
+ ? document.activeElement === f
+ : document.activeElement === l
+ ) {
+ e.preventDefault();
+ (e.shiftKey ? l : f).focus();
+ }
+ }
+ };
+ window.addEventListener('keydown', key);
+ return () => window.removeEventListener('keydown', key);
+ }, [setShowCreateCareer]);
+
+ /* 파일 선택/드래그 */
+ const onSelect = (f) => {
+ if (f) {
+ setFile(f);
+ setFileURL(URL.createObjectURL(f));
+ }
+ };
+ const handleFile = (e) => onSelect(e.target.files?.[0]);
+ const onDrop = (e) => {
+ e.preventDefault();
+ onSelect(e.dataTransfer.files?.[0]);
+ };
+
+ /* 저장 */
+ const submit = async () => {
+ if (!title.trim()) return alert('제목을 입력하세요!');
+
+ try {
+ const fd = new FormData();
+ if (file) fd.append('file', file);
+ fd.append('userId', userId);
+ fd.append('title', title);
+ fd.append('content', content);
+
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/users/files/career/upload',
+ {
+ method: 'POST',
+ body: fd,
+ },
+ );
+ if (res.ok) {
+ alert('게시글 등록 완료!');
+ window.location.reload();
+ } else alert((await res.text()) || '등록 실패');
+ } catch (err) {
+ console.error(err);
+ alert('요청 중 오류');
+ }
+ };
+
+ return (
+ setShowCreateCareer(false)}>
+ e.stopPropagation()}>
+ {/* ── Top bar ── */}
+
+ setShowCreateCareer(false)}
+ viewBox="0 0 24 24"
+ fill="none"
+ strokeWidth="2"
+ >
+
+
+ 커리어 작성
+ 저장
+
+
+ {/* ── 제목 + 클립 ── */}
+
+ setTitle(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ {/* ── 파일 미리보기 (선택 시) ── */}
+ {file && (
+ e.preventDefault()}
+ onDrop={onDrop}
+ >
+ {file.type.startsWith('video/') ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default CreateCareer;
diff --git a/front-end/src/components/profile/Profile.jsx b/front-end/src/components/profile/Profile.jsx
new file mode 100644
index 0000000..aae248e
--- /dev/null
+++ b/front-end/src/components/profile/Profile.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import styled from 'styled-components';
+
+const Container = styled.div`
+ display: flex;
+`;
+
+const Box = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const BoxBox = styled.div`
+ display: flex;
+ gap: 2vh;
+`;
+
+const Profile = ({ setMyProfile }) => {
+ const [userData, setUserData] = useState(null);
+ const { userId } = useParams();
+ const myId = sessionStorage.getItem('userId');
+
+ useEffect(() => {
+ fetch(`http://52.78.12.127:8080/api/users/check/id/${userId}`)
+ .then((res) => (res.ok ? res.json() : Promise.reject(res)))
+ .then(setUserData)
+ .catch((err) => console.error(err));
+ }, [userId]);
+
+ useEffect(() => {
+ setMyProfile(userId === myId);
+ }, [setMyProfile, userId, myId]);
+
+ if (!userData) return Loading...
;
+
+ return (
+
+ {/* 프로필 이미지 */}
+
+ {userData.profileImage ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+
+
+ {/* 닉네임 */}
+
+ {userData.userName}
+
+
+ {/* 포지션 뱃지 */}
+
+ {[
+ userData.firstPosition,
+ userData.secondPosition,
+ userData.thirdPosition,
+ ]
+ .filter(Boolean)
+ .map((pos) => (
+
+ {pos}
+
+ ))}
+
+
+
+ {/* 전화번호 */}
+
+ {userData.tel}
+
+
+
+ );
+};
+
+export default Profile;
diff --git a/front-end/src/components/profile/ProfileFeedCreate.jsx b/front-end/src/components/profile/ProfileFeedCreate.jsx
new file mode 100644
index 0000000..a6a4123
--- /dev/null
+++ b/front-end/src/components/profile/ProfileFeedCreate.jsx
@@ -0,0 +1,293 @@
+import { useEffect, useRef, useState } from 'react';
+import styled, { keyframes } from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+/* ── 애니메이션 & 기본 레이아웃 ───────────────────────────── */
+const slideUp = keyframes`
+ from { transform: translateY(10%); opacity:0; }
+ to { transform: translateY(0); opacity:1; }
+`;
+const Backdrop = styled.div`
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+`;
+const Sheet = styled.div`
+ width: 92%;
+ max-width: 430px;
+ background: #fff;
+ border-radius: 1.6vh;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+ display: flex;
+ flex-direction: column;
+ animation: ${slideUp} 0.25s ease-out;
+`;
+
+/* ── 상단바 (닫기·제목·저장) ─────────────────────────────── */
+const TopBar = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 2.2vh 2vh 2.2vh 1.2vh;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 2vh;
+ font-weight: 600;
+ svg {
+ width: 2.4vh;
+ height: 2.4vh;
+ stroke: #333;
+ cursor: pointer;
+ }
+ button {
+ background: none;
+ border: none;
+ font-size: 1.8vh;
+ color: #00c851;
+ }
+`;
+
+/* ── 제목 + 첨부 아이콘 라인 ────────────────────────────── */
+const TitleLine = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 1.4vh 2vh;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 1.8vh;
+ input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ font-size: 1.8vh;
+ padding: 0;
+ &:focus {
+ outline: none;
+ }
+ }
+ label {
+ cursor: pointer;
+ }
+ svg {
+ width: 2.2vh;
+ height: 2.2vh;
+ stroke: #1976d2;
+ }
+`;
+
+/* ── 내용 입력 ─────────────────────────────────────────── */
+const BodyInput = styled.textarea`
+ border: none;
+ resize: none;
+ height: 22vh;
+ font-size: 1.7vh;
+ padding: 2vh;
+ ::placeholder {
+ color: #b5b5b5;
+ }
+ &:focus {
+ outline: none;
+ }
+`;
+
+/* ── 하단 저장 버튼 ────────────────────────────────────── */
+const Footer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ padding: 1.6vh 2vh 2.4vh;
+`;
+const Save = styled.button`
+ background: ${({ disabled }) => (disabled ? '#b2dfbc' : '#00c851')};
+ opacity: ${({ disabled }) => (disabled ? 0.65 : 1)};
+ border: none;
+ border-radius: 1.6vh;
+ color: #fff;
+ font-size: 1.8vh;
+ padding: 1.2vh 3.6vh;
+ cursor: ${({ disabled }) => 'pointer'};
+ transition: transform 0.1s;
+ &:active:not(:disabled) {
+ transform: scale(0.97);
+ }
+`;
+
+/* ── 미리보기 (작은 정사각형) ──────────────────────────── */
+const FilePreviewBox = styled.div`
+ display: flex;
+ justify-content: center;
+ padding: 1.5vh 0 1.5vh; /* 하단 여백 줄 추가 */
+ border-bottom: 1px solid #e0e0e0;
+`;
+
+const PreviewImg = styled.img`
+ width: 40vh;
+ height: 20vh;
+ object-fit: cover;
+ border-radius: 1vh;
+`;
+
+const PreviewVideo = styled.video`
+ width: 40vh;
+ height: 20vh;
+ object-fit: cover;
+ border-radius: 1vh;
+`;
+/* 숨겨진 파일 input */
+const HiddenFile = styled.input`
+ display: none;
+`;
+
+/* ── Main Component ───────────────────────────────────── */
+const ProfileFeedCreate = ({ setShowModal }) => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [file, setFile] = useState(null);
+ const [fileURL, setFileURL] = useState(null);
+ const modalRef = useRef(null);
+ const userId = sessionStorage.getItem('userId');
+
+ /* ESC 닫기 + TAB 포커스 루프 */
+ useEffect(() => {
+ const key = (e) => {
+ if (e.key === 'Escape') setShowModal(false);
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusables = modalRef.current.querySelectorAll(
+ 'button,input,textarea',
+ );
+ const f = focusables[0],
+ l = focusables[focusables.length - 1];
+ if (!focusables.length) return;
+ if (
+ e.shiftKey
+ ? document.activeElement === f
+ : document.activeElement === l
+ ) {
+ e.preventDefault();
+ (e.shiftKey ? l : f).focus();
+ }
+ }
+ };
+ window.addEventListener('keydown', key);
+ return () => window.removeEventListener('keydown', key);
+ }, [setShowModal]);
+
+ /* 파일 선택/드래그 */
+ const onSelect = (f) => {
+ if (f) {
+ setFile(f);
+ setFileURL(URL.createObjectURL(f));
+ }
+ };
+ const handleFile = (e) => onSelect(e.target.files?.[0]);
+ const onDrop = (e) => {
+ e.preventDefault();
+ onSelect(e.dataTransfer.files?.[0]);
+ };
+
+ /* 저장 */
+ const submit = async () => {
+ if (!title.trim() || !content.trim()) return alert('제목·내용 입력!');
+
+ try {
+ const fd = new FormData();
+ if (file) fd.append('file', file);
+ fd.append('userId', userId);
+ fd.append('title', title);
+ fd.append('content', content);
+
+ const res = await fetch(
+ 'http://52.78.12.127:8080/api/users/files/player/upload',
+ {
+ method: 'POST',
+ body: fd,
+ },
+ );
+ if (res.ok) {
+ alert('게시글 등록 완료!');
+ window.location.reload();
+ } else alert((await res.text()) || '등록 실패');
+ } catch (err) {
+ console.error(err);
+ alert('요청 중 오류');
+ }
+ };
+
+ return (
+ setShowModal(false)}>
+ e.stopPropagation()}>
+ {/* ── Top bar ── */}
+
+ setShowModal(false)}
+ viewBox="0 0 24 24"
+ fill="none"
+ strokeWidth="2"
+ >
+
+
+ 글쓰기
+ 저장
+
+
+ {/* ── 제목 + 클립 ── */}
+
+ setTitle(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ {/* ── 파일 미리보기 (선택 시) ── */}
+ {file && (
+ e.preventDefault()}
+ onDrop={onDrop}
+ >
+ {file.type.startsWith('video/') ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* ── 내용 입력 ── */}
+ setContent(e.target.value)}
+ />
+
+ {/* ── 저장 버튼 ── */}
+
+
+
+ );
+};
+
+export default ProfileFeedCreate;
diff --git a/front-end/src/components/profile/ProfileUpdate.jsx b/front-end/src/components/profile/ProfileUpdate.jsx
new file mode 100644
index 0000000..43780cd
--- /dev/null
+++ b/front-end/src/components/profile/ProfileUpdate.jsx
@@ -0,0 +1,222 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+import field from '../../img/field.png';
+
+const Container = styled.div`
+ padding: 3vh 2vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const Title = styled.h2`
+ font-size: 2.4vh;
+ font-weight: bold;
+ margin-bottom: 4vh;
+`;
+
+const StyledSummitButton = styled.button`
+ background-color: black;
+ color: white;
+ width: 40vh;
+ height: 6vh;
+ font-size: 2vh;
+ border-radius: 0.7vh;
+ margin-bottom: 2vh;
+ box-sizing: border-box;
+ &:hover {
+ cursor: pointer;
+ }
+`;
+
+const StyledInput = styled.input`
+ width: 40vh;
+ height: 6vh;
+ font-size: 2vh;
+ border-radius: 0.7vh;
+ border: 1px solid #b9b9b9;
+ padding: 1vh;
+ margin-bottom: 2vh;
+ box-sizing: border-box;
+`;
+
+const Subtitle = styled.p`
+ margin: 4vh 0 2vh;
+ font-size: 2.2vh;
+ font-weight: bold;
+`;
+
+const FieldWrapper = styled.div`
+ position: relative;
+ width: 49vh;
+ height: 42vh;
+ background-image: url(${field});
+ background-size: 100% 100%;
+ background-repeat: no-repeat;
+ background-position: center;
+ margin-bottom: 2vh;
+`;
+
+const ButtonBox = styled.div`
+ position: absolute;
+ width: 100%;
+ height: 100%;
+`;
+
+const StyledButton = styled.button`
+ position: absolute;
+ top: ${(props) => props.$top};
+ left: ${(props) => props.$left};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: ${(props) =>
+ props.$selected ? 'black' : 'rgba(240, 228, 57, 0.7)'};
+ color: ${(props) => (props.$selected ? 'white' : 'black')};
+ border: 2px solid black;
+ border-radius: 20vh;
+ cursor: pointer;
+ width: 8.2vh;
+ height: 4vh;
+ font-size: 1.5vh;
+`;
+
+const POSITIONS = [
+ { code: 'ST', top: '1vh', left: '20.3vh' },
+ { code: 'LS', top: '4vh', left: '11.6vh' },
+ { code: 'RS', top: '4vh', left: '29vh' },
+ { code: 'LW', top: '7vh', left: '3.6vh' },
+ { code: 'CF', top: '7vh', left: '20.3vh' },
+ { code: 'RW', top: '7vh', left: '37.6vh' },
+ { code: 'LAM', top: '13vh', left: '11.6vh' },
+ { code: 'CAM', top: '13vh', left: '20.3vh' },
+ { code: 'RAM', top: '13vh', left: '29vh' },
+ { code: 'LM', top: '19vh', left: '3vh' },
+ { code: 'LCM', top: '19vh', left: '11.6vh' },
+ { code: 'CM', top: '19vh', left: '20.3vh' },
+ { code: 'RCM', top: '19vh', left: '29vh' },
+ { code: 'RM', top: '19vh', left: '37.6vh' },
+ { code: 'LWB', top: '25vh', left: '3vh' },
+ { code: 'LDM', top: '25vh', left: '11.6vh' },
+ { code: 'CDM', top: '25vh', left: '20.3vh' },
+ { code: 'RDM', top: '25vh', left: '29vh' },
+ { code: 'RWB', top: '25vh', left: '37.6vh' },
+ { code: 'LB', top: '31vh', left: '3vh' },
+ { code: 'LCB', top: '31vh', left: '11.6vh' },
+ { code: 'SW', top: '31vh', left: '20.3vh' },
+ { code: 'RCB', top: '31vh', left: '29vh' },
+ { code: 'RB', top: '31vh', left: '37.6vh' },
+ { code: 'GK', top: '37vh', left: '20.3vh' },
+];
+
+const ProfileUpdate = () => {
+ const [userName, setUserName] = useState('');
+ const [userTel, setUserTel] = useState('');
+ const [selected, setSelected] = useState([]);
+ const userMail = sessionStorage.getItem('userMail');
+ const password = sessionStorage.getItem('password');
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ try {
+ const res = await fetch(`http://52.78.12.127:8080/api/users/check/${userMail}`);
+ if (!res.ok) throw new Error('유저 정보를 불러올 수 없습니다.');
+ const data = await res.json();
+ setUserName(data.userName);
+ setUserTel(data.tel);
+ setSelected([
+ data.firstPosition,
+ data.secondPosition,
+ data.thirdPosition,
+ ].filter(Boolean));
+ } catch (err) {
+ alert(err.message);
+ }
+ };
+ fetchUser();
+ }, [userMail]);
+
+ const togglePosition = (pos) => {
+ if (selected.includes(pos)) {
+ setSelected(selected.filter((p) => p !== pos));
+ } else if (selected.length < 3) {
+ setSelected([...selected, pos]);
+ } else {
+ alert('포지션은 최대 3개까지 선택 가능합니다.');
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!userName || !userTel || selected.length !== 3) {
+ alert('모든 항목을 입력하고 포지션 3개를 선택하세요.');
+ return;
+ }
+
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/users/update', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userMail,
+ password,
+ userName,
+ tel: userTel,
+ firstPosition: selected[0],
+ secondPosition: selected[1],
+ thirdPosition: selected[2],
+ }),
+ });
+
+ if (res.ok) {
+ alert('회원 정보가 수정되었습니다.');
+ navigate('/profile');
+ } else {
+ alert(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버와의 통신 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+
+ 회원 정보 수정
+ setUserName(e.target.value)}
+ />
+ setUserTel(e.target.value)}
+ />
+ 선호 포지션 (3개 선택)
+
+
+ {POSITIONS.map(({ code, top, left }) => (
+ togglePosition(code)}
+ >
+ {selected.includes(code)
+ ? `${selected.indexOf(code) + 1}. ${code}`
+ : code}
+
+ ))}
+
+
+
+ 회원정보 변경
+
+
+ );
+};
+
+export default ProfileUpdate;
diff --git a/front-end/src/components/profile/UserFeed.jsx b/front-end/src/components/profile/UserFeed.jsx
new file mode 100644
index 0000000..1cff180
--- /dev/null
+++ b/front-end/src/components/profile/UserFeed.jsx
@@ -0,0 +1,68 @@
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+
+const Container = styled.div`
+ width: 100%;
+ height: 20vh;
+ background-color: white;
+ text-align: center;
+ flex-shrink: 0;
+ border-radius: 0.8vh;
+ box-shadow: 0 0.2vh 0.4vh rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+`;
+
+const Title = styled.h1`
+ font-size: 1.4vh;
+ margin: 1vh 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const StyledImg = styled.img`
+ width: 10vh;
+ height: 10vh;
+ object-fit: cover;
+ border-radius: 0.4vh;
+`;
+
+const StyledVideo = styled.video`
+ width: 10vh;
+ height: 10vh;
+ object-fit: cover;
+ border-radius: 0.4vh;
+`;
+
+const StyledLink = styled(Link)`
+ text-decoration: none;
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const UserFeed = ({ userFeed }) => {
+ return (
+
+
+ {userFeed.title}
+ {userFeed.fileType.startsWith('image/') ? (
+
+ ) : userFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+ 지원되지 않는 파일
+ )}
+
+
+ );
+};
+
+export default UserFeed;
diff --git a/front-end/src/components/profile/UserFeedDelete.jsx b/front-end/src/components/profile/UserFeedDelete.jsx
new file mode 100644
index 0000000..fee05ee
--- /dev/null
+++ b/front-end/src/components/profile/UserFeedDelete.jsx
@@ -0,0 +1,42 @@
+import { useNavigate } from 'react-router-dom';
+
+const UserFeedDelete = ({ feedId, userFeed, renderButton }) => {
+ const navigate = useNavigate();
+ const userId = userFeed.userId;
+
+ const handleDelete = async () => {
+ const confirmDelete = window.confirm('정말로 게시글을 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/${feedId}`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 완료');
+ navigate(`/profile/${userId}`);
+ window.location.reload();
+ } else {
+ const error = await res.text();
+ alert('삭제 실패: ' + error);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ // 🔥 여기서 renderButton로 넘겨받았으면 외부 커스텀 버튼 사용
+ if (renderButton) {
+ return renderButton({ onClick: handleDelete });
+ }
+
+ // 기본 버튼
+ return 삭제 ;
+};
+
+export default UserFeedDelete;
diff --git a/front-end/src/components/profile/UserFeedDetail.jsx b/front-end/src/components/profile/UserFeedDetail.jsx
new file mode 100644
index 0000000..14fe302
--- /dev/null
+++ b/front-end/src/components/profile/UserFeedDetail.jsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef, useState } from 'react';
+import UserFeedUpdate from './UserFeedUpdate';
+import UserFeedDelete from './UserFeedDelete';
+import FeedCommentList from '../common/feedcomment/FeedCommentList';
+
+const UserFeedDetail = ({ feedId }) => {
+ const [update, setUpdate] = useState(false);
+ const [userFeed, setUserFeed] = useState(null);
+ const [showMenu, setShowMenu] = useState(false);
+ const videoRef = useRef(null);
+ const [isAuthor, setIsAuthor] = useState(false);
+ const userId = sessionStorage.getItem('userId');
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const userRes = await fetch(
+ `http://52.78.12.127:8080/api/users/files/file/${feedId}`,
+ );
+ if (!userRes.ok) throw new Error('네트워크 에러');
+ const userData = await userRes.json();
+ setUserFeed(userData);
+ setIsAuthor(userData.userId == userId);
+ } catch (error) {
+ setUserFeed(null);
+ }
+ };
+
+ fetchData();
+ }, [feedId]);
+
+ if (!userFeed) {
+ return (
+
+ 게시글을 찾을 수 없습니다.
+
+ );
+ }
+
+ return (
+
+ {/* 드롭다운 버튼 */}
+ {isAuthor && (
+
+
setShowMenu((prev) => !prev)}
+ className="text-gray-500 hover:text-gray-800 text-xl"
+ >
+ ⋯
+
+ {showMenu && (
+
+ {
+ setUpdate(true);
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-800 hover:bg-gray-100"
+ >
+ ✏️ 수정
+
+ (
+ {
+ onClick();
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
+ >
+ 🗑️ 삭제
+
+ )}
+ />
+
+ )}
+
+ )}
+
+ {/* 본문 콘텐츠 */}
+
+ {userFeed.title}
+
+
+
+ {userFeed.fileType.startsWith('image/') ? (
+
+ ) : userFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+
지원되지 않는 파일입니다.
+ )}
+
+
+
+
+ {update &&
}
+
+
+
+ );
+};
+
+export default UserFeedDetail;
diff --git a/front-end/src/components/profile/UserFeedList.jsx b/front-end/src/components/profile/UserFeedList.jsx
new file mode 100644
index 0000000..b8175a1
--- /dev/null
+++ b/front-end/src/components/profile/UserFeedList.jsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import UserFeed from './UserFeed';
+import { useParams } from 'react-router-dom';
+
+const GridContainer = styled.div`
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 2vh;
+ justify-items: center;
+ width: 100%;
+`;
+
+const UserFeedList = () => {
+ const [userFeedList, setUserFeedList] = useState([]);
+ const { userId } = useParams();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/player/user/${userId}`,
+ );
+ const data = await res.json();
+ setUserFeedList(data);
+ };
+ fetchData();
+ }, [userId]);
+
+ return (
+
+ {userFeedList.map((userFeed) => (
+
+ ))}
+
+ );
+};
+
+export default UserFeedList;
diff --git a/front-end/src/components/profile/UserFeedUpdate.jsx b/front-end/src/components/profile/UserFeedUpdate.jsx
new file mode 100644
index 0000000..72c7a19
--- /dev/null
+++ b/front-end/src/components/profile/UserFeedUpdate.jsx
@@ -0,0 +1,181 @@
+import { useState, useRef } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const ImagePreview = styled.img`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+`;
+
+const StyledVideo = styled.video`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+ border-radius: 1vh;
+`;
+
+const UserFeedUpdate = ({ setUpdate, userFeed }) => {
+ const userId = userFeed.userId;
+ const [title, setTitle] = useState(userFeed.title);
+ const [content, setContent] = useState(userFeed.content);
+ const [file, setFile] = useState(''); // 파일 객체 (image or video)
+ const [fileType, setFileType] = useState(userFeed.fileType);
+ const [fileUrl, setFileUrl] = useState(
+ `http://52.78.12.127:8080/media/user/${userFeed.realFileName}`,
+ ); // 미리보기 URL
+ const fileInputRef = useRef(null);
+
+ const handleFileUpload = (e) => {
+ const uploaded = e.target.files[0];
+ if (uploaded) {
+ setFile(uploaded);
+ setFileType(uploaded.type);
+ setFileUrl(URL.createObjectURL(uploaded));
+ }
+ };
+
+ const handleClickFileInput = () => {
+ fileInputRef.current?.click(); // 숨겨진 input을 트리거
+ };
+
+ const handleUpdate = async () => {
+ if (!title || !content) {
+ alert('내용을 전부 입력해주세요.');
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('userId', userId);
+ formData.append('title', title);
+ formData.append('content', content);
+
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileInfo/${userFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (file) {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/users/files/fileData/${userFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) {
+ alert((await res.text()) || '게시글 파일 수정 실패');
+ }
+ }
+
+ if (response.ok) {
+ alert('게시글 수정 완료!');
+ window.location.reload();
+ } else {
+ alert((await response.text()) || '게시글 수정 실패');
+ }
+ } catch (error) {
+ console.error('게시글 수정 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+ setUpdate(false)}
+ className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center z-[9999]"
+ >
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg relative animate-fadeIn"
+ >
+
+
게시글 수정
+ setUpdate(false)}
+ className="text-[2.4vh] bg-none border-none cursor-pointer absolute right-0 top-0"
+ >
+ ✖
+
+
+
+
+
+ 파일
+ 📎
+
+ 파일 변경
+
+
+
+ {fileType?.startsWith('video/') ? (
+
+ ) : (
+
{
+ e.target.src = altImage;
+ }}
+ />
+ )}
+
+
+
+ {/* 제목 */}
+
+
+ 제목 ✏️
+
+
setTitle(e.target.value)}
+ className="w-full text-[1.7vh] p-[1.5vh] border border-gray-300 rounded-[1vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ />
+
+
+ {/* 내용 */}
+
+
+ {/* 등록 버튼 */}
+
+ 수정
+
+
+
+ );
+};
+
+export default UserFeedUpdate;
diff --git a/front-end/src/components/schedule/Calender.jsx b/front-end/src/components/schedule/Calender.jsx
new file mode 100644
index 0000000..d16994e
--- /dev/null
+++ b/front-end/src/components/schedule/Calender.jsx
@@ -0,0 +1,61 @@
+import dayjs from "dayjs";
+import { useEffect, useState } from "react";
+import CalenderInfo from "./CalenderInfo";
+import MyCalenderList from "./MyCalenderList";
+
+const Calender = () => {
+ const [currentDate, setCurrentDate] = useState(dayjs());
+ const [teams, setTeams] = useState([]);
+ const [games, setGames] = useState([]);
+ const [selectedDay, setSelectedDay] = useState({ year: null, month: null, date: null });
+ const [isLoading, setIsLoading] = useState(true);
+ const userMail = sessionStorage.getItem('userMail');
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/mail/${userMail}`);
+ if (res.ok) {
+ const data = await res.json();
+ setTeams(data);
+ } else {
+ alert(await res.text());
+ }
+ };
+ fetchTeams();
+ }, [userMail]);
+
+ useEffect(() => {
+ const fetchGames = async () => {
+ const allGames = [];
+ for (const team of teams) {
+ const res = await fetch(`http://52.78.12.127:8080/api/games/team/${team.teamId}`);
+ if (res.ok) {
+ const data = await res.json();
+ const filtered = data.filter((g) => dayjs(g.date).isSame(currentDate, 'month'));
+ allGames.push(...filtered.map((g) => ({ ...g, team })));
+ }
+ }
+ setGames(allGames);
+ setIsLoading(false);
+ };
+
+ if (teams.length > 0) fetchGames();
+ }, [teams, currentDate]);
+
+ if (isLoading) return 불러오는 중...
;
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default Calender;
\ No newline at end of file
diff --git a/front-end/src/components/schedule/CalenderInfo.jsx b/front-end/src/components/schedule/CalenderInfo.jsx
new file mode 100644
index 0000000..6743a22
--- /dev/null
+++ b/front-end/src/components/schedule/CalenderInfo.jsx
@@ -0,0 +1,91 @@
+import dayjs from 'dayjs';
+
+const CalenderInfo = ({ currentDate, setCurrentDate, games, selectedDay, setSelectedDay }) => {
+
+ // 이번 달 날짜 배열 생성
+ const getDaysInMonth = () => {
+ const start = currentDate.startOf('month').day();
+ const end = currentDate.daysInMonth();
+ const days = [];
+ for (let i = 0; i < start; i++) days.push('');
+ for (let i = 1; i <= end; i++) days.push(i);
+ return days;
+ };
+
+ // 날짜별 경기 색상 저장
+ const gamesByDate = games.reduce((acc, game) => {
+ const gameDate = dayjs(game.date);
+ if (gameDate.month() === currentDate.month() && gameDate.year() === currentDate.year()) {
+ const day = gameDate.date();
+ if (!acc[day]) acc[day] = [];
+ if (!acc[day].includes(game.team.firstColor)) {
+ acc[day].push(game.team.firstColor);
+ }
+ }
+ return acc;
+ }, {});
+
+ return (
+
+ {/* 달력 헤더 */}
+
+ setCurrentDate(currentDate.subtract(1, 'month'))} className="text-[2vh]">◀
+
{currentDate.format('YYYY년 M월')}
+ setCurrentDate(currentDate.add(1, 'month'))} className="text-[2vh]">▶
+
+
+ {/* 요일 헤더 */}
+
+ {['일', '월', '화', '수', '목', '금', '토'].map((day) => (
+
+ {day}
+
+ ))}
+
+
+ {/* 날짜 그리드 */}
+
+ {getDaysInMonth().map((d, i) => {
+ if (d === '') return
;
+
+ const isToday = d === dayjs().date() && currentDate.isSame(dayjs(), 'month') && currentDate.isSame(dayjs(), 'year');
+ const isSelected = d === selectedDay.date && currentDate.month() === selectedDay.month && currentDate.year() === selectedDay.year;
+
+ return (
+
setSelectedDay({ year: currentDate.year(), month: currentDate.month(), date: d })}
+ className={`
+ aspect-square min-h-[6vh] flex flex-col items-center justify-center relative cursor-pointer
+ ${isSelected ? 'text-green-500 font-bold' : isToday ? 'text-black font-bold' : 'text-black font-normal'}
+ `}
+ >
+ {/* 오늘 날짜 표시 */}
+ {isToday && (
+
today
+ )}
+
+ {/* 날짜 숫자 */}
+
{d}
+
+ {/* 경기 점 표시 */}
+ {gamesByDate[d] && (
+
+ {gamesByDate[d].map((color, idx) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default CalenderInfo;
diff --git a/front-end/src/components/schedule/MyCalender.jsx b/front-end/src/components/schedule/MyCalender.jsx
new file mode 100644
index 0000000..4106166
--- /dev/null
+++ b/front-end/src/components/schedule/MyCalender.jsx
@@ -0,0 +1,76 @@
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const Card = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 2vh;
+ border-radius: 1.5vh;
+ margin-bottom: 3vh;
+ background-color: #fff;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
+ border: 1.5px solid transparent;
+
+ &:hover {
+ border: 1.5px solid #00c264;
+ }
+`;
+
+const Team = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const Logo = styled.img`
+ width: 7vh;
+ height: 7vh;
+ border-radius: 50%;
+ object-fit: cover;
+`;
+
+const Name = styled.div`
+ font-size: 1.6vh;
+ font-weight: bold;
+ margin-top: 0.5vh;
+ text-align: center;
+ max-width: 12vh;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const Middle = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1vh;
+ font-weight: bold;
+`;
+
+const MyCalender = ({ game }) => {
+ return (
+
+
+
+ { e.target.src = altImage; }} />
+ {game.team.teamName}
+
+
+
+ VS
+ {game.date.slice(0, 10)}
+
+
+
+ { e.target.src = altImage; }} />
+ {game.gameName}
+
+
+
+ );
+};
+
+export default MyCalender;
diff --git a/front-end/src/components/schedule/MyCalenderList.jsx b/front-end/src/components/schedule/MyCalenderList.jsx
new file mode 100644
index 0000000..fc33d45
--- /dev/null
+++ b/front-end/src/components/schedule/MyCalenderList.jsx
@@ -0,0 +1,27 @@
+import dayjs from 'dayjs';
+import MyCalender from './MyCalender';
+
+const MyCalenderList = ({ games, selectedDay }) => {
+ const selectedDate = dayjs(`${selectedDay.year}-${selectedDay.month + 1}-${selectedDay.date}`);
+
+ const filteredGames = games.filter((g) =>
+ dayjs(g.date).isSame(selectedDate, 'day')
+ );
+
+ if (!selectedDay.date) return null;
+
+ return (
+
+
{selectedDate.format('YYYY-MM-DD')} 경기 일정
+ {filteredGames.length > 0 ? (
+ filteredGames.map((game) => (
+
+ ))
+ ) : (
+
해당 날짜에 경기가 없습니다.
+ )}
+
+ );
+};
+
+export default MyCalenderList;
diff --git a/front-end/src/components/schedule/MySchedule.jsx b/front-end/src/components/schedule/MySchedule.jsx
new file mode 100644
index 0000000..4fec10b
--- /dev/null
+++ b/front-end/src/components/schedule/MySchedule.jsx
@@ -0,0 +1,131 @@
+import { Link } from 'react-router-dom';
+import dayjs from 'dayjs';
+import altImage from '../../img/alt_image.png';
+import { useEffect, useState } from 'react';
+
+const getGameStatus = (gameDate) => {
+ const now = dayjs();
+ const start = dayjs(gameDate);
+ const end = start.add(1, 'hour');
+
+ if (now.isBefore(start)) return '예정';
+ if (now.isAfter(end)) return '완료';
+ return '진행중';
+};
+
+const getLeftBarColor = (status) => {
+ switch (status) {
+ case '예정':
+ return 'bg-blue-500';
+ case '진행중':
+ return 'bg-yellow-400';
+ case '완료':
+ return 'bg-gray-400';
+ default:
+ return 'bg-gray-200';
+ }
+};
+
+const GameStatusBadge = ({ status }) => {
+ const statusStyle = {
+ 예정: 'bg-blue-100 text-blue-700',
+ 진행중: 'bg-yellow-100 text-yellow-700',
+ 완료: 'bg-gray-200 text-gray-700',
+ };
+
+ return (
+
+ {status}
+
+ );
+};
+
+const MySchedule = ({ game }) => {
+ const [team, setTeam] = useState('');
+ const status = getGameStatus(game.date);
+
+ const dayName = ['일', '월', '화', '수', '목', '금', '토'][
+ dayjs(game.date).day()
+ ];
+ const barColor = getLeftBarColor(status);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const teamResponse = await fetch(
+ `http://52.78.12.127:8080/api/teams/${game.teamId}`,
+ );
+
+ if (!teamResponse.ok) return;
+ const teamData = await teamResponse.json();
+ setTeam(teamData);
+ };
+
+ fetchData();
+ }, [game]);
+
+ return (
+
+
+ {/* 좌측 컬러 바 */}
+
+
+ {/* 본문 */}
+
+ {/* 상단 (날짜 + 상태) */}
+
+
+ {dayjs(game.date).format('YYYY-MM-DD')} ({dayName}){' '}
+ {dayjs(game.date).format('HH:mm')}
+
+
+
+
+ {/* 경기 정보 */}
+
+ {/* 내 팀 */}
+
+
(e.target.src = altImage)}
+ className="w-[7vh] h-[7vh] rounded-full object-cover mb-[0.3vh] border-2 border-white shadow-sm"
+ />
+
+ {team.teamName}
+
+
+
+ {/* VS */}
+
+
+ {/* 상대 팀 */}
+
+
(e.target.src = altImage)}
+ className="w-[7vh] h-[7vh] rounded-full object-cover mb-[0.3vh] border-2 border-white shadow-sm"
+ />
+
+ {game.versus}
+
+
+
+
+ {/* 경기명 */}
+
+ {game.gameName}
+
+
+
+
+ );
+};
+
+export default MySchedule;
diff --git a/front-end/src/components/schedule/MyScheduleList.jsx b/front-end/src/components/schedule/MyScheduleList.jsx
new file mode 100644
index 0000000..abe2bfb
--- /dev/null
+++ b/front-end/src/components/schedule/MyScheduleList.jsx
@@ -0,0 +1,95 @@
+import { useEffect, useState } from 'react';
+import MySchedule from './MySchedule';
+import dayjs from 'dayjs';
+
+const MyScheduleList = ({ filter }) => {
+ const userMail = sessionStorage.getItem('userMail');
+ const [teams, setTeams] = useState([]);
+ const [games, setGames] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/teams/mail/${userMail}`,
+ );
+ if (res.ok) {
+ const data = await res.json();
+ setTeams(data);
+ }
+ };
+ fetchTeams();
+ }, [userMail]);
+
+ useEffect(() => {
+ const fetchGamesForAllTeams = async () => {
+ setLoading(true);
+ try {
+ const promises = teams.map(async (team) => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/games/team/${team.teamId}`,
+ );
+ if (res.ok) {
+ return res.json(); // 응답 JSON 반환
+ } else {
+ throw new Error(`팀 ${team.teamId} 데이터 가져오기 실패`);
+ }
+ });
+
+ const results = await Promise.all(promises);
+
+ setGames(results.flat());
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (teams.length > 0) {
+ fetchGamesForAllTeams();
+ }
+ }, [teams]);
+
+ if (loading)
+ return 불러오는 중...
;
+ if (games.length === 0)
+ return (
+ 예정된 경기가 없습니다.
+ );
+
+ const getGameStatus = (gameDate) => {
+ const now = dayjs();
+ const start = dayjs(gameDate);
+ const end = start.add(1, 'hour');
+
+ if (now.isBefore(start)) return '예정';
+ if (now.isAfter(end)) return '완료';
+ return '진행중';
+ };
+
+ const sortedGames = [...games].sort((a, b) =>
+ dayjs(a.date).isAfter(dayjs(b.date)) ? 1 : -1,
+ );
+
+ const filteredGames =
+ filter === '전체'
+ ? sortedGames
+ : sortedGames.filter((g) => getGameStatus(g.date) === filter);
+
+ return (
+
+ {filteredGames.length > 0 ? (
+ filteredGames.map((game) => (
+
+ ))
+ ) : (
+
+ 해당 경기가 없습니다.
+
+ )}
+
+ );
+};
+
+export default MyScheduleList;
diff --git a/front-end/src/components/teamfeed/Comment.jsx b/front-end/src/components/teamfeed/Comment.jsx
new file mode 100644
index 0000000..841d730
--- /dev/null
+++ b/front-end/src/components/teamfeed/Comment.jsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'react';
+import CommentDelete from './CommentDelete';
+import CommentUpdate from './CommentUpdate';
+
+const Comment = ({ comment, videoRef }) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const userMail = sessionStorage.getItem('userMail');
+ const [user, setUser] = useState('');
+ const isAuthor = userMail == comment.userMail;
+
+ const handleTimeClick = (timeStr) => {
+ const [min, sec] = timeStr.split(':').map(Number);
+ const timeInSeconds = min * 60 + sec;
+ if (videoRef?.current) {
+ videoRef.current.currentTime = timeInSeconds;
+ videoRef.current.play(); // 자동 재생도 가능
+ }
+ };
+
+ const parseContentWithTimeLinks = (text) => {
+ const regex = /(\d{1,2}:\d{2})/g;
+ const parts = text.split(regex);
+ return parts.map((part, index) =>
+ regex.test(part) ? (
+ handleTimeClick(part)}
+ className="text-blue-500 underline hover:text-blue-700 text-xs ml-1"
+ >
+ {part}
+
+ ) : (
+ {part}
+ ),
+ );
+ };
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/check/${comment.userMail}`,
+ );
+ const data = await response.json();
+ setUser(data);
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchUser();
+ }, [comment]);
+
+ return (
+
+
+
+ {user.userName}
+
+ {isAuthor && (
+
+ {!isEditing && (
+ setIsEditing(true)}
+ className="text-xs text-blue-500 hover:underline hover:text-blue-600"
+ >
+ 댓글 수정
+
+ )}
+
+
+ )}
+
+
+ {!isEditing ? (
+
+ {parseContentWithTimeLinks(comment.content)}
+
+ ) : (
+ setIsEditing(false)} />
+ )}
+
+
+ {new Date(comment.createdAt).toLocaleString('ko-KR')}
+
+
+ );
+};
+
+export default Comment;
diff --git a/front-end/src/components/teamfeed/CommentCreate.jsx b/front-end/src/components/teamfeed/CommentCreate.jsx
new file mode 100644
index 0000000..b90666f
--- /dev/null
+++ b/front-end/src/components/teamfeed/CommentCreate.jsx
@@ -0,0 +1,63 @@
+import { useState } from 'react';
+const CommentCreate = ({ teamFeedId }) => {
+ const [content, setContent] = useState('');
+ const userMail = sessionStorage.getItem('userMail');
+ const userId = sessionStorage.getItem('userId');
+
+ const handleSubmit = async () => {
+ if (!content.trim()) {
+ alert('댓글을 입력해주세요.');
+ return;
+ }
+
+ const body = {
+ userMail: userMail,
+ content: content,
+ fileId: Number(teamFeedId),
+ userId: Number(userId),
+ };
+
+ console.log(body);
+
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/comments/create', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(errorText || '댓글 등록 실패');
+ }
+
+ alert('댓글이 등록되었습니다.');
+ window.location.reload();
+ } catch (err) {
+ console.error('등록 오류:', err);
+ alert('댓글 등록 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+
+ setContent(e.target.value)}
+ placeholder="댓글을 입력해 주세요."
+ className="flex-1 border border-gray-300 rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
+ />
+
+ ✔️
+
+
+ );
+};
+
+export default CommentCreate;
\ No newline at end of file
diff --git a/front-end/src/components/teamfeed/CommentDelete.jsx b/front-end/src/components/teamfeed/CommentDelete.jsx
new file mode 100644
index 0000000..22a1a23
--- /dev/null
+++ b/front-end/src/components/teamfeed/CommentDelete.jsx
@@ -0,0 +1,31 @@
+const CommentDelete = ({ comment }) => {
+ const handleRemove = async () => {
+ const confirmDelete = window.confirm('정말로 댓글을 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const response = await fetch(`http://52.78.12.127:8080/api/comments/${comment.feedId}`, {
+ method: "DELETE"
+ })
+ if (!response.ok) {
+ alert("오류 발생")
+ throw new Error(`서버 오류: ${errorMessage}`);
+ }
+ alert("댓글이 삭제 됐습니다.")
+ window.location.reload();
+ } catch (error) {
+ console.error("업데이트 실패:", error);
+ alert("댓글 수정 중 오류가 발생했습니다.");
+ }
+ }
+ return(
+
+ 댓글 삭제
+
+ )
+}
+
+export default CommentDelete;
\ No newline at end of file
diff --git a/front-end/src/components/teamfeed/CommentList.jsx b/front-end/src/components/teamfeed/CommentList.jsx
new file mode 100644
index 0000000..58b5c75
--- /dev/null
+++ b/front-end/src/components/teamfeed/CommentList.jsx
@@ -0,0 +1,47 @@
+import { useParams } from 'react-router-dom';
+import Comment from './Comment';
+import CommentCreate from './CommentCreate';
+import { useEffect, useState } from 'react';
+
+const CommentList = ({ videoRef }) => {
+ const { teamFeedId } = useParams();
+ const [comments, setComments] = useState([]);
+
+ useEffect(() => {
+ const fetchComments = async () => {
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/comments/file/${teamFeedId}`,
+ );
+ const data = await res.json();
+ setComments(data);
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ fetchComments();
+ }, [teamFeedId]);
+
+ return (
+
+ {/* 댓글 제목 */}
+
+ 댓글 {comments.length}
+
+
+ {/* 댓글 리스트 */}
+
+ {comments.map((comment) => (
+
+ ))}
+
+
+ {/* 댓글 입력창 */}
+
+
+ );
+};
+
+export default CommentList;
diff --git a/front-end/src/components/teamfeed/CommentUpdate.jsx b/front-end/src/components/teamfeed/CommentUpdate.jsx
new file mode 100644
index 0000000..9e6a360
--- /dev/null
+++ b/front-end/src/components/teamfeed/CommentUpdate.jsx
@@ -0,0 +1,54 @@
+import { useState } from "react";
+
+const CommentUpdate = ({ comment, onCancel }) => {
+ const [editContent, setEditContent] = useState(comment.content);
+
+ const handleSubmit = async () => {
+ const formData = new FormData();
+ formData.append("content", editContent)
+
+ try {
+ const response = await fetch(`http://52.78.12.127:8080/api/comments/update/${comment.feedId}`, {
+ method: "PUT",
+ body: formData
+ })
+ if (!response.ok) {
+ const errorMessage = await response.text();
+ throw new Error(`서버 오류: ${errorMessage}`);
+ }
+
+ alert("댓글이 수정되었습니다.");
+ window.location.reload();
+ } catch (error) {
+ console.error("업데이트 실패:", error);
+ alert("댓글 수정 중 오류가 발생했습니다.");
+ }
+ }
+
+ return (
+
+ );
+};
+
+export default CommentUpdate;
diff --git a/front-end/src/components/teamfeed/TeamFeedCreate.jsx b/front-end/src/components/teamfeed/TeamFeedCreate.jsx
new file mode 100644
index 0000000..77b7cce
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedCreate.jsx
@@ -0,0 +1,131 @@
+import { useState } from "react";
+import styled from "styled-components";
+import altImage from '../../img/alt_image.png';
+
+const ImagePreview = styled.img`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+`;
+
+const StyledVideo = styled.video`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+ border-radius: 1vh;
+`;
+
+const TeamFeedCreate = ({ teamId, setCreate }) => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [file, setFile] = useState(null); // 파일 객체 (image or video)
+ const [fileUrl, setFileUrl] = useState(null); // 미리보기 URL
+
+ const handleFileUpload = (e) => {
+ const uploaded = e.target.files[0];
+ if (uploaded) {
+ setFile(uploaded);
+ setFileUrl(URL.createObjectURL(uploaded));
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!title || !content) {
+ alert('내용을 전부 입력해주세요.');
+ return;
+ }
+
+
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("teamId", teamId);
+ formData.append("title", title);
+ formData.append("content", content);
+
+ const response = await fetch('http://52.78.12.127:8080/api/files/upload', {
+ method: 'POST',
+ body: formData,
+ });
+ if (response.ok) {
+ alert('팀 게시글 등록 완료!');
+ window.location.reload();
+ } else {
+ alert(await response.text() || '팀 게시글 등록 실패');
+ }
+ } catch (error) {
+ console.error('팀 생성 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+ setCreate(false)} className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center z-[9999]">
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg relative animate-fadeIn"
+ >
+
+
게시글 작성
+ setCreate(false)} className="text-[2.4vh] bg-none border-none cursor-pointer absolute right-0 top-0">✖
+
+
+ {/* 파일 미리보기 */}
+
+
파일 📎
+
+ {file && file.type.startsWith('video/') ? (
+
+ ) : (
+ { e.target.src = altImage; }}
+ />
+ )}
+
+
+
+
+ {/* 제목 */}
+
+
제목 ✏️
+
setTitle(e.target.value)}
+ className="w-full text-[1.7vh] p-[1.5vh] border border-gray-300 rounded-[1vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ />
+
+
+ {/* 내용 */}
+
+
+ {/* 등록 버튼 */}
+
+ 등록
+
+
+
+ );
+};
+
+export default TeamFeedCreate;
diff --git a/front-end/src/components/teamfeed/TeamFeedDelete.jsx b/front-end/src/components/teamfeed/TeamFeedDelete.jsx
new file mode 100644
index 0000000..e852eae
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedDelete.jsx
@@ -0,0 +1,41 @@
+import { useNavigate } from 'react-router-dom';
+
+const TeamFeedDelete = ({ teamFeedId, teamFeed, renderButton }) => {
+ const navigate = useNavigate();
+ const teamId = teamFeed.teamId;
+
+ const handleDelete = async () => {
+ const confirmDelete = window.confirm('정말로 팀 게시글을 삭제할까요?');
+ if (!confirmDelete) return;
+
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/files/${teamFeedId}`,
+ {
+ method: 'DELETE',
+ },
+ );
+
+ if (res.ok) {
+ alert('삭제 완료');
+ navigate(`/teamfeed/list/${teamId}`);
+ } else {
+ const error = await res.text();
+ alert('삭제 실패: ' + error);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ // 🔥 여기서 renderButton로 넘겨받았으면 외부 커스텀 버튼 사용
+ if (renderButton) {
+ return renderButton({ onClick: handleDelete });
+ }
+
+ // 기본 버튼
+ return 삭제 ;
+};
+
+export default TeamFeedDelete;
diff --git a/front-end/src/components/teamfeed/TeamFeedDetail.jsx b/front-end/src/components/teamfeed/TeamFeedDetail.jsx
new file mode 100644
index 0000000..2c8e54b
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedDetail.jsx
@@ -0,0 +1,212 @@
+import { Link } from 'react-router-dom';
+import styled, { keyframes } from 'styled-components';
+import { FaHeart, FaComment } from 'react-icons/fa';
+import { useState } from 'react';
+
+const Card = styled.div`
+ width: 100%;
+ max-width: 280px;
+ background: #fff;
+ border-radius: 1rem;
+ box-shadow: 0 6px 12px rgba(0,0,0,0.06);
+ overflow: hidden;
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+
+ &:hover {
+ transform: translateY(-6px);
+ box-shadow: 0 14px 28px rgba(0,0,0,0.12);
+ }
+`;
+
+const StyledLink = styled(Link)`
+ color: inherit;
+ text-decoration: none;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+`;
+
+const MediaWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ overflow: hidden;
+ flex-shrink: 0;
+
+ img, video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.4s ease;
+ }
+
+ ${Card}:hover & img,
+ ${Card}:hover & video {
+ transform: scale(1.05);
+ }
+`;
+
+const Overlay = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0; right: 0;
+ padding: 6px 10px;
+ background: linear-gradient(transparent, rgba(0,0,0,0.6));
+ color: white;
+ font-size: 0.8rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ user-select: none;
+`;
+
+const Content = styled.div`
+ padding: 1rem;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+`;
+
+const Title = styled.h3`
+ font-size: 1.1rem;
+ font-weight: 700;
+ margin: 0 0 0.4rem 0;
+ color: #222;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+`;
+
+const Excerpt = styled.p`
+ font-size: 0.9rem;
+ color: #555;
+ line-height: 1.4;
+ margin: 0 0 1rem 0;
+ flex-grow: 1;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+`;
+
+const MetaRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const AuthorDate = styled.div`
+ font-size: 0.8rem;
+ color: #888;
+`;
+
+const Actions = styled.div`
+ display: flex;
+ gap: 1rem;
+`;
+
+const scaleUp = keyframes`
+ 0% { transform: scale(1);}
+ 50% { transform: scale(1.3);}
+ 100% { transform: scale(1);}
+`;
+
+const ActionBtn = styled.button`
+ background: none;
+ border: none;
+ display: flex;
+ align-items: center;
+ color: ${props => (props.$active ? '#e0245e' : '#888')};
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: ${props => (props.$active ? '#c21' : '#555')};
+ }
+
+ & > svg {
+ margin-right: 6px;
+ ${props => props.$active && `animation: ${scaleUp} 0.5s ease;`}
+ }
+`;
+
+const Tags = styled.div`
+ margin-top: 0.8rem;
+ font-size: 0.75rem;
+ color: #666;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+
+ span {
+ background: #eee;
+ padding: 2px 8px;
+ border-radius: 12px;
+ user-select: none;
+ }
+`;
+
+const TeamFeedDetail = ({ teamFeed }) => {
+ const [liked, setLiked] = useState(false);
+ const mediaUrl = `http://52.78.12.127:8080/media/team/${teamFeed.realFileName}`;
+ const date = new Date(teamFeed.createdAt).toLocaleDateString();
+ const excerpt = teamFeed.description || '';
+
+ return (
+
+
+
+ {teamFeed.fileType.startsWith('image/') ? (
+
+ ) : teamFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+
+ 지원되지 않는 형식
+
+ )}
+
+ {teamFeed.authorName}
+
+
+
+
+ {teamFeed.title}
+ {excerpt && {excerpt} }
+
+ {teamFeed.authorName}
+
+ {
+ e.preventDefault();
+ setLiked((prev) => !prev);
+ }}
+ >
+ {liked ? teamFeed.likes + 1 : teamFeed.likes}
+
+
+ {teamFeed.comments}
+
+
+
+ {teamFeed.tags && teamFeed.tags.length > 0 && (
+
+ {teamFeed.tags.map((tag) => (
+ #{tag}
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default TeamFeedDetail;
diff --git a/front-end/src/components/teamfeed/TeamFeedDetailInfo.jsx b/front-end/src/components/teamfeed/TeamFeedDetailInfo.jsx
new file mode 100644
index 0000000..ed09309
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedDetailInfo.jsx
@@ -0,0 +1,153 @@
+import { useEffect, useRef, useState } from 'react';
+import TeamFeedUpdate from './TeamFeedUpdate';
+import TeamFeedDelete from './TeamFeedDelete';
+import CommentList from './CommentList';
+import { FaThumbsUp } from 'react-icons/fa';
+
+const TeamFeedDetailInfo = ({ teamFeedId }) => {
+ const [update, setUpdate] = useState(false);
+ const [teamFeed, setTeamFeed] = useState(null);
+ const [showMenu, setShowMenu] = useState(false);
+ const [likeCount, setLikeCount] = useState(1); // 임시 값
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const res = await fetch(`http://52.78.12.127:8080/api/files/file/${teamFeedId}`);
+ if (!res.ok) throw new Error('네트워크 에러');
+ const data = await res.json();
+ setTeamFeed(data);
+ } catch (error) {
+ setTeamFeed(null);
+ }
+ };
+ fetchData();
+ }, [teamFeedId]);
+
+ if (!teamFeed) {
+ return (
+
+ 게시글을 찾을 수 없습니다.
+
+ );
+ }
+
+ return (
+
+ {/* 우측 상단 메뉴 */}
+
+
setShowMenu((prev) => !prev)}
+ className="text-gray-400 hover:text-gray-700 text-2xl select-none"
+ aria-label="게시글 메뉴 열기"
+ type="button"
+ >
+ ⋯
+
+ {showMenu && (
+
+ {
+ setUpdate(true);
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition"
+ type="button"
+ >
+ ✏️ 수정
+
+ (
+ {
+ onClick();
+ setShowMenu(false);
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-gray-100 transition"
+ type="button"
+ >
+ 🗑️ 삭제
+
+ )}
+ />
+
+ )}
+
+
+ {/* 제목 */}
+
+ {teamFeed.title}
+
+
+ {/* 작성자 정보 */}
+
+ 작성자: {teamFeed.writer || '이름 없음'}
+
+
+ {/* 미디어 */}
+
+ {teamFeed.fileType.startsWith('image/') ? (
+
+ ) : teamFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+
지원되지 않는 파일입니다.
+ )}
+
+
+ {/* 본문 */}
+
+ {teamFeed.content}
+
+
+ {/* 좋아요 버튼 */}
+
+ setLikeCount((prev) => prev + 1)}
+ aria-label="좋아요 버튼"
+ style={{ borderRadius: '9999px' }} // 이건 원형 유지해도 됨 (버튼)
+ >
+
+ 좋아요 {likeCount}
+
+
+
+ {/* 좋아요와 댓글 사이 구분 띠 */}
+
+
+ {/* 댓글 카드 */}
+
+
+ {/* 수정창 */}
+ {update && (
+
+ )}
+
+ );
+};
+
+export default TeamFeedDetailInfo;
diff --git a/front-end/src/components/teamfeed/TeamFeedDetailList.jsx b/front-end/src/components/teamfeed/TeamFeedDetailList.jsx
new file mode 100644
index 0000000..223f565
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedDetailList.jsx
@@ -0,0 +1,48 @@
+import { useEffect, useState } from 'react';
+import TeamFeedDetail from './TeamFeedDetail';
+import styled from 'styled-components';
+
+const GridContainer = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(12vh, 1fr));
+ gap: 2vh;
+ justify-items: center;
+`;
+
+const Title = styled.h2`
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: #222;
+ margin-bottom: 1rem;
+ text-align: ;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+`;
+
+const TeamFeedDetailList = ({ teamId }) => {
+ const [teamFeedList, setTeamFeedList] = useState([]);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/files/team/${teamId}`
+ );
+ const data = await res.json();
+ setTeamFeedList(data);
+ };
+ fetchData();
+ }, [teamId]);
+
+ return (
+ <>
+ 팀 게시글 리스트
+
+ {teamFeedList.map((teamFeed) => (
+
+ ))}
+
+ >
+ );
+};
+
+export default TeamFeedDetailList;
diff --git a/front-end/src/components/teamfeed/TeamFeedUpdate.jsx b/front-end/src/components/teamfeed/TeamFeedUpdate.jsx
new file mode 100644
index 0000000..191052e
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamFeedUpdate.jsx
@@ -0,0 +1,181 @@
+import { useState, useRef } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const ImagePreview = styled.img`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+`;
+
+const StyledVideo = styled.video`
+ width: 30vh;
+ height: 30vh;
+ margin: 2vh;
+ object-fit: fill;
+ border-radius: 1vh;
+`;
+
+const TeamFeedUpdate = ({ setUpdate, teamFeedId, teamFeed }) => {
+ const teamId = teamFeed.teamId;
+ const [title, setTitle] = useState(teamFeed.title);
+ const [content, setContent] = useState(teamFeed.content);
+ const [file, setFile] = useState(''); // 파일 객체 (image or video)
+ const [fileType, setFileType] = useState(teamFeed.fileType);
+ const [fileUrl, setFileUrl] = useState(
+ `http://52.78.12.127:8080/media/team/${teamFeed.realFileName}`,
+ ); // 미리보기 URL
+ const fileInputRef = useRef(null);
+
+ const handleFileUpload = (e) => {
+ const uploaded = e.target.files[0];
+ if (uploaded) {
+ setFile(uploaded);
+ setFileType(uploaded.type);
+ setFileUrl(URL.createObjectURL(uploaded));
+ }
+ };
+
+ const handleClickFileInput = () => {
+ fileInputRef.current?.click(); // 숨겨진 input을 트리거
+ };
+
+ const handleUpdate = async () => {
+ if (!title || !content) {
+ alert('내용을 전부 입력해주세요.');
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('teamId', teamId);
+ formData.append('title', title);
+ formData.append('content', content);
+
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/files/fileInfo/${teamFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (file) {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/files/fileData/${teamFeed.fileId}`,
+ {
+ method: 'PUT',
+ body: formData,
+ },
+ );
+
+ if (!res.ok) {
+ alert((await res.text()) || '팀 게시글 파일 수정 실패');
+ }
+ }
+
+ if (response.ok) {
+ alert('팀 게시글 수정 완료!');
+ window.location.reload();
+ } else {
+ alert((await response.text()) || '팀 게시글 수정 실패');
+ }
+ } catch (error) {
+ console.error('팀 게시글 수정 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+ setUpdate(false)}
+ className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center z-[9999]"
+ >
+
e.stopPropagation()}
+ className="bg-white rounded-[2vh] p-[4vh_3vh] w-[90%] max-w-[360px] box-border shadow-lg relative animate-fadeIn"
+ >
+
+
게시글 수정
+ setUpdate(false)}
+ className="text-[2.4vh] bg-none border-none cursor-pointer absolute right-0 top-0"
+ >
+ ✖
+
+
+
+
+
+ 파일
+ 📎
+
+ 파일 변경
+
+
+
+ {fileType?.startsWith('video/') ? (
+
+ ) : (
+
{
+ e.target.src = altImage;
+ }}
+ />
+ )}
+
+
+
+ {/* 제목 */}
+
+
+ 제목 ✏️
+
+
setTitle(e.target.value)}
+ className="w-full text-[1.7vh] p-[1.5vh] border border-gray-300 rounded-[1vh] bg-[#f9f9f9] focus:outline-green-500 focus:bg-white box-border"
+ />
+
+
+ {/* 내용 */}
+
+
+ {/* 등록 버튼 */}
+
+ 수정
+
+
+
+ );
+};
+
+export default TeamFeedUpdate;
diff --git a/front-end/src/components/teamfeed/TeamInfo.jsx b/front-end/src/components/teamfeed/TeamInfo.jsx
new file mode 100644
index 0000000..0d4fd69
--- /dev/null
+++ b/front-end/src/components/teamfeed/TeamInfo.jsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from "react";
+import UniformIcon from '../common/UniformIcon';
+import { CiSquarePlus } from "react-icons/ci";
+import TeamFeedCreate from "./TeamFeedCreate";
+import styled from "styled-components";
+import altImage from '../../img/alt_image.png'; // altImage import 추가
+
+const Title = styled.h2`
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: #222;
+ margin-bottom: 0.25rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+`;
+
+// 밑줄용 컴포넌트 (가로 길이, 두께, 색상 자유롭게 조절 가능)
+const Underline = styled.div`
+ width: 3.5rem;
+ height: 3px;
+ background-color: #28a745; /* 파란색 계열 */
+ margin-bottom: 1rem;
+ border-radius: 2px;
+`;
+
+const TeamInfo = ({ teamId }) => {
+ const userMail = sessionStorage.getItem('userMail')
+ const [team, setTeam] = useState();
+ const [teamUser, setTeamUser] = useState([]);
+ const [teamManagerMail, setTeamManagerMail] = useState();
+ const [create, setCreate] = useState(false);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const teamRes = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}`);
+ const teamData = await teamRes.json();
+ setTeam(teamData);
+ setTeamManagerMail(teamData.teamManager?.userMail || '');
+
+ const userRes = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/users-in-team`);
+ setTeamUser(await userRes.json());
+ };
+ fetchData();
+
+ }, [teamId]);
+
+ if (!team) return <>로딩중>;
+
+ return (
+
+
팀 정보
+
+
+
+
(e.target.src = altImage)}
+ alt="팀 로고"
+ className="w-16 h-16 rounded-lg object-cover"
+ />
+
+
{team.teamName}
+
📍 {team.location}
+
👥 {teamUser.length}명
+
+
+
+
+
+ {userMail === teamManagerMail && (
+ setCreate(true)} className="w-5 h-5 cursor-pointer" title="새 게시글 작성" />
+ )}
+
+ {create &&
}
+
+
+ )
+}
+
+export default TeamInfo;
diff --git a/front-end/src/components/teams/TeamCard.jsx b/front-end/src/components/teams/TeamCard.jsx
new file mode 100644
index 0000000..41927b5
--- /dev/null
+++ b/front-end/src/components/teams/TeamCard.jsx
@@ -0,0 +1,74 @@
+import { Link } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+
+const TeamCard = ({ team, posts }) => {
+ const isRecruiting = (category) =>
+ posts.some(
+ (post) => post.teamId === team.teamId && post.category === category,
+ );
+
+ return (
+
+ {/* 팀 정보 */}
+
+
{
+ e.target.src = altImage;
+ }}
+ />
+
+
+ {team.teamName}
+
+
+ {team.location} · {team.users.length}명
+
+
+ {isRecruiting('팀원 모집') && (
+
+ 팀원 모집 중{' '}
+
+ )}
+ {isRecruiting('용병') && (
+
+ 용병 모집 중
+
+ )}
+ {isRecruiting('매칭') && (
+
+ 매칭 모집 중
+
+ )}
+
+
+
+
+ {/* 자세히 버튼 */}
+
+
+ );
+};
+
+export default TeamCard;
diff --git a/front-end/src/components/teams/TeamCreateModal.jsx b/front-end/src/components/teams/TeamCreateModal.jsx
new file mode 100644
index 0000000..9fb7174
--- /dev/null
+++ b/front-end/src/components/teams/TeamCreateModal.jsx
@@ -0,0 +1,281 @@
+import { useState } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+
+const Overlay = styled.div`
+ position: fixed;
+ bottom: 5vh;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 50vh;
+ max-width: 100vw;
+ background-color: #f4f4f4;
+ border-top-left-radius: 2vh;
+ border-top-right-radius: 2vh;
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+`;
+
+const Row = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1vh;
+`;
+
+const ImagePreview = styled.img`
+ width: 10vh;
+ height: 10vh;
+ border-radius: 50%;
+ margin: 2vh;
+ object-fit: cover;
+`;
+
+const Input = styled.input`
+ font-size: 1.8vh;
+ padding: 1vh;
+ margin: 0.5vh;
+ width: 80%;
+ border-radius: 1vh;
+ border: 1px solid #ccc;
+`;
+
+const ButtonBox = styled.div`
+ display: flex;
+ justify-content: space-between;
+ width: 90%;
+`;
+
+const ColorBox = styled.div`
+ display: flex;
+ justify-content: space-between;
+ width: 25%;
+ align-items: center;
+ position: relative;
+`;
+
+const ColorButton = styled.button`
+ margin-left: 2vh;
+ margin-bottom: 2vh;
+ width: 3vh;
+ height: 3vh;
+ border-radius: 50%;
+ border: none;
+ background-color: ${(props) => props.color};
+ opacity: ${(props) => (props.selected ? 1 : 0.4)};
+ border: ${(props) => (props.color === 'white' ? '1px solid black' : 'none')};
+`;
+
+const CreateButton = styled.button`
+ margin-left: 2vh;
+ margin-bottom: 2vh;
+ height: 4.3vh;
+ background-color: black;
+ color: white;
+ font-size: 2vh;
+ padding: 1vh 2vh;
+ border: none;
+ border-radius: 1vh;
+`;
+
+const ColorPalette = styled.div`
+ position: absolute;
+ bottom: 5.5vh;
+ left: 0;
+ display: grid;
+ grid-template-columns: repeat(4, 3vh);
+ gap: 1vh;
+ background-color: white;
+ border: 1px solid #ccc;
+ padding: 1vh;
+ border-radius: 1vh;
+ z-index: 999;
+`;
+
+const ColorOption = styled.div`
+ width: 3vh;
+ height: 3vh;
+ border-radius: 50%;
+ background-color: ${(props) => props.color};
+ border: ${(props) => (props.color === 'white' ? '1px solid black' : 'none')};
+ cursor: pointer;
+`;
+
+const ALL_COLORS = [
+ 'red', 'blue', 'skyblue', 'navy', 'white', 'black', 'yellow', 'orange',
+ 'green', 'darkgreen', 'maroon', 'purple', 'pink', 'gray', 'gold', 'teal',
+];
+
+const TeamCreateModal = ({ onClose, onSuccess }) => {
+ const userMail = sessionStorage.getItem('userMail');
+ const [teamName, setTeamName] = useState('');
+ const [location, setLocation] = useState('');
+ const [logo, setLogo] = useState(null);
+ const [logoFile, setLogoFile] = useState(null);
+ const [homeColor, setHomeColor] = useState('red');
+ const [awayColor, setAwayColor] = useState('black');
+ const [showColorPicker, setShowColorPicker] = useState(false);
+ const [selectedColorType, setSelectedColorType] = useState(null); // 'home' or 'away'
+
+ const handleImageUpload = (e) => {
+ const file = e.target.files[0];
+ setLogoFile(file);
+ const reader = new FileReader();
+ reader.onloadend = () => setLogo(reader.result);
+ reader.readAsDataURL(file);
+ };
+
+ const handleSelectPaletteColor = (color) => {
+ if (selectedColorType === 'home') {
+ setHomeColor(color);
+ } else if (selectedColorType === 'away') {
+ setAwayColor(color);
+ }
+ setShowColorPicker(false);
+ setSelectedColorType(null);
+ };
+
+ const handleCreate = async () => {
+ if (!teamName || !location) {
+ alert('팀명과 위치는 필수입니다.');
+ return;
+ }
+
+ if (homeColor === awayColor) {
+ alert('두 유니폼 색상은 달라야 합니다.');
+ return;
+ }
+
+ let userData;
+ try {
+ const response = await fetch(`http://52.78.12.127:8080/api/users/check/${userMail}`);
+ if (response.ok) {
+ userData = await response.json();
+ } else {
+ alert(await response.text());
+ return;
+ }
+ } catch (err) {
+ console.error(err);
+ alert('유저 정보 조회 실패');
+ return;
+ }
+
+ let finalLogoFile = logoFile;
+ if (!finalLogoFile) {
+ try {
+ const response = await fetch(altImage);
+ const blob = await response.blob();
+ finalLogoFile = new File([blob], 'default-logo.png', { type: blob.type });
+ } catch (err) {
+ console.error(err);
+ alert('기본 로고 파일 불러오기 실패');
+ return;
+ }
+ }
+
+ const newTeam = {
+ teamManager: userData,
+ teamName,
+ location,
+ firstColor: homeColor,
+ secondColor: awayColor,
+ };
+
+ try {
+ const formData = new FormData();
+ formData.append('team', new Blob([JSON.stringify(newTeam)], { type: 'application/json' }));
+ formData.append('logo', finalLogoFile);
+
+ const response = await fetch('http://52.78.12.127:8080/api/teams/create-team', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (response.ok) {
+ alert('팀 생성 완료!');
+ onClose();
+ if (onSuccess) onSuccess();
+ } else {
+ alert(await response.text() || '팀 생성 실패');
+ }
+ } catch (error) {
+ console.error('팀 생성 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TeamCreateModal;
diff --git a/front-end/src/components/teams/TeamFeed.jsx b/front-end/src/components/teams/TeamFeed.jsx
new file mode 100644
index 0000000..9c3b433
--- /dev/null
+++ b/front-end/src/components/teams/TeamFeed.jsx
@@ -0,0 +1,53 @@
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+
+const Container = styled.div`
+ width: 100%;
+ max-width: 220px;
+ background-color: #fff;
+ border-radius: 0.75rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
+ overflow: hidden;
+ transition: transform 0.2s ease;
+ cursor: pointer;
+
+ &:hover {
+ transform: translateY(-4px);
+ }
+`;
+
+const ImageWrapper = styled.div`
+ width: 100%;
+ height: 10px;
+ overflow: hidden;
+`;
+
+const StyledImg = styled.img`
+ width: 50%;
+ height: 50%;
+ object-fit: cover;
+ display: block;
+`;
+
+const Title = styled.h2`
+ font-size: 4rem;
+ font-weight: 600;
+ padding: 0.75rem 1rem;
+ color: #333;
+ text-align: center;
+`;
+
+const TeamFeed = ({ teamFeed }) => {
+ return (
+
+
+
+
+
+ {teamFeed.title}
+
+
+ );
+};
+
+export default TeamFeed;
diff --git a/front-end/src/components/teams/TeamFeedList.jsx b/front-end/src/components/teams/TeamFeedList.jsx
new file mode 100644
index 0000000..9522b77
--- /dev/null
+++ b/front-end/src/components/teams/TeamFeedList.jsx
@@ -0,0 +1,81 @@
+import { Link, useParams } from 'react-router-dom';
+import ScrollContainer from 'react-indiana-drag-scroll';
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+
+const StyledImg = styled.img`
+ width: 10vh;
+ height: 10vh;
+ object-fit: cover;
+ border-radius: 0.4vh;
+`;
+
+const StyledVideo = styled.video`
+ width: 10vh;
+ height: 10vh;
+ object-fit: cover;
+ border-radius: 0.4vh;
+`;
+
+const TeamFeedList = () => {
+ const { teamId } = useParams();
+ const [teamFeedList, setTeamFeedList] = useState([]);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/files/team/${teamId}`,
+ );
+ const data = await res.json();
+ setTeamFeedList(data);
+ };
+ fetchData();
+ }, [teamId]);
+
+ if (!teamFeedList) return <>로딩중>;
+
+ return (
+
+
+
📝 팀 게시글
+
+ 전체보기
+
+
+
+
+ {teamFeedList.map((teamFeed) => (
+
+
+ {teamFeed.fileType.startsWith('image/') ? (
+
+ ) : teamFeed.fileType.startsWith('video/') ? (
+
+ ) : (
+ 지원되지 않는 파일
+ )}
+
+
+ {teamFeed.title}
+
+
+ ))}
+
+
+ );
+};
+
+export default TeamFeedList;
diff --git a/front-end/src/components/teams/TeamInfo.jsx b/front-end/src/components/teams/TeamInfo.jsx
new file mode 100644
index 0000000..995847d
--- /dev/null
+++ b/front-end/src/components/teams/TeamInfo.jsx
@@ -0,0 +1,136 @@
+import { useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+import setting from '../../img/setting.png';
+import TeamMatch from './TeamMatch';
+import TeamJoin from './TeamJoin';
+import TeamFeedList from './TeamFeedList';
+import UniformIcon from '../common/UniformIcon';
+
+const TeamInfo = ({ teamId }) => {
+ const [team, setTeam] = useState(null);
+ const [teamUser, setTeamUser] = useState([]);
+ const [games, setGames] = useState([]);
+ const [teamManagerId, setTeamManagerId] = useState('');
+ const [showMembers, setShowMembers] = useState(false);
+ const userId = sessionStorage.getItem('userId');
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const teamRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${teamId}`,
+ );
+ const teamData = await teamRes.json();
+ setTeam(teamData);
+ setTeamManagerId(teamData.teamManagerId || '');
+
+ const userRes = await fetch(
+ `http://52.78.12.127:8080/api/teams/${teamId}/users-in-team`,
+ );
+ setTeamUser(await userRes.json());
+
+ const gamesRes = await fetch(
+ `http://52.78.12.127:8080/api/games/team/${teamId}`,
+ );
+ const text = await gamesRes.text();
+ setGames(text ? JSON.parse(text) : []);
+ };
+ fetchData();
+ }, [teamId]);
+
+ if (!team) return 로딩 중...
;
+
+ const isInTeam = teamUser.some(
+ (user) => Number(user.userId) === Number(userId),
+ );
+
+ const moveProfile = (userId) => {
+ navigate(`/profile/${userId}`);
+ };
+
+ const manager = teamUser.find((u) => u.userId == teamManagerId);
+
+ return (
+
+ {/* 팀 요약 */}
+
+
+
(e.target.src = altImage)}
+ alt="팀 로고"
+ className="w-16 h-16 rounded-lg object-cover"
+ />
+
+
{team.teamName}
+
📍 {team.location}
+
👥 {teamUser.length}명
+
+
+
+
+
+ {userId == teamManagerId && (
+
+
+
+ )}
+
+
+
+ {/* 팀원 명단 */}
+
+
setShowMembers(!showMembers)}
+ >
+
👥 팀 명단
+
+ {showMembers ? '숨기기 ▲' : '보기 ▼'}
+
+
+ {showMembers && (
+
+ {manager && (
+ moveProfile(manager.userId)}
+ >
+ 👑 {manager.userName} ({manager.firstPosition},{' '}
+ {manager.secondPosition}, {manager.thirdPosition})
+
+ )}
+ {teamUser
+ .filter((u) => u.userId !== teamManagerId)
+ .map((u) => (
+ moveProfile(u.userId)}
+ >
+ 👤 {u.userName} ({u.firstPosition}, {u.secondPosition},{' '}
+ {u.thirdPosition})
+
+ ))}
+
+ )}
+
+
+ {/* 팀 게시글 */}
+
+
+ {/* 경기 일정 */}
+
+
📅 경기 일정
+ {isInTeam ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default TeamInfo;
diff --git a/front-end/src/components/teams/TeamJoin.jsx b/front-end/src/components/teams/TeamJoin.jsx
new file mode 100644
index 0000000..e9f343b
--- /dev/null
+++ b/front-end/src/components/teams/TeamJoin.jsx
@@ -0,0 +1,45 @@
+import styled from 'styled-components';
+
+const StyledButton = styled.button`
+ background-color: black;
+ color: white;
+ width: 100%;
+ height: 6vh;
+ font-size: 2vh;
+ border-radius: 0.7vh;
+ margin-top: 3vh;
+ &:hover {
+ cursor: pointer;
+ }
+`;
+
+const TeamJoin = () => {
+ const handleJoin = async () => {
+ const teamId = sessionStorage.getItem('teamId');
+ const userMail = sessionStorage.getItem('userMail');
+
+ if (!teamId || !userMail) return alert('정보 누락');
+
+ try {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/add-user`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail }),
+ });
+
+ if (res.ok) {
+ alert('팀 가입 완료!');
+ window.location.reload();
+ } else {
+ alert(`가입 실패: ${await res.text()}`);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ return 팀 가입하기 ;
+};
+
+export default TeamJoin;
diff --git a/front-end/src/components/teams/TeamList.jsx b/front-end/src/components/teams/TeamList.jsx
new file mode 100644
index 0000000..6fd5af3
--- /dev/null
+++ b/front-end/src/components/teams/TeamList.jsx
@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react';
+import TeamCard from './TeamCard';
+
+const TeamList = ({ refreshFlag, keyword }) => {
+ const [teams, setTeams] = useState(null);
+ const [posts, setPosts] = useState([]);
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ try {
+ const url = keyword && keyword.trim()
+ ? `http://52.78.12.127:8080/api/teams/name/${keyword.trim()}`
+ : `http://52.78.12.127:8080/api/teams/`;
+
+ const response = await fetch(url);
+ if (response.ok) {
+ const data = await response.json();
+ setTeams(data);
+ } else {
+ const err = await response.text();
+ alert(err || '팀 목록을 불러오지 못했습니다.');
+ }
+ } catch (err) {
+ console.error('팀 불러오기 실패:', err);
+ alert('서버 통신 중 오류가 발생했습니다.');
+ }
+ };
+
+ fetchTeams();
+ }, [keyword]);
+
+ useEffect(() => {
+ const fetchPosts = async () => {
+ try {
+ const res = await fetch('http://52.78.12.127:8080/api/community');
+ if (res.ok) {
+ const data = await res.json();
+ setPosts(data);
+ } else {
+ console.error(await res.text());
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ fetchPosts();
+ }, [refreshFlag]);
+
+ if (!teams) return 로딩 중...
;
+ if (teams.length === 0) return 검색 결과가 없습니다.
;
+
+ return (
+
+ {teams.map((team, i) => (
+
+ ))}
+
+ );
+};
+
+export default TeamList;
diff --git a/front-end/src/components/teams/TeamMatch.jsx b/front-end/src/components/teams/TeamMatch.jsx
new file mode 100644
index 0000000..c8f774b
--- /dev/null
+++ b/front-end/src/components/teams/TeamMatch.jsx
@@ -0,0 +1,146 @@
+import dayjs from 'dayjs';
+import { Link } from 'react-router-dom';
+import altImage from '../../img/alt_image.png';
+import { useEffect, useState } from 'react';
+
+const TeamMatch = ({ games, teamManagerId }) => {
+ const userId = sessionStorage.getItem('userId');
+ const teamId = sessionStorage.getItem('teamId');
+ const [team, setTeam] = useState([]);
+
+ useEffect(() => {
+ const fetchTeam = async () => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/teams/${teamId}`,
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setTeam(data);
+ } else {
+ console.log(await response.text());
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ }
+ };
+
+ fetchTeam();
+ }, [teamId]);
+
+ const handleLeave = async () => {
+ try {
+ const res = await fetch(
+ `http://52.78.12.127:8080/api/teams/${teamId}/remove-user/id`,
+ {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userId }),
+ },
+ );
+ if (res.ok) {
+ alert('팀 탈퇴 완료');
+ sessionStorage.removeItem('teamId');
+ window.location.reload();
+ } else {
+ alert(`탈퇴 실패: ${await res.text()}`);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('서버 오류');
+ }
+ };
+
+ const sortedGames = [...games].sort((a, b) =>
+ dayjs(a.date).isAfter(dayjs(b.date)) ? 1 : -1,
+ );
+
+ return (
+
+
+
+
+
+ {games.length === 0 ? (
+
+ 예정된 경기가 없습니다.
+
+ ) : (
+ sortedGames.map((game) => (
+
+
+ {game.gameName}
+
+
+
+ {/* 홈 팀 */}
+
+
(e.target.src = altImage)}
+ alt="home"
+ className="w-14 h-14 rounded-full object-cover mb-1 border border-gray-300"
+ />
+
+ {team.teamName}
+
+
+
+ {/* 경기 정보 */}
+
+
+ {dayjs(game.date).format('YYYY.MM.DD HH:mm')}
+
+ VS
+ {game.playersMail?.some(
+ (player) => player.userMail === userMail,
+ ) && (
+
+ 참가중
+
+ )}
+
+
+ {/* 어웨이 팀 */}
+
+
(e.target.src = altImage)}
+ alt="away"
+ className="w-14 h-14 rounded-full object-cover mb-1 border border-gray-300"
+ />
+
+ {game.versus}
+
+
+
+
+ ))
+ )}
+
+
+ {userId == teamManagerId ? (
+
+
+ 경기 추가
+
+
+ ) : (
+
+ 팀 탈퇴하기
+
+ )}
+
+
+ );
+};
+
+export default TeamMatch;
diff --git a/front-end/src/components/teams/TeamMember.jsx b/front-end/src/components/teams/TeamMember.jsx
new file mode 100644
index 0000000..b33d902
--- /dev/null
+++ b/front-end/src/components/teams/TeamMember.jsx
@@ -0,0 +1,66 @@
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+
+const UserBox = styled.li`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.2vh 2vh;
+ border-bottom: 1px solid #ddd;
+`;
+
+const ButtonBox = styled.div`
+ display: flex;
+ gap: 1vh;
+`;
+
+const TeamMember = ({ user, teamId, refreshUsers }) => {
+ const userMail = sessionStorage.getItem('userMail');
+ const navigate = useNavigate()
+
+ const handleRemove = async () => {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/remove-user`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail: user.userMail }),
+ });
+ if (res.ok) {
+ alert('선수 방출 완료');
+ refreshUsers();
+ } else {
+ alert(await res.text());
+ }
+ };
+
+ const handlePromote = async () => {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/transfer-manager`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ currentManagerMail: userMail, newManagerMail: user.userMail }),
+ });
+ if (res.ok) {
+ alert('팀 매니저가 변경되었습니다.');
+ navigate(`/team/${teamId}`)
+ } else {
+ alert(await res.text());
+ }
+ };
+
+ return (
+
+
+ {user.userName}
+
+ {user.userMail}
+
+
+ 매니저 임명
+
+ 방출
+
+
+
+ );
+};
+
+export default TeamMember;
diff --git a/front-end/src/components/teams/TeamMemberList.jsx b/front-end/src/components/teams/TeamMemberList.jsx
new file mode 100644
index 0000000..03bb570
--- /dev/null
+++ b/front-end/src/components/teams/TeamMemberList.jsx
@@ -0,0 +1,45 @@
+import { useEffect, useState } from 'react';
+import TeamMember from './TeamMember';
+
+const TeamMemberList = ({ teamId }) => {
+ const [users, setUsers] = useState([]);
+ const userMail = sessionStorage.getItem('userMail');
+
+ useEffect(() => {
+ const fetchUsers = async () => {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/users-in-team`);
+ const data = await res.json();
+ setUsers(data.filter((user) => user.userMail !== userMail));
+ };
+ fetchUsers();
+ }, [teamId, userMail]);
+
+ return (
+ <>
+ {/* 팀원 관리 */}
+
+
팀원 관리
+
+
+
+ {users.map((user) => (
+ {
+ fetch(`http://52.78.12.127:8080/api/teams/${teamId}/users-in-team`)
+ .then((res) => res.json())
+ .then((data) =>
+ setUsers(data.filter((u) => u.userMail !== userMail))
+ );
+ }}
+ />
+ ))}
+
+
+ >
+ );
+};
+
+export default TeamMemberList;
diff --git a/front-end/src/components/teams/TeamSaveDelete.jsx b/front-end/src/components/teams/TeamSaveDelete.jsx
new file mode 100644
index 0000000..0a638a4
--- /dev/null
+++ b/front-end/src/components/teams/TeamSaveDelete.jsx
@@ -0,0 +1,77 @@
+import { useNavigate } from 'react-router-dom';
+import { FaSave, FaTrash } from 'react-icons/fa';
+
+const TeamSaveDelete = ({ team, teamId, logoFile }) => {
+ const navigate = useNavigate();
+
+ const handleUpdate = async () => {
+ const res = await fetch('http://52.78.12.127:8080/api/teams/update-team', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(team),
+ });
+ if (res.ok) {
+ alert('팀 정보 수정 완료');
+ navigate(`/team/${teamId}`);
+ } else {
+ alert('수정 실패');
+ }
+ }
+
+ const handleUpdateLogo = async () => {
+ if(logoFile) {
+ const formData = new FormData();
+ formData.append('file', logoFile);
+
+ const response = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/upload-logo`, {
+ method: 'POST',
+ body: formData,
+ });
+ if (!response.ok) {
+ alert('수정 실패');
+ }
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!window.confirm('정말 팀을 삭제하시겠습니까?')) return;
+
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/delete-team`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ teamId: Number(teamId) }),
+ });
+
+ if (res.ok) {
+ alert('삭제 완료');
+ sessionStorage.removeItem('teamId');
+ navigate('/main');
+ } else {
+ alert(await res.text());
+ }
+ };
+ return (
+
+ {
+ handleUpdate()
+ handleUpdateLogo()
+ }}
+ className="flex items-center gap-2 border border-blue-500 text-blue-500 px-4 py-2 rounded-full hover:bg-blue-50"
+ >
+
+ 팀 정보 저장
+
+
+
+
+ 팀 삭제
+
+
+ )
+}
+
+export default TeamSaveDelete;
\ No newline at end of file
diff --git a/front-end/src/components/teams/TeamSearch.jsx b/front-end/src/components/teams/TeamSearch.jsx
new file mode 100644
index 0000000..a713923
--- /dev/null
+++ b/front-end/src/components/teams/TeamSearch.jsx
@@ -0,0 +1,34 @@
+import { useState } from 'react';
+
+const TeamSearch = ({ onSearch }) => {
+ const [input, setInput] = useState('');
+
+ const handleSearchClick = () => {
+ if (onSearch) onSearch(input.trim());
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter') handleSearchClick();
+ };
+
+ return (
+
+ setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="flex-1 text-[1.7vh] focus:outline-none"
+ />
+
+ 🔍
+
+
+ );
+};
+
+export default TeamSearch;
diff --git a/front-end/src/components/teams/TeamUpdate.jsx b/front-end/src/components/teams/TeamUpdate.jsx
new file mode 100644
index 0000000..10ac82b
--- /dev/null
+++ b/front-end/src/components/teams/TeamUpdate.jsx
@@ -0,0 +1,152 @@
+// TeamUpdate.jsx: 팀원 목록 제목과 리스트만 보여줌
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import altImage from '../../img/alt_image.png';
+import UniformIcon from '../common/UniformIcon';
+import TeamMemberList from './TeamMemberList';
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 3vh;
+ padding: 5vh 2vh;
+`;
+
+const Row = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 2vh;
+ width: 100%;
+`;
+
+const ImagePreview = styled.img`
+ width: 10vh;
+ height: 10vh;
+ border-radius: 50%;
+ object-fit: cover;
+`;
+
+const Input = styled.input`
+ width: 100%;
+ padding: 1.2vh;
+ border: 1px solid #ccc;
+ border-radius: 0.7vh;
+ font-size: 1.6vh;
+ margin-bottom: 1vh;
+`;
+
+const Label = styled.label`
+ font-size: 1.5vh;
+ font-weight: bold;
+ margin-bottom: 0.5vh;
+ display: block;
+`;
+
+const UniformBox = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5vh;
+ padding: 2vh 0;
+ border-top: 1px solid #ddd;
+ border-bottom: 1px solid #ddd;
+`;
+
+const UniformColor = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1vh;
+`;
+
+const UniformLabel = styled.span`
+ font-size: 1.4vh;
+ font-weight: 500;
+ margin-right: 0.5vh;
+`;
+
+const TeamUpdate = ({ team, setTeam, setLogoFile, teamId }) => {
+ const [teamName, setTeamName] = useState('');
+ const [location, setLocation] = useState('');
+ const [firstColor, setFirstColor] = useState('');
+ const [secondColor, setSecondColor] = useState('');
+ const [logo, setLogo] = useState(null);
+ const [teamUser, setTeamUser] = useState([]);
+
+ useEffect(() => {
+ const fetchTeam = async () => {
+ const res = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}`);
+ const data = await res.json();
+ setTeam(data);
+ setTeamName(data.teamName);
+ setLocation(data.location);
+ setFirstColor(data.firstColor);
+ setSecondColor(data.secondColor);
+ if (data.logo) {
+ setLogo(`http://52.78.12.127:8080/logos/${data.logo}`);
+ } else {
+ setLogo(altImage);
+ }
+
+ const userRes = await fetch(`http://52.78.12.127:8080/api/teams/${teamId}/users-in-team`);
+ setTeamUser(await userRes.json());
+ };
+
+ fetchTeam();
+ }, [teamId]);
+
+ useEffect(() => {
+ setTeam(prev => ({
+ ...prev,
+ teamName,
+ location,
+ firstColor,
+ secondColor,
+ }))
+ }, [teamName, location, firstColor, secondColor])
+
+ const handleImageUpload = (e) => {
+ const file = e.target.files[0];
+ setLogoFile(file);
+ const reader = new FileReader();
+ reader.onloadend = () => setLogo(reader.result);
+ reader.readAsDataURL(file);
+ };
+
+ if (!team) return 로딩 중...
;
+
+ return (
+
+ {/* 팀 프로필 */}
+
+
+ (e.target.src = altImage)} />
+
+
+
+
+ 팀명
+ setTeamName(e.target.value)} />
+ 위치
+ setLocation(e.target.value)} />
+
+
+
+ {/* 유니폼 색상 */}
+
+
+ HOME
+
+ setFirstColor(e.target.value)} className="w-10 h-6 border cursor-pointer" />
+
+
+
+ AWAY
+
+ setSecondColor(e.target.value)} className="w-10 h-6 border cursor-pointer" />
+
+
+
+ );
+};
+
+export default TeamUpdate;
diff --git a/front-end/src/constants/positionList.js b/front-end/src/constants/positionList.js
new file mode 100644
index 0000000..ef182eb
--- /dev/null
+++ b/front-end/src/constants/positionList.js
@@ -0,0 +1,27 @@
+export const positionList = [
+ { key: 'stId', label: 'ST', top: '1vh', left: '22.3vh' },
+ { key: 'lsId', label: 'LS', top: '3vh', left: '14.3vh' },
+ { key: 'rsId', label: 'RS', top: '3vh', left: '30.3vh' },
+ { key: 'lwId', label: 'LW', top: '6vh', left: '6.3vh' },
+ { key: 'cfId', label: 'CF', top: '6vh', left: '22.3vh' },
+ { key: 'rwId', label: 'RW', top: '6vh', left: '38.3vh' },
+ { key: 'lamId', label: 'LAM', top: '12vh', left: '14.3vh' },
+ { key: 'camId', label: 'CAM', top: '12vh', left: '22.3vh' },
+ { key: 'ramId', label: 'RAM', top: '12vh', left: '30.3vh' },
+ { key: 'lmId', label: 'LM', top: '18vh', left: '6.3vh' },
+ { key: 'lcmId', label: 'LCM', top: '18vh', left: '14.3vh' },
+ { key: 'cmId', label: 'CM', top: '18vh', left: '22.3vh' },
+ { key: 'rcmId', label: 'RCM', top: '18vh', left: '30.3vh' },
+ { key: 'rmId', label: 'RM', top: '18vh', left: '38.3vh' },
+ { key: 'lwbId', label: 'LWB', top: '24vh', left: '6.3vh' },
+ { key: 'ldmId', label: 'LDM', top: '24vh', left: '14.3vh' },
+ { key: 'cdmId', label: 'CDM', top: '24vh', left: '22.3vh' },
+ { key: 'rdmId', label: 'RDM', top: '24vh', left: '30.3vh' },
+ { key: 'rwbId', label: 'RWB', top: '24vh', left: '38.3vh' },
+ { key: 'lbId', label: 'LB', top: '30vh', left: '6.3vh' },
+ { key: 'lcbId', label: 'LCB', top: '30vh', left: '14.3vh' },
+ { key: 'swId', label: 'SW', top: '30vh', left: '22.3vh' },
+ { key: 'rcbId', label: 'RCB', top: '30vh', left: '30.3vh' },
+ { key: 'rbId', label: 'RB', top: '30vh', left: '38.3vh' },
+ { key: 'gkId', label: 'GK', top: '36vh', left: '22.3vh' },
+];
diff --git a/front-end/src/data/formation.json b/front-end/src/data/formation.json
new file mode 100644
index 0000000..23cfe69
--- /dev/null
+++ b/front-end/src/data/formation.json
@@ -0,0 +1,42 @@
+[
+ {
+ "id": "0",
+ "title": "3-4-3 포메이션",
+ "summation": "히딩크 감독과 태극전사의 4강신화",
+ "img": "/library/343.png",
+ "description1": "3-4-3 포메이션은 강한 전방압박을 기반으로 한 측면 공격 위주의 전술입니다. 공격수 세명과 미드필더, 거기에 윙백까지 압박에 가담하여 상대 수비의 실수를 유발하고, 측면으로 빠르게 공격을 전개합니다. 다른 포메이션에 비해 상대적으로 적은 중원의 숫자가 특징입니다.",
+ "description2": "센터백을 세명 두고, 사이드백을 위로 올려 미드필더를 지원하고 측면에서의 공수 모두를 담당시킵니다. 공격이 측면에 집중 되어있기 때문에, 중앙 공간이 상대적으로 넓어지고, 이를 커버하기 위해 미드필더들의 활동량이 필수적입니다. 측면 공격, 전방 압박에 유리하지만, 그만큼 기동력이 중요하고 체력소모가 심하기에, 장기적으로 체력 문제, 부상 유발로 이어질 수 있습니다."
+ },
+ {
+ "id": "1",
+ "title": "3-5-2 포메이션",
+ "summation": "몰락한 명가의 부활, 시모네 인자기의 인테르 밀란",
+ "img": "/library/352.png",
+ "description1": "3-5-2 포메이션은 중앙 미드필더의 숫자가 많아 중원 장악에 유리합니다. 공격 시에 윙백들이 미드필더를 지원하기 떄문에 중원에서 우위를 가져갈 수 있고, 수비 시에는 두 명의 볼란치와 윙백이 수비에 가담하여 안정적인 수비를 구축합니다.",
+ "description2": "측면 윙백의 활동량이 중요하며, 공격과 수비 전환이 빠른 팀에 적합합니다. 윙백과 볼란치, 특히 윙백은 공수 모두 가담하기 때문에 강인한 체력을 요구합니다. 공격형 미드필더의 역할이 매우 중요하기 때문에, 공격에서 뛰어난 재능을 가진 플레이메이커를 보유한 팀이 사용하기 적합한 포메이션입니다. 윙백들의 체력이 떨어진 경우 측면을 커버할 선수가 부족하여 측면 수비에 취약해질 수 있습니다. 또한 공격형 미드필더의 발이 묶이는 경우 공격이 진행이 안될 수 있습니다."
+ },
+ {
+ "id": "2",
+ "title": "4-3-3 포메이션",
+ "summation": "티키타카의 대명사이자 현대축구의 기본",
+ "img": "/library/433.png",
+ "description1": "4-3-3 포메이션은 공격 시 양쪽 윙어와 중앙 공격수가 넓게 퍼져 상대 수비를 흔들고, 미드필더 3명이 공격과 수비의 균형을 담당합니다. 수비 시에는 미드필더들이 라인을 내리며 4-1-4-1 형태로 전환되어 견고한 수비 블록을 형성합니다.",
+ "description2": "현대 축구의 핵심인 포지션 플레이와 포메이션 변형을 가져가기 적합한 포메이션으로, 가장 중요한 포지션은 중앙 미드필더(특히 수비형 미드필더)와 양쪽 윙어입니다. 미드필더는 공격과 수비 전환의 핵심 역할을 하며, 윙어는 빠른 돌파와 크로스, 수비 가담까지 요구됩니다. 장점은 공격과 수비의 균형, 넓은 공간 활용, 다양한 공격 루트이며, 단점은 미드필더의 활동량 부담과 중앙 공간이 비었을 때 수비 불안이 생길 수 있다는 점입니다."
+ },
+ {
+ "id": "3",
+ "title": "4-4-2 포메이션",
+ "summation": "아리고 사키 - 알렉스 퍼거슨 - 디에고 시메오네",
+ "img": "/library/442.png",
+ "description1": "4-4-2 포메이션은 공격 시 두 명의 스트라이커가 상대 수비를 압박하며, 미드필더 4명이 넓게 퍼져 측면과 중앙을 모두 활용합니다. 수비 시에는 두 줄의 4명이 촘촘한 라인을 형성해 상대의 공격을 효과적으로 차단합니다.",
+ "description2": "중앙 미드필더와 두 명의 스트라이커가 핵심 포지션입니다. 미드필더는 넓은 지역을 커버하며, 공격과 수비 모두에 적극적으로 참여해야 합니다. 장점은 단순하고 조직적인 수비, 빠른 역습에 강점이 있지만, 미드필더의 활동량 부담과 중앙 미드필더의 창의성 부족, 상대적으로 중원 장악력이 약할 수 있다는 단점이 있습니다."
+ },
+ {
+ "id": "4",
+ "title": "4-2-3-1 포메이션",
+ "summation": "신과 세계최강을 부숴버린 하인케스의 뮌헨",
+ "img": "/library/4231.png",
+ "description1": "4-2-3-1 포메이션은 공격 시 2명의 수비형 미드필더가 후방을 보호하고, 3명의 공격형 미드필더가 다양한 공격 루트를 만들어냅니다. 수비 시에는 4-4-1-1 또는 4-5-1 형태로 전환되어 중원과 수비라인을 두텁게 만듭니다.",
+ "description2": "가장 중요한 포지션은 중앙 공격형 미드필더(‘10번’)와 두 명의 수비형 미드필더입니다. 10번은 창의적인 패스와 공격 전개, 득점까지 책임지며, 수비형 미드필더는 수비 안정성과 빌드업의 핵심입니다. 장점은 공격 다양성과 수비 안정성, 단점은 최전방 공격수의 고립, 미드필더의 활동량 부담이 있습니다."
+ }
+]
\ No newline at end of file
diff --git a/front-end/src/data/tactic.json b/front-end/src/data/tactic.json
new file mode 100644
index 0000000..626b1b2
--- /dev/null
+++ b/front-end/src/data/tactic.json
@@ -0,0 +1,50 @@
+[
+ {
+ "id": "0",
+ "title": "토탈풋볼",
+ "summation": "뭉치면 이기고 흩어지면 진다",
+ "img": "/library/totalfootball.html",
+ "description1": "토탈 풋볼은 팀 전체가 포지션에 구애받지 않고 조직적인 움직임을 통해 상대의 공간을 제한하고 공의 점유를 가져온다는, 전술보다는 철학에 가까운 개념입니다. 우리의 공격 시간을 늘릴수록 승리 확률이 올라간다는 것에 주목해 수비와 공격 각 상황에서 역할에 상관없이 모든 선수가 공격과 수비에 함께 가담합니다.",
+ "description2": "1970년대 이전, 토탈 풋볼의 개념이 등장하지 않았던 시기에도 주어진 역할에 구애받지 않고 유기적인 움직임을 통해 성공한 선수들, 전술들은 이미 존재했습니다. 그러나 본격적으로 전원 공격 + 전원 수비의 형태를 창시, 팀 전체에 이 개념을 주입시키고 발전시킨건 AFC 아약스를 이끌던 리누스 미헬스 감독, 그리고 그의 제자이자 선수 요한 크루이프 입니다. 전원 압박을 통해 공을 빼앗고 짧은 패스를 통해 점유율을 올리는 토탈 풋볼은 이를 기반으로 발전한 전술들, 그리고 이에 대항하기 위해 생겨난 전술들로 구분할 수 있을 정도로 현대 축구에 지대한 영향을 끼쳤습니다."
+ },
+ {
+ "id": "1",
+ "title": "사키이즘",
+ "summation": "공을 오래 가지는 것보다는 공간을 장악하라",
+ "img": "/library/sacchism.html",
+ "description1": "사키이즘이란 이탈리아의 아리고 사키 감독이 토탈 풋볼에서 영감을 얻어 창시한 축구 철학입니다. 공격, 미드필드, 수비로 구분되는 세 라인에 필드 플레이어가 고르게 퍼져있어야 한다 생각했고 4-4-2 포지션을 만들어 냈습니다.",
+ "description2": "공격시에는 볼 소유를 유지하고, 수비 시에는 최대한 빠르게 탈취해야 한다는 기조는 토탈 풋볼과 같지만, 토탈 풋볼은 점유 시간에 집중했다면 사키이즘은 공간에 집중했습니다. 상대의 공간을 제한하여 압박하면 더 쉽게 공을 뺏을 수 있다고 생각한 것입니다. 사키이즘은 효율적인 압박을 위해서 일정한 공간 안에서 대형과 간격을 유지하는 조직적인 움직임을 요구했습니다. 고르게 포진한 선수들이 강한 압박을 가해 볼을 탈취한 순간 바로 역습으로 전환하여 빠르게 마무리 짓는 효율적인 접근을 취했지만, 4-4-2 포메이션은 간격이 넓어 짧은 패스로 점유를 올리기에는 적합하지 않았고 공격 상황에서는 다소 아쉬운 모습을 보였습니다. 그러나 조직적인 전방 압박을 위해 공간을 좁힌다는 개념은 수비적인 전술은 물론이고 공격 위주의 전술을 추구하는 감독들에게도 많은 영향을 끼쳤으며, 지금까지도 이어지고 있습니다."
+ },
+ {
+ "id": "2",
+ "title": "게겐프레싱",
+ "summation": "공격이 최선의 수비? 수비가 최선의 공격!",
+ "img": "/library/gegenpressing.html",
+ "description1": "공 소유권을 잃은 상황에서 상대를 강하게 압박하여 소유권을 바로 되찾아 오는 전술입니다. 상대가 공을 잡은 위치에 따라서는 수비수까지 압박에 가담하는 경우도 있기 때문에 전방 압박과는 구분되는 압박 전술입니다.",
+ "description2": "게겐프레싱은 사키이즘에서 많은 영향을 받은 전술입니다. 개인 능력이 뛰어나 한명으로는 막을 수 없는 상대를 막기 위해 여러 명이서 압박을 하는 사키이즘은 라인을 끌어올려 짧은 패스로 전개하다가 공을 뺏기면 바로 다시 뺏어와 다시 짧은 패스를 뿌리는 티키타카로 발전하였고, 독일에서 이 티키타카의 압박 전술을 변형시켜 볼을 뺏는 즉시 그 자리에서 역습을 진행하여 빠르게 마무리를 짓는 방식으로 진화하였습니다. 이는 약팀이 강팀을 상대하기에 매우 효율적인 전술로 떠올랐습니다. 아무리 탈압박 능력이 뛰어나다 해도 선수 여럿이 한꺼번에 달려들면 어쩔 수 없이 소유권을 내주는 상황이 오기 때문에, 개인의 능력보다는 조직력으로 승부가 가능했기 때문입니다. 체력이 극심하게 소모되는 단점이 있지만 최근에는 상황에 맞춘 압박 강도 조절, 존 프레싱으로 변화 등 약점을 보완하며 발전하고 있습니다."
+ },
+ {
+ "id": "3",
+ "title": "티키타카",
+ "summation": "pass, pass, pass, pass, pass, ... GOAL!",
+ "img": "/library/tikitaka1.html",
+ "description1": "티키타카는 스페인어로 탁구공이 왔다갔다 한다는 뜻으로, 선수 개인이 볼 터치를 많이 가져가지 않고 바로바로 동료에게 짧은 패스를 주는 모습에서 붙여진 이름입니다. 공간, 점유율, 압박 이 세 가지가 티키타카를 정의하는 가장 중요한 키워드입니다.",
+ "description2": "토탈풋볼을 패싱 플레이로 발전시켜 모든 선수들이 공격하게 만든 전술로 선수들 간에 짧은 패스로 공격을 전개합니다. 단순히 점유율을 높이기 위해 의미없는 패스를 주고 받는 것이 아니고, 상대 수비 간격에 공간을 만들고 그 위치로 볼을 보내기 위해서 끊임없는 움직임을 요구합니다. 짧은 패스를 위해서 필연적으로 높은 수비 라인이 형성되고, 그에 따라오는 넓은 뒷 공간 때문에 역습의 위험이 존재합니다. 이 위협을 줄이기 위해서 공격 실패시 즉시 볼을 뺏어오거나 상대의 전개를 방해하기 위한 전방 압박이 필수이며, 역습 상황을 맞더라도 커버가 가능한 수비 자원의 기동력이 필수입니다. 요약하자면 상대의 압박을 이겨내고 정확한 패스가 가능한 선수 개개인의 기술과 능력, 전방 압박과 포지션 전환 및 침투를 수행할 체력과 수비 전환시 빠르게 커버가 가능한 기동력까지 요구하는, 효율적이고 강력하지만 실현시키기 매우 까다롭고 그렇기에 선수들의 능력치가 높은 강팀들이 주로 사용하는 전술입니다."
+ },
+ {
+ "id": "4",
+ "title": "두 줄 수비",
+ "summation": "노잼 축구, 안티 풋볼? 이기면 그만인데?",
+ "img": "/library/BUS.html",
+ "description1": "게겐프레싱 이전에 약팀의 전술 하면 떠오르는 전술로, 수비와 미드필더 두 라인의 전후좌우 간격을 좁게 유지시키고 상대방이 공격할 공간을 내주지 않는 매우 수비적인 전술입니다. 4-4-2 포메이션을 기반으로 두 줄을 세우기 때문에 사키이즘의 영향을 받았다고 볼 수 있습니다.",
+ "description2": "두 줄 수비는 디에고 시메오네 감독이 크게 유행시킨 전술로, 중위권을 전전하던 아틀레티코 마드리드에 부임 후 팀을 리그 우승권에 올려놓을 정도로 큰 효과를 본 전술입니다. 이후 수많은 약팀들이 강팀을 상대하기 위한 전술로 채용하고 있으며, 강팀들도 점수를 지켜야 하는 상황에 종종 사용하는 수비용 전술입니다. 선수들 간에 간격 유지가 매우 중요하며, 강한 압박을 통해 공격을 끊어내고 역습으로 빠르게 전환하여 마무리를 지을 수 있어야 합니다. 다만 공을 뺏어내도 상대가 다시 압박하여 공을 뺏긴 경우 우리 진영에서 역습을 당할 위험이 있고, 중앙에 촘촘한 대형을 유지하기 때문에 측면이 매우 취약해 질 수 있는 위험이 있습니다. 또한 마무리를 제대로 짓지 못하면 수비만 하다가 경기를 패배한다는 단점도 있습니다. 이러한 단점들을 보완하기 위해 여러 방법들이 고안되었는데 대표적으로 일정 높이 이상으로 수비 라인을 올리거나, 중앙 공격수나 미드필더의 위치를 변형해 빈 공간을 커버하는 등의 방식들이 있습니다."
+ },
+ {
+ "id": "5",
+ "title": "포지션 플레이",
+ "summation": "선수의 역할은 포지션(위치)에 따라 정해진다",
+ "img": "/library/positionplay.png",
+ "description1": "펩 과르디올라 감독이 유행시킨 전술 개념으로, 토탈 풋볼을 더 발전시키고 선수들에게 디테일한 설명과 전술적 요구를 하기 위해 고안된 개념입니다. 기존의 포지션(공격수, 수비수 등)과는 달리, 포지션 플레이에서 포지션이란 공간을 의미합니다.",
+ "description2": "축구의 전술 안에서 선수들의 위치를 변화시켜 특정 공간에서 우리 팀의 숫자를 늘려 공간을 차지하는 과정을 더 효율적으로 하려면 선수들이 감독의 지시를 완벽하게 수행해야 한다고 펩 과르디올라 감독은 생각했습니다. 어떤 상황에서든 움직임이 자유로운 1명이 더 많다면 매우 유리한 상황이 될 것이고, 이 자유로운 사람을 만들기 위한 전술을 세부적으로 설명하고 지시하기 위해서 포지션 플레이라는 개념을 만들어 냈습니다. 경기장을 24개의 공간으로 나누어 15개의 포지션을 두고 선수들에게 포지션을 옮겨가며 최적의 위치를 찾도록 하는 것입니다. 포지션 플레이에서 가장 중요한 것은 패스하고 더 좋은 위치(포지션)으로 움직이는 것입니다. 끊임없는 패스와 스위칭 플레이를 통해 상대를 혼란스럽게 하고 그 과정에서 숫자의 차이(수적 우위), 수준의 차이(질적 우위), 포지션의 차이(포지션적 우위)를 만들어 냅니다. 수적 우위란 공간 안에 상대보다 우리 선수들이 더 많은 것을 의미하고, 질적 우위는 잘하는 선수를 못하는 선수에게 배치하는 것, 그리고 포지션적 우위는 상대 팀보다 더 좋은 위치에 서 있는 것입니다. 이런 개념들은 현대 축구의 수많은 감독들에게 영향을 주었고, 최근의 전술 트렌드에서 빼놓을 수 없고 많은 팀에서 사용되고 있습니다."
+ }
+]
\ No newline at end of file
diff --git a/front-end/src/fonts/MarinesBold.woff b/front-end/src/fonts/MarinesBold.woff
new file mode 100644
index 0000000..79a9fd3
Binary files /dev/null and b/front-end/src/fonts/MarinesBold.woff differ
diff --git a/front-end/src/fonts/MarinesBold.woff2 b/front-end/src/fonts/MarinesBold.woff2
new file mode 100644
index 0000000..115335b
Binary files /dev/null and b/front-end/src/fonts/MarinesBold.woff2 differ
diff --git a/front-end/src/fonts/MarinesRegular.woff b/front-end/src/fonts/MarinesRegular.woff
new file mode 100644
index 0000000..4ca67cf
Binary files /dev/null and b/front-end/src/fonts/MarinesRegular.woff differ
diff --git a/front-end/src/fonts/MarinesRegular.woff2 b/front-end/src/fonts/MarinesRegular.woff2
new file mode 100644
index 0000000..0873c18
Binary files /dev/null and b/front-end/src/fonts/MarinesRegular.woff2 differ
diff --git a/front-end/src/hooks/api/delete/useCommentDelete.js b/front-end/src/hooks/api/delete/useCommentDelete.js
new file mode 100644
index 0000000..1bc9ce7
--- /dev/null
+++ b/front-end/src/hooks/api/delete/useCommentDelete.js
@@ -0,0 +1,27 @@
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useCommentDelete = () => {
+ const commentDelete = async (comment) => {
+ try {
+ const response = await fetch(
+ `http://52.78.12.127:8080/api/users/comments/${comment.feedId}`,
+ {
+ method: 'DELETE',
+ },
+ );
+ if (!response.ok) {
+ alert('오류 발생');
+ throw new Error(`서버 오류: ${errorMessage}`);
+ }
+ alert('댓글이 삭제 됐습니다.');
+ window.location.reload();
+ } catch (error) {
+ console.error('업데이트 실패:', error);
+ alert('댓글 수정 중 오류가 발생했습니다.');
+ }
+ };
+
+ return { commentDelete };
+};
+
+export default useCommentDelete;
diff --git a/front-end/src/hooks/api/get/useCareer.js b/front-end/src/hooks/api/get/useCareer.js
new file mode 100644
index 0000000..be347c9
--- /dev/null
+++ b/front-end/src/hooks/api/get/useCareer.js
@@ -0,0 +1,24 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useCareer = () => {
+ const { userId } = useParams();
+ const [careerList, setCareerList] = useState([]);
+
+ useEffect(() => {
+ const fetchCareerList = async () => {
+ const careerListResponse = await fetch(
+ `${baseURL}/api/users/files/career/user/${userId}`,
+ );
+ const careerListData = await careerListResponse.json();
+ setCareerList(careerListData);
+ };
+
+ fetchCareerList();
+ }, [userId]);
+
+ return { careerList };
+};
+
+export default useCareer;
diff --git a/front-end/src/hooks/api/get/useCheckUserMail.js b/front-end/src/hooks/api/get/useCheckUserMail.js
new file mode 100644
index 0000000..83db258
--- /dev/null
+++ b/front-end/src/hooks/api/get/useCheckUserMail.js
@@ -0,0 +1,33 @@
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useCheckUserMail = ({ onNext }) => {
+ const checkUserMail = async (value) => {
+ if (!value) {
+ alert('이메일을 입력하세요.');
+ return;
+ }
+ if (!value.includes('@')) {
+ alert('이메일 형식이 올바르지 않습니다.');
+ return;
+ }
+ try {
+ const response = await fetch(
+ `${baseURL}/api/users/userMail-check?userMail=${value}`,
+ );
+
+ if (response.ok) {
+ alert('이미 존재하는 이메일입니다.');
+ return;
+ } else {
+ onNext();
+ }
+ } catch (error) {
+ console.error('서버 요청 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return { checkUserMail };
+};
+
+export default useCheckUserMail;
diff --git a/front-end/src/hooks/api/get/useFetchQuarters.js b/front-end/src/hooks/api/get/useFetchQuarters.js
new file mode 100644
index 0000000..c57c3ed
--- /dev/null
+++ b/front-end/src/hooks/api/get/useFetchQuarters.js
@@ -0,0 +1,30 @@
+import { useState } from 'react';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useFetchQuarters = () => {
+ const [quarters, setQuarters] = useState([]);
+
+ const fetchQuarters = async ({ gameId }) => {
+ if (!gameId) return;
+
+ try {
+ const response = await fetch(
+ `${baseURL}/api/quarters/getQuarterList/${gameId}`,
+ );
+ const data = await response.json();
+ if (response.ok) {
+ setQuarters(data);
+ } else {
+ alert('쿼터 목록 패치 중 오류 발생');
+ console.error(data);
+ }
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ return { quarters, setQuarters, fetchQuarters };
+};
+
+export default useFetchQuarters;
diff --git a/front-end/src/hooks/api/get/usePost.js b/front-end/src/hooks/api/get/usePost.js
new file mode 100644
index 0000000..45ece5e
--- /dev/null
+++ b/front-end/src/hooks/api/get/usePost.js
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const usePost = ({ contentId }) => {
+ const [post, setPost] = useState(null);
+ const [game, setGame] = useState('');
+ const [team, setTeam] = useState(null);
+
+ useEffect(() => {
+ const handleError = (err) => {
+ console.error('오류:', err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ };
+
+ const fetchData = async () => {
+ try {
+ // 1. 게시물 데이터 가져오기
+ const postResponse = await fetch(
+ `${baseURL}/api/community/${contentId}`,
+ );
+ if (!postResponse.ok) throw new Error(await postResponse.text());
+ const postData = await postResponse.json();
+ setPost(postData);
+
+ // 2. 경기 날짜 가져오기
+ if (postData.gameId) {
+ const gameResponse = await fetch(
+ `${baseURL}/api/games/game/${postData.gameId}`,
+ );
+ if (!gameResponse.ok) throw new Error(await gameResponse.text());
+ const gameData = await gameResponse.json();
+ setGame(gameData);
+ }
+
+ // 3. 팀 정보 가져오기
+ if (postData.teamId) {
+ const teamResponse = await fetch(
+ `${baseURL}/api/teams/${postData.teamId}`,
+ );
+ if (!teamResponse.ok) throw new Error(await teamResponse.text());
+ const teamData = await teamResponse.json();
+ setTeam(teamData);
+ }
+ } catch (err) {
+ handleError(err);
+ }
+ };
+
+ fetchData();
+ }, [contentId]);
+
+ return { post, game, team };
+};
+
+export default usePost;
diff --git a/front-end/src/hooks/api/get/useUser.js b/front-end/src/hooks/api/get/useUser.js
new file mode 100644
index 0000000..934e68c
--- /dev/null
+++ b/front-end/src/hooks/api/get/useUser.js
@@ -0,0 +1,29 @@
+import { useState } from 'react';
+
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useUser = () => {
+ const [user, setUser] = useState('');
+
+ const fetchUser = async (userId) => {
+ try {
+ const response = await fetch(`${baseURL}/api/users/check/id/${userId}`);
+
+ if (response.ok) {
+ const data = await response.json();
+ setUser(data);
+ } else {
+ const errMsg = await response.text();
+ alert('오류발생');
+ console.error(errMsg);
+ }
+ } catch (err) {
+ console.error('오류:', err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ }
+ };
+
+ return { user, fetchUser };
+};
+
+export default useUser;
diff --git a/front-end/src/hooks/api/post/useCommentCreate.js b/front-end/src/hooks/api/post/useCommentCreate.js
new file mode 100644
index 0000000..2ec513f
--- /dev/null
+++ b/front-end/src/hooks/api/post/useCommentCreate.js
@@ -0,0 +1,28 @@
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useCommentCreate = () => {
+ const commentCreate = async (body) => {
+ try {
+ const res = await fetch(`${baseURL}/api/users/comments/create`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(errorText || '댓글 등록 실패');
+ }
+
+ alert('댓글이 등록되었습니다.');
+ window.location.reload();
+ } catch (err) {
+ console.error('등록 오류:', err);
+ alert('댓글 등록 중 문제가 발생했습니다.');
+ }
+ };
+
+ return { commentCreate };
+};
+
+export default useCommentCreate;
diff --git a/front-end/src/hooks/api/post/useLogin.js b/front-end/src/hooks/api/post/useLogin.js
new file mode 100644
index 0000000..2d40e3a
--- /dev/null
+++ b/front-end/src/hooks/api/post/useLogin.js
@@ -0,0 +1,40 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useLogin = () => {
+ const [isLoading, setLoading] = useState(false);
+ const navigate = useNavigate();
+
+ const login = async ({ userMail, password }) => {
+ setLoading(true);
+
+ try {
+ const response = await fetch(`${baseURL}/api/users/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userMail, password }),
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ sessionStorage.setItem('userMail', data.userMail);
+ sessionStorage.setItem('userId', data.userId);
+ navigate('/main');
+ } else {
+ const errMsg = await response.text();
+ alert('로그인 실패: 아이디 또는 비밀번호를 확인하세요.');
+ console.error(errMsg);
+ }
+ } catch (err) {
+ console.error('로그인 오류:', err);
+ alert('서버와의 통신 중 오류가 발생했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return { isLoading, login };
+};
+
+export default useLogin;
diff --git a/front-end/src/hooks/api/post/useSignUp.js b/front-end/src/hooks/api/post/useSignUp.js
new file mode 100644
index 0000000..83ff390
--- /dev/null
+++ b/front-end/src/hooks/api/post/useSignUp.js
@@ -0,0 +1,31 @@
+import { useNavigate } from 'react-router-dom';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useSignUp = () => {
+ const navigate = useNavigate();
+
+ const signUp = async ({ body }) => {
+ try {
+ const response = await fetch(`${baseURL}/api/users/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: body,
+ });
+
+ if (response.ok) {
+ alert('회원가입 완료!');
+ navigate('/');
+ } else {
+ const err = await response.text();
+ alert(err || '포지션 설정 실패');
+ }
+ } catch (error) {
+ console.error('서버 요청 중 오류:', error);
+ alert('서버 요청 중 문제가 발생했습니다.');
+ }
+ };
+
+ return { signUp };
+};
+
+export default useSignUp;
diff --git a/front-end/src/hooks/useData.js b/front-end/src/hooks/useData.js
new file mode 100644
index 0000000..5a5fca4
--- /dev/null
+++ b/front-end/src/hooks/useData.js
@@ -0,0 +1,143 @@
+import { useEffect, useState } from 'react';
+import { positionList } from '../constants/positionList';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useData = ({ quarterId }) => {
+ const [gameId, setGameId] = useState('');
+ const [quarter, setQuarter] = useState([]);
+ const [quarters, setQuarters] = useState([]);
+ const [game, setGame] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedQuarter, setSelectedQuarter] = useState('');
+ const currentQuarterIndex = Number(selectedQuarter) - 1;
+ const [currentQuarter, setCurrentQuarter] = useState(null);
+ const [team, setTeam] = useState('');
+
+ useEffect(() => {
+ const fetchQuarter = async () => {
+ try {
+ const response = await fetch(
+ `${baseURL}/api/quarters/saved-formation/${quarterId}`,
+ );
+ const data = await response.json();
+ if (response.ok) {
+ setQuarter(data);
+ } else {
+ alert('쿼터 패치 중 오류 발생');
+ console.error(data);
+ }
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchQuarter();
+ }, [quarterId]);
+
+ useEffect(() => {
+ setGameId(quarter.gameId);
+ }, [quarter]);
+
+ useEffect(() => {
+ const fetchQuarters = async () => {
+ if (!gameId) return;
+
+ try {
+ const response = await fetch(
+ `${baseURL}/api/quarters/getQuarterList/${gameId}`,
+ );
+ const data = await response.json();
+ if (response.ok) {
+ setQuarters(data);
+ } else {
+ alert('쿼터 목록 패치 중 오류 발생');
+ console.error(data);
+ }
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+ fetchQuarters();
+ }, [gameId]);
+ const getCount = () => {
+ if (!currentQuarter) return 0;
+
+ return positionList.filter(
+ (pos) =>
+ currentQuarter[pos.key] !== null &&
+ currentQuarter[pos.key] !== undefined,
+ ).length;
+ };
+
+ useEffect(() => {
+ if (!gameId) return;
+
+ const fetchGame = async () => {
+ try {
+ const res = await fetch(`${baseURL}/api/games/game/${gameId}`);
+ const data = await res.json();
+ setGame(data);
+ } catch (err) {
+ console.error('게임 데이터를 불러오는 중 오류 발생:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchGame();
+ }, [gameId]);
+
+ useEffect(() => {
+ const fetchSelectedQuarters = async () => {
+ if (quarters.length > 0) {
+ setSelectedQuarter(quarters[0].quarter);
+ setCurrentQuarter(quarters[0]);
+ }
+ };
+
+ fetchSelectedQuarters();
+ }, [quarters]);
+
+ useEffect(() => {
+ if (!currentQuarter) return;
+
+ const fetchUsers = () => {
+ setUsers(currentQuarter.playersMail);
+ };
+
+ fetchUsers();
+ }, [currentQuarter]);
+
+ useEffect(() => {
+ if (!game) return;
+ const fetchTeam = async () => {
+ try {
+ const response = await fetch(`${baseURL}/api/teams/${game.teamId}`);
+ const data = await response.json();
+ setTeam(data);
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchTeam();
+ }, [game]);
+
+ return {
+ game,
+ users,
+ setGame,
+ setUsers,
+ positionList,
+ getCount,
+ currentQuarter,
+ setCurrentQuarter,
+ team,
+ };
+};
+
+export default useData;
diff --git a/front-end/src/hooks/useGameData.js b/front-end/src/hooks/useGameData.js
new file mode 100644
index 0000000..e597537
--- /dev/null
+++ b/front-end/src/hooks/useGameData.js
@@ -0,0 +1,104 @@
+import { useEffect, useState } from 'react';
+import useFetchQuarters from './api/get/useFetchQuarters';
+import { positionList } from '../constants/positionList';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const useGameData = ({ gameId }) => {
+ const [game, setGame] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedQuarter, setSelectedQuarter] = useState('');
+ const currentQuarterIndex = Number(selectedQuarter) - 1;
+ const [currentQuarter, setCurrentQuarter] = useState(null);
+ const [team, setTeam] = useState('');
+ const { quarters, setQuarters, fetchQuarters } = useFetchQuarters({
+ gameId,
+ });
+
+ const getCount = () => {
+ if (!currentQuarter) return 0;
+
+ return positionList.filter(
+ (pos) =>
+ currentQuarter[pos.key] !== null &&
+ currentQuarter[pos.key] !== undefined,
+ ).length;
+ };
+
+ useEffect(() => {
+ if (!gameId) return;
+
+ const fetchGame = async () => {
+ try {
+ const res = await fetch(`${baseURL}/api/games/game/${gameId}`);
+ const data = await res.json();
+ setGame(data);
+ } catch (err) {
+ console.error('게임 데이터를 불러오는 중 오류 발생:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchQuarters({ gameId });
+
+ fetchGame();
+ }, [gameId]);
+
+ useEffect(() => {
+ const fetchSelectedQuarters = async () => {
+ if (quarters.length > 0) {
+ setSelectedQuarter(quarters[0].quarter);
+ setCurrentQuarter(quarters[0]);
+ }
+ };
+
+ fetchSelectedQuarters();
+ }, [quarters]);
+
+ useEffect(() => {
+ if (!currentQuarter) return;
+
+ const fetchUsers = () => {
+ setUsers(currentQuarter.playersMail);
+ };
+
+ fetchUsers();
+ }, [currentQuarter]);
+
+ useEffect(() => {
+ if (!game) return;
+ const fetchTeam = async () => {
+ try {
+ const response = await fetch(`${baseURL}/api/teams/${game.teamId}`);
+ const data = await response.json();
+ setTeam(data);
+ } catch (err) {
+ alert('서버 오류 발생');
+ console.error(err);
+ }
+ };
+
+ fetchTeam();
+ }, [game]);
+
+ return {
+ game,
+ users,
+ loading,
+ setGame,
+ setUsers,
+ positionList,
+ getCount,
+ quarters,
+ setQuarters,
+ selectedQuarter,
+ setSelectedQuarter,
+ currentQuarter,
+ setCurrentQuarter,
+ currentQuarterIndex,
+ team,
+ };
+};
+
+export default useGameData;
diff --git a/front-end/src/hooks/usePRGameData.js b/front-end/src/hooks/usePRGameData.js
new file mode 100644
index 0000000..0785b38
--- /dev/null
+++ b/front-end/src/hooks/usePRGameData.js
@@ -0,0 +1,70 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { positionList } from '../constants/positionList';
+const baseURL = import.meta.env.VITE_API_BASE_URL;
+
+const usePRGameData = () => {
+ const { prGameId } = useParams();
+ const gameId = sessionStorage.getItem('gameId');
+ const [game, setGame] = useState(null);
+ const [prGame, setPRGame] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const getCount = () => {
+ if (!game) return 0;
+
+ return positionList.filter(
+ (pos) => game[pos.key] !== null && game[pos.key] !== undefined,
+ ).length;
+ };
+
+ const getPRCount = () => {
+ if (!prGame) return 0;
+
+ return positionList.filter(
+ (pos) => prGame[pos.key] !== null && prGame[pos.key] !== undefined,
+ ).length;
+ };
+
+ useEffect(() => {
+ const fetchGame = async () => {
+ try {
+ const res = await fetch(
+ `${baseURL}/api/pr-games/findByPRGameId/${prGameId}`,
+ );
+ if (!res.ok) throw new Error('PR 게임 API 실패');
+ const data = await res.json();
+ setPRGame(data);
+
+ const response = await fetch(
+ `${baseURL}/api/quarters/saved-formation/${gameId}`,
+ );
+ const quarterData = await response.json();
+ setGame(quarterData);
+ setUsers(quarterData.playersMail || []);
+ } catch (err) {
+ console.error('게임 데이터를 불러오는 중 오류 발생:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (prGameId) fetchGame();
+ }, [prGameId, gameId]);
+
+ return {
+ game,
+ prGame,
+ users,
+ loading,
+ setGame,
+ setPRGame,
+ setUsers,
+ getCount,
+ getPRCount,
+ positionList,
+ };
+};
+
+export default usePRGameData;
diff --git a/front-end/src/img/alt_image.png b/front-end/src/img/alt_image.png
new file mode 100644
index 0000000..59ae3e0
Binary files /dev/null and b/front-end/src/img/alt_image.png differ
diff --git a/front-end/src/img/back.png b/front-end/src/img/back.png
new file mode 100644
index 0000000..8f44931
Binary files /dev/null and b/front-end/src/img/back.png differ
diff --git a/front-end/src/img/back2.png b/front-end/src/img/back2.png
new file mode 100644
index 0000000..009446e
Binary files /dev/null and b/front-end/src/img/back2.png differ
diff --git a/front-end/src/img/back3.png b/front-end/src/img/back3.png
new file mode 100644
index 0000000..7c5a66c
Binary files /dev/null and b/front-end/src/img/back3.png differ
diff --git a/front-end/src/img/back4.png b/front-end/src/img/back4.png
new file mode 100644
index 0000000..a2f00b1
Binary files /dev/null and b/front-end/src/img/back4.png differ
diff --git a/front-end/src/img/field.png b/front-end/src/img/field.png
new file mode 100644
index 0000000..a6843bf
Binary files /dev/null and b/front-end/src/img/field.png differ
diff --git a/front-end/src/img/generated_image-removebg-preview.png b/front-end/src/img/generated_image-removebg-preview.png
new file mode 100644
index 0000000..9c86d25
Binary files /dev/null and b/front-end/src/img/generated_image-removebg-preview.png differ
diff --git a/front-end/src/img/grayUniform.png b/front-end/src/img/grayUniform.png
new file mode 100644
index 0000000..cb31cd8
Binary files /dev/null and b/front-end/src/img/grayUniform.png differ
diff --git a/front-end/src/img/player.png b/front-end/src/img/player.png
new file mode 100644
index 0000000..ddf6d22
Binary files /dev/null and b/front-end/src/img/player.png differ
diff --git a/front-end/src/img/setting.png b/front-end/src/img/setting.png
new file mode 100644
index 0000000..48b086d
Binary files /dev/null and b/front-end/src/img/setting.png differ
diff --git a/front-end/src/img/uniform.png b/front-end/src/img/uniform.png
new file mode 100644
index 0000000..f020459
Binary files /dev/null and b/front-end/src/img/uniform.png differ
diff --git a/front-end/src/index.css b/front-end/src/index.css
index 08a3ac9..55b4df8 100644
--- a/front-end/src/index.css
+++ b/front-end/src/index.css
@@ -1,68 +1,86 @@
-:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
+@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
+/* 전체 앱 공통 스타일 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
+ background-color: #f9f9f9;
+ color: #222;
+ display: flex;
+ justify-content: center;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
+/* 앱처럼 좁은 뷰포트 영역 중앙 배치 */
+#root {
+ overflow-x: hidden;
+ width: 75vh;
+ max-width: 100vw;
+ min-height: 100dvh;
+ background-color: #f9f9f9;
+ box-sizing: border-box;
+ position: relative;
}
-a:hover {
- color: #535bf2;
+
+/* 코드 글꼴 */
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
}
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
+/* (선택) Marines 폰트도 사용 가능하도록 유지 */
+@font-face {
+ font-family: 'MarinesRegular';
+ src: url('./fonts/MarinesRegular.woff2') format('woff2'),
+ url('./fonts/MarinesRegular.woff') format('woff');
}
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
+@font-face {
+ font-family: 'MarinesBold';
+ src: url('./fonts/MarinesBold.woff2') format('woff2'),
+ url('./fonts/MarinesBold.woff') format('woff');
}
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
+/* 스크롤바 숨김 (모든 브라우저 호환) */
+.scrollbar-hide {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
}
-button:hover {
- border-color: #646cff;
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
+
+/* 커스텀 말풍선 디자인 */
+.custom-bubble {
+ background-color: #f9fafb; /* gray-50 */
+ border: 1px solid #d1d5db; /* gray-300 */
+ border-radius: 1.5vh;
+ padding: 2vh;
+ position: relative;
+}
+
+.custom-bubble::after {
+ content: '';
+ position: absolute;
+ right: 1.5vh;
+ bottom: -1.2vh;
+ width: 1.5vh;
+ height: 1.5vh;
+ background-color: #f9fafb; /* gray-50 */
+ border-left: 1px solid #d1d5db;
+ border-bottom: 1px solid #d1d5db;
+ transform: rotate(45deg);
+ border-bottom-left-radius: 0.3vh;
}
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
+/* 스크롤바 항상 보이게 하기 */
+html {
+ overflow-y: scroll;
}
diff --git a/front-end/src/main.jsx b/front-end/src/main.jsx
index b9a1a6d..8b6aa5c 100644
--- a/front-end/src/main.jsx
+++ b/front-end/src/main.jsx
@@ -1,10 +1,16 @@
-import { StrictMode } from 'react'
+import React from 'react'
import { createRoot } from 'react-dom/client'
-import './index.css'
+import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
+import './index.css'
+
+const rootElement = document.getElementById('root');
+const root = createRoot(rootElement);
-createRoot(document.getElementById('root')).render(
-
-
- ,
-)
+root.render(
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/front-end/src/pages/auth/LoginPage.jsx b/front-end/src/pages/auth/LoginPage.jsx
new file mode 100644
index 0000000..da87c9f
--- /dev/null
+++ b/front-end/src/pages/auth/LoginPage.jsx
@@ -0,0 +1,23 @@
+import { Link } from 'react-router-dom';
+import LoginForm from '../../components/auth/LoginForm';
+import logo from '../../assets/logo.png';
+
+const LoginPage = () => {
+ return (
+
+ {/* 로고 이미지로 변경 */}
+
+
+
+
+
+ 아직 계정이 없으신가요?
+
+ 회원가입
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/front-end/src/pages/auth/SignUpPage.jsx b/front-end/src/pages/auth/SignUpPage.jsx
new file mode 100644
index 0000000..07d69e6
--- /dev/null
+++ b/front-end/src/pages/auth/SignUpPage.jsx
@@ -0,0 +1,24 @@
+import SignUp from '../../components/auth/SignUp';
+import { useNavigate } from 'react-router-dom';
+import logo from "../../assets/logo.png";
+
+const SignUpPage = () => {
+ const navigate = useNavigate();
+
+ return (
+
+ {/* 로고 이미지로 변경 */}
+
+
+
회원가입
+
+
+
+
navigate('/')}>
+ 로그인 페이지로 이동
+
+
+ );
+};
+
+export default SignUpPage;
diff --git a/front-end/src/pages/feed/FeedDetailPage.jsx b/front-end/src/pages/feed/FeedDetailPage.jsx
new file mode 100644
index 0000000..159ae44
--- /dev/null
+++ b/front-end/src/pages/feed/FeedDetailPage.jsx
@@ -0,0 +1,225 @@
+import { useNavigate, useParams } from 'react-router-dom';
+import { useState } from 'react';
+import Feed from '../../components/feed/Feed';
+import FeedEdit from '../../components/feed/FeedEdit';
+import FeedMatch from '../../components/feed/FeedMatch';
+import useFeedDelete from '../../components/feed/FeedDelete';
+import FeedMercenary from '../../components/feed/FeedMercenary';
+import usePost from '../../hooks/api/get/usePost';
+import useUser from '../../hooks/api/get/useUser';
+
+const FeedDetailPage = () => {
+ const navigate = useNavigate();
+ const userMail = sessionStorage.getItem('userMail');
+ const userId = sessionStorage.getItem('userId');
+ const { contentId } = useParams();
+
+ const [post, setPost] = useState(null);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showMatchModal, setShowMatchModal] = useState(false);
+ const [showMercenaryModal, setShowMercenaryModal] = useState(false);
+
+ const { game, team } = usePost({ contentId });
+ const { user } = useUser({ userId });
+
+ const handleDelete = useFeedDelete(post?.contentId);
+
+ const handleUpdate = (updatedPost) => {
+ setPost(updatedPost);
+ setShowEditModal(false);
+ };
+
+ return (
+
+ {/* 상단 헤더 */}
+
+
navigate(-1)}
+ className="absolute left-0 text-[3vh] ml-[1vh]"
+ >
+
+
+
+
+
+ {post
+ ? post.category === '매칭'
+ ? '매칭'
+ : post.category === '팀원 모집'
+ ? '팀원 모집'
+ : post.category === '용병'
+ ? '용병'
+ : ''
+ : ''}
+
+
+
+ {/* 게시글 본문 */}
+
+
+ {post && (
+
+ {/* 게시글 정보 */}
+ {post.category === '매칭' || post.category === '용병' ? (
+
+
+ 🕒 매칭 날짜
+
+ {game.matchDay
+ ? game.matchDay.replace('T', ' ').slice(0, 16)
+ : null}
+
+
+
+ 팀 이름
+
+ {team?.teamName}
+
+
+
+ 지역
+
+ {team?.location}
+
+
+ {post.category === '용병' && (
+
+ 매치 이름
+
+ {game.gameName}
+
+
+ )}
+
+ ) : (
+
+
+ 팀 이름
+
+ {team?.teamName}
+
+
+
+ 지역
+
+ {team?.location}
+
+
+
+ )}
+
+ {/* 기본 정보 */}
+
+ 작성자
+ {user.userName}
+
+
+ 작성일
+
+ {post.createTime.slice(0, 10)}
+
+
+
+ 조회수
+ {post.views}
+
+
+ {/* 게시글 내용 */}
+
+ {post.content}
+
+
+ {/* 버튼 */}
+
+ {post.category === '매칭' && (
+ setShowMatchModal(true)}
+ className="flex justify-between items-center p-[2vh]"
+ >
+ 매칭 신청
+ ➔
+
+ )}
+
+ {post.category === '용병' && (
+ setShowMercenaryModal(true)}
+ className="flex justify-between items-center p-[2vh]"
+ >
+ 용병 신청
+ ➔
+
+ )}
+
+ navigate(`/team/${post.team.teamId}`)}
+ className="flex justify-between items-center p-[2vh]"
+ >
+ 팀 상세페이지
+ ➔
+
+
+ {userMail === post.userMail && (
+ <>
+ setShowEditModal(true)}
+ className="flex justify-between items-center p-[2vh]"
+ >
+ 수정
+ ➔
+
+
+ 삭제
+ ➔
+
+ >
+ )}
+
+
+ )}
+
+ {/* 수정 모달 */}
+ {post && showEditModal && (
+
setShowEditModal(false)}
+ />
+ )}
+
+ {/* 매칭 모달 */}
+ {post && showMatchModal && (
+ setShowMatchModal(false)}
+ />
+ )}
+
+ {/* 용병 모달 */}
+ {post && showMercenaryModal && (
+ setShowMercenaryModal(false)}
+ />
+ )}
+
+ );
+};
+
+export default FeedDetailPage;
diff --git a/front-end/src/pages/feed/FeedPage.jsx b/front-end/src/pages/feed/FeedPage.jsx
new file mode 100644
index 0000000..8493c4f
--- /dev/null
+++ b/front-end/src/pages/feed/FeedPage.jsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import FeedList from '../../components/feed/FeedList';
+import FeedCreate from '../../components/feed/FeedCreate';
+
+const FeedPage = () => {
+ const [category, setCategory] = useState('매칭');
+ const [showModal, setShowModal] = useState(false);
+ const userMail = sessionStorage.getItem('userMail');
+
+ return (
+
+ {/* 카테고리 탭 */}
+
+ {['매칭', '팀원 모집', '용병'].map((tab) => (
+
setCategory(tab)}
+ >
+ {tab}
+
+ ))}
+
+
+ {/* 게시글 목록 */}
+
+
+ {/* 글쓰기 버튼 */}
+
+
setShowModal(true)}
+ className="w-[6.5vh] h-[6.5vh] border-2 border-green-500 text-green-500 bg-white rounded-full cursor-pointer shadow-lg flex items-center justify-center hover:bg-green-50 active:scale-95 transition-transform"
+ >
+
+
+
+
+
+ {/* 툴팁 */}
+
+ {category === '매칭'
+ ? '매칭 모집'
+ : category === '팀원 모집'
+ ? '팀원 모집 '
+ : '용병 모집'}
+
+
+
+
+ {/* 글쓰기 모달 */}
+ {showModal && (
+
setShowModal(false)}
+ />
+ )}
+
+ );
+};
+
+export default FeedPage;
diff --git a/front-end/src/pages/game/CreateGamePage.jsx b/front-end/src/pages/game/CreateGamePage.jsx
new file mode 100644
index 0000000..13598c0
--- /dev/null
+++ b/front-end/src/pages/game/CreateGamePage.jsx
@@ -0,0 +1,94 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import CreateGame from '../../components/game/CreateGame';
+
+const CreateGamePage = () => {
+ const [versus, setVersus] = useState('');
+ const [gameName, setGameName] = useState('');
+ const [startDate, setStartDate] = useState('');
+ const [oppoLogo, setOppoLogo] = useState(null);
+ const navigate = useNavigate();
+ const teamId = sessionStorage.getItem('teamId');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ const success = await CreateGame({ versus, gameName, startDate, oppoLogo, teamId });
+ if (success) {
+ alert('경기가 성공적으로 추가되었습니다.');
+ navigate(`/team/${teamId}`);
+ }
+ } catch (err) {
+ alert(`생성 실패: ${err.message}`);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CreateGamePage;
diff --git a/front-end/src/pages/game/GameDetailPage.jsx b/front-end/src/pages/game/GameDetailPage.jsx
new file mode 100644
index 0000000..9ddbe52
--- /dev/null
+++ b/front-end/src/pages/game/GameDetailPage.jsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import GameInfo from '../../components/game/GameInfo';
+import GameUpdate from '../../components/game/GameUpdate';
+import PopUp from '../../components/game/PopUp';
+import useGameData from '../../hooks/useGameData';
+import { useParams } from 'react-router-dom';
+
+const GameDetailPage = () => {
+ const { gameId } = useParams();
+ const [update, setUpdate] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedPositionKey, setSelectedPositionKey] = useState(null);
+ const {
+ game,
+ users,
+ loading,
+ setGame,
+ setUsers,
+ positionList,
+ getCount,
+ quarters,
+ setQuarters,
+ selectedQuarter,
+ setSelectedQuarter,
+ currentQuarter,
+ setCurrentQuarter,
+ currentQuarterIndex,
+ team,
+ } = useGameData({ gameId });
+
+ const togglePopup = () => {
+ setIsOpen((prev) => {
+ const next = !prev;
+ if (!next) setSelectedPositionKey(null);
+ return next;
+ });
+ };
+
+ if (loading) return 로딩 중...
;
+
+ return (
+ <>
+ {update ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default GameDetailPage;
diff --git a/front-end/src/pages/lib/ExpertFeedPage.jsx b/front-end/src/pages/lib/ExpertFeedPage.jsx
new file mode 100644
index 0000000..347c930
--- /dev/null
+++ b/front-end/src/pages/lib/ExpertFeedPage.jsx
@@ -0,0 +1,15 @@
+import { useParams } from 'react-router-dom';
+import ExpertFeedDetail from '../../components/lib/ExpertFeedDetail';
+
+const ExpertFeedPage = () => {
+ const { feedId } = useParams();
+
+ return (
+ <>
+ 게시글
+
+ >
+ );
+};
+
+export default ExpertFeedPage;
diff --git a/front-end/src/pages/lib/LibDetailPage.jsx b/front-end/src/pages/lib/LibDetailPage.jsx
new file mode 100644
index 0000000..89762b2
--- /dev/null
+++ b/front-end/src/pages/lib/LibDetailPage.jsx
@@ -0,0 +1,156 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import formations from '../../data/formation.json';
+import tactics from '../../data/tactic.json';
+import styled from 'styled-components';
+import backImg from '../../img/back.png';
+
+const Container = styled.div`
+ min-height: 100vh;
+ background-color: #f8fafc;
+ display: flex;
+ justify-content: center;
+ padding: 5vh 2vh;
+`;
+
+const Card = styled.div`
+ width: 100%;
+ max-width: 720px;
+ background-color: #ffffff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 32px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
+ position: relative;
+`;
+
+const BackButton = styled.img`
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ opacity: 0.7;
+ transition: 0.2s;
+
+ &:hover {
+ opacity: 1;
+ transform: scale(1.05);
+ }
+`;
+
+const Title = styled.h1`
+ font-size: 24px;
+ font-weight: 700;
+ text-align: center;
+ margin-bottom: 12px;
+ color: #1f2937;
+`;
+
+const Summary = styled.p`
+ font-size: 16px;
+ font-weight: 500;
+ text-align: center;
+ color: #4b5563;
+ margin-bottom: 24px;
+`;
+
+const SectionTitle = styled.h2`
+ font-size: 18px;
+ font-weight: 600;
+ color: #111827;
+ margin-top: 32px;
+ margin-bottom: 12px;
+`;
+
+const Description = styled.p`
+ font-size: 16px;
+ color: #374151;
+ line-height: 1.7;
+`;
+
+const MediaBox = styled.div`
+ margin: 24px 0;
+ border-radius: 8px;
+ overflow: hidden;
+ background-color: #f1f5f9;
+`;
+
+const LibDetailPage = () => {
+ const { type, id } = useParams();
+ const navigate = useNavigate();
+ const data =
+ type === 'formation'
+ ? formations.find((f) => String(f.id) === id)
+ : tactics.find((t) => String(t.id) === id);
+
+ if (!data) {
+ return (
+
+
+
+ {type === 'formation'
+ ? '포메이션 정보를 찾을 수 없습니다.'
+ : '전술 정보를 찾을 수 없습니다.'}
+
+
+
+ );
+ }
+
+ const renderMedia = () => {
+ if (!data.img) {
+ return (
+
+ 📭 미디어가 없습니다.
+
+ );
+ }
+ if (data.img.endsWith('.mp4')) {
+ return (
+
+
+
+ );
+ }
+ if (data.img.match(/\.(jpg|jpeg|png|gif)$/)) {
+ return (
+
+
+
+ );
+ }
+ if (data.img.endsWith('.html')) {
+ return (
+
+
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+
+ navigate(-1)} />
+ {data.title}
+ {data.summation}
+
+ 상세 설명
+ {data.description1}
+
+ {renderMedia()}
+
+ 비고
+ {data.description2}
+
+
+ );
+};
+
+export default LibDetailPage;
diff --git a/front-end/src/pages/lib/LibPage.jsx b/front-end/src/pages/lib/LibPage.jsx
new file mode 100644
index 0000000..deb12ac
--- /dev/null
+++ b/front-end/src/pages/lib/LibPage.jsx
@@ -0,0 +1,172 @@
+import { useState, useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import formations from '../../data/formation.json';
+import tactics from '../../data/tactic.json';
+import ExpertFeedCreate from '../../components/lib/ExpertFeedCreate';
+import useUser from '../../hooks/api/get/useUser';
+import ExpertFeedList from '../../components/lib/ExpertFeedList';
+
+const TAB_LIST = [
+ { key: 'feedback', label: 'Feedback.' },
+ { key: 'lib', label: 'Lib.' },
+];
+
+const LIB_LIST = [
+ { key: 'formation', label: 'Formation Lib.' },
+ { key: 'tactics', label: 'Tactics Lib.' },
+];
+
+const LibPage = () => {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const initialTab = searchParams.get('tab') || 'feedback';
+ const initialLib = searchParams.get('lib') || 'formation';
+ const [activeTab, setActiveTab] = useState(initialTab);
+ const [activeLib, setActiveLib] = useState(initialLib);
+ const [isLib, setIsLib] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+ const userId = sessionStorage.getItem('userId');
+ const { user } = useUser({ userId });
+ const isExpert = user.userCode === 'EXPERT';
+
+ const handleTabChange = (key) => {
+ setActiveTab(key);
+ setSearchParams({ tab: key, lib: activeLib });
+ };
+
+ const handleLibChange = (key) => {
+ setActiveLib(key);
+ setSearchParams({ tab: activeTab, lib: key });
+ };
+
+ useEffect(() => {
+ const tab = searchParams.get('tab') || 'feedback';
+ setActiveTab(tab);
+ }, [searchParams]);
+
+ useEffect(() => {
+ const lib = searchParams.get('lib') || 'formation';
+ setActiveLib(lib);
+ }, [searchParams]);
+
+ useEffect(() => {
+ setIsLib(activeTab === 'lib');
+ }, [activeTab]);
+
+ return (
+
+ {/* 최상위 탭 */}
+
+ {TAB_LIST.map((tab) => (
+ handleTabChange(tab.key)}
+ className={`min-w-[6rem] text-center whitespace-nowrap text-xl font-extrabold focus:outline-none ${
+ activeTab === tab.key
+ ? 'text-green-700 border-b-4 border-green-600 pb-2'
+ : 'text-gray-400 hover:text-green-600'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* 하위 탭 (Lib 선택 시) */}
+ {isLib && (
+
+ {LIB_LIST.map((lib) => (
+ handleLibChange(lib.key)}
+ className={`min-w-[5rem] text-center whitespace-nowrap text-base font-medium focus:outline-none ${
+ activeLib === lib.key
+ ? 'text-green-600 border-b-2 border-green-500 pb-1'
+ : 'text-gray-400 hover:text-green-500'
+ }`}
+ >
+ {lib.label}
+
+ ))}
+
+ )}
+
+ {/* 탭 내용 */}
+
+ {isLib && activeLib === 'formation' && (
+ <>
+ {formations.map((form) => (
+
navigate(`/lib/detail/formation/${form.id}`)}
+ className="bg-white rounded-xl p-6 mb-6 shadow cursor-pointer hover:scale-[1.02] transition-transform"
+ >
+
{form.summation}
+
{form.title}
+
+
+ ))}
+ >
+ )}
+
+ {isLib && activeLib === 'tactics' && (
+ <>
+ {tactics.map((tactic) => (
+
navigate(`/lib/detail/tactic/${tactic.id}`)}
+ className="bg-white rounded-xl p-6 mb-6 shadow cursor-pointer hover:scale-[1.02] transition-transform"
+ >
+
{tactic.title}
+
{tactic.summation}
+
+ ))}
+ >
+ )}
+
+
+ {activeTab === 'feedback' &&
}
+
+ {/* 게시글 작성 버튼 */}
+ {!isLib && isExpert && (
+
+
setShowModal(true)}
+ className="w-[6.5vh] h-[6.5vh] border-2 border-green-500 text-green-500 bg-white rounded-full cursor-pointer shadow-lg flex items-center justify-center hover:bg-green-50 active:scale-95 transition-transform"
+ aria-label="게시글 작성"
+ >
+
+
+
+
+
+ {/* 툴팁 */}
+
+
+ )}
+
+ {/* 모달 */}
+ {showModal && !isLib &&
}
+
+ );
+};
+
+export default LibPage;
diff --git a/front-end/src/pages/main/MainPage.jsx b/front-end/src/pages/main/MainPage.jsx
new file mode 100644
index 0000000..4a0c462
--- /dev/null
+++ b/front-end/src/pages/main/MainPage.jsx
@@ -0,0 +1,54 @@
+import FormationCarousel from '../../components/main/FormationCarousel';
+import MyTeamSection from '../../components/main/MyTeamSection';
+import ScheduleSection from '../../components/main/ScheduleSection';
+import styled from 'styled-components';
+
+const PageWrapper = styled.div`
+ padding-top: 8vh;
+ padding-bottom: 2vh;
+ background-color: #ffffff;
+ min-height: 120vh;
+`;
+
+const SectionWrapper = styled.div`
+ padding: 1vh 1.7vw;
+
+ @media (max-width: 768px) {
+ padding: 1.5vh 3vw;
+ }
+
+ @media (max-width: 480px) {
+ padding: 1vh 4vw;
+ }
+`;
+
+const Divider = styled.div`
+ height: 0.7vh;
+ background-color: #f2f2f2;
+ margin: 1vh 0;
+ border-radius: 1vh;
+`;
+
+const MainPage = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MainPage;
diff --git a/front-end/src/pages/myTeam/MyTeamListPage.jsx b/front-end/src/pages/myTeam/MyTeamListPage.jsx
new file mode 100644
index 0000000..7e15a35
--- /dev/null
+++ b/front-end/src/pages/myTeam/MyTeamListPage.jsx
@@ -0,0 +1,90 @@
+import { useEffect, useState } from 'react';
+import MyTeamList from '../../components/myTeam/MyTeamList';
+import { useNavigate } from 'react-router-dom';
+
+const MyTeamListPage = () => {
+ const navigate = useNavigate();
+ const userMail = sessionStorage.getItem('userMail');
+
+ const [teams, setTeams] = useState([]);
+ const [totalGames, setTotalGames] = useState(0);
+
+ useEffect(() => {
+ const fetchTeams = async () => {
+ try {
+ const response = await fetch(`http://52.78.12.127:8080/api/teams/mail/${userMail}`);
+ if (response.ok) {
+ const data = await response.json();
+ setTeams(data);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ fetchTeams();
+ }, [userMail]);
+
+ useEffect(() => {
+ const fetchTotalGames = async () => {
+ try {
+ let total = 0;
+ for (const team of teams) {
+ const res = await fetch(`http://52.78.12.127:8080/api/games/team/${team.teamId}`);
+ if (res.ok) {
+ const data = await res.json();
+ total += data.length;
+ }
+ }
+ setTotalGames(total);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ if (teams.length > 0) {
+ fetchTotalGames();
+ }
+ }, [teams]);
+
+ return (
+
+
+
My Teams
+
내가 소속된 팀을 한눈에 볼 수 있어요
+
+
+
+ 소속 팀: {teams.length}개
+
+
+ 총 경기: {totalGames}개
+
+
+
+
+
+
+
+
navigate('/my-schedule')}
+ className="w-[6.5vh] h-[6.5vh] bg-green-500 text-white rounded-full cursor-pointer shadow-lg flex items-center justify-center hover:bg-green-400 active:scale-95 transition-transform"
+ >
+
+
+
+
+
+ {/* 툴팁 */}
+
+
+
+
+
+ );
+};
+
+export default MyTeamListPage;
diff --git a/front-end/src/pages/prgame/PRGameCreatePage.jsx b/front-end/src/pages/prgame/PRGameCreatePage.jsx
new file mode 100644
index 0000000..e28988a
--- /dev/null
+++ b/front-end/src/pages/prgame/PRGameCreatePage.jsx
@@ -0,0 +1,75 @@
+import styled from 'styled-components';
+import { useState } from 'react';
+import PopUp from '../../components/game/PopUp';
+import PRGameCreate from '../../components/prgame/PRGameCreate';
+import useGameData from '../../hooks/useGameData';
+import { useParams } from 'react-router-dom';
+import useData from '../../hooks/useData';
+
+const PRGameCreatePageContainer = styled.div`
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ padding-top: 8vh;
+`;
+
+const PRGameCreatePage = () => {
+ const { quarterId } = useParams();
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedPositionKey, setSelectedPositionKey] = useState(null);
+ const {
+ game,
+ users,
+ setGame,
+ setUsers,
+ positionList,
+ getCount,
+ currentQuarter,
+ setCurrentQuarter,
+ team,
+ } = useData({
+ quarterId,
+ });
+
+ const togglePopup = () => {
+ setIsOpen((prev) => {
+ const next = !prev;
+ if (!next) setSelectedPositionKey(null);
+ return next;
+ });
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default PRGameCreatePage;
diff --git a/front-end/src/pages/prgame/PRGameListPage.jsx b/front-end/src/pages/prgame/PRGameListPage.jsx
new file mode 100644
index 0000000..e6470da
--- /dev/null
+++ b/front-end/src/pages/prgame/PRGameListPage.jsx
@@ -0,0 +1,66 @@
+import { Link, useParams } from 'react-router-dom';
+import styled from 'styled-components';
+import PRGameList from '../../components/prgame/PRGameList';
+import { GiSoccerField } from 'react-icons/gi'; // ← 여기!
+
+const PRGamesListPageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding-top: 8vh;
+`;
+
+const PRGameListPage = () => {
+ const { quarterId } = useParams();
+
+ return (
+
+
+
+ {/* ───────── “포메이션 생성” Glass 버튼 ───────── */}
+
+ {/* 흐림 레이어 */}
+
+
+ {/* hover 그라데이션 */}
+
+
+ {/* 아이콘 + 텍스트 */}
+
+
+
+ 포메이션 생성
+
+
+
+
+ );
+};
+
+export default PRGameListPage;
diff --git a/front-end/src/pages/prgame/PRGamePage.jsx b/front-end/src/pages/prgame/PRGamePage.jsx
new file mode 100644
index 0000000..2e9793c
--- /dev/null
+++ b/front-end/src/pages/prgame/PRGamePage.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import PRGameUpdate from '../../components/prgame/PRGameUpdate';
+import PRGamePopUp from '../../components/prgame/PRGamePopUp';
+import usePRGameData from '../../hooks/usePRGameData';
+import PRGameDetail from '../../components/prgame/PRGameDetail';
+
+const PRGamePage = () => {
+ const { prGameId } = useParams();
+ const [update, setUpdate] = useState(false);
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedPositionKey, setSelectedPositionKey] = useState(null);
+ const {
+ game,
+ prGame,
+ users,
+ loading,
+ setGame,
+ setPRGame,
+ setUsers,
+ getCount,
+ getPRCount,
+ positionList,
+ } = usePRGameData();
+
+ const togglePopup = () => {
+ setIsOpen((prev) => {
+ const next = !prev;
+ if (!next) setSelectedPositionKey(null);
+ return next;
+ });
+ };
+
+ return (
+
+ {update ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default PRGamePage;
diff --git a/front-end/src/pages/profile/ProfilePage.jsx b/front-end/src/pages/profile/ProfilePage.jsx
new file mode 100644
index 0000000..cf0426e
--- /dev/null
+++ b/front-end/src/pages/profile/ProfilePage.jsx
@@ -0,0 +1,211 @@
+import { useNavigate } from 'react-router-dom';
+import { useState } from 'react';
+import Profile from '../../components/profile/Profile';
+import ProfileFeedCreate from '../../components/profile/ProfileFeedCreate';
+import UserFeedList from '../../components/profile/UserFeedList';
+import useCareer from '../../hooks/api/get/useCareer';
+import CreateCareer from '../../components/profile/CreateCareer';
+import Career from '../../components/profile/Career';
+
+const ProfilePage = () => {
+ const [myProfile, setMyProfile] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+ const [showMenu, setShowMenu] = useState(false);
+ const [showCreateCareer, setShowCreateCareer] = useState(false);
+ const navigate = useNavigate();
+ const { careerList } = useCareer();
+
+ const handleMenu = () => {
+ setShowMenu(!showMenu);
+ };
+
+ return (
+
+ {/* ── 프로필 카드 ─────────────────────────────────── */}
+
+ {/* 프로필 정보 */}
+
+
+ {/* 설정 리스트 */}
+ {myProfile && (
+
+ {showMenu && (
+ <>
+ {/* 회원정보 편집 */}
+
+ navigate('/profile/update', { state: { mode: 'update' } })
+ }
+ className="flex items-center justify-between py-5 px-2 cursor-pointer hover:bg-gray-50 transition group"
+ >
+ 회원정보 편집
+
+
+
+
+
+ {/* 비밀번호 변경 */}
+
+ navigate('/profile/update', { state: { mode: 'password' } })
+ }
+ className="flex items-center justify-between py-5 px-2 cursor-pointer hover:bg-gray-50 transition group"
+ >
+ 비밀번호 변경
+
+
+
+
+ >
+ )}
+
+ {/* 로그아웃 */}
+ {
+ sessionStorage.removeItem('userMail');
+ navigate('/');
+ }}
+ className="flex items-center justify-between py-5 px-2 cursor-pointer text-red-500 hover:bg-red-50 transition group"
+ >
+ 로그아웃
+
+
+
+
+ {showMenu ? (
+
+ ▲
+
+ ) : (
+
+ ▼
+
+ )}
+
+ )}
+
+
+ {/* 경력 카드 */}
+
+
+
경력
+
setShowCreateCareer(true)}
+ >
+ 경력 추가
+
+
+ {careerList.length != 0 ? (
+ careerList.map((career) => {
+ return
;
+ })
+ ) : (
+
경력이 없습니다.
+ )}
+
+
+ {showCreateCareer && (
+
+ )}
+ {/* 유저 피드 */}
+
+
+
+
+ {/* ── 글쓰기 FAB + 툴팁 ─────────────────────────────── */}
+ {myProfile && (
+
+
setShowModal(true)}
+ className="
+ w-[6.5vh] h-[6.5vh] rounded-full bg-white border-2 border-green-500
+ text-green-500 shadow-lg flex items-center justify-center
+ transition-transform duration-150
+ group-hover:scale-110 group-hover:shadow-xl active:scale-95
+ "
+ >
+
+
+
+
+
+ {/* 툴팁 */}
+
+
+ )}
+
+ {/* ── 글쓰기 모달 ─────────────────────────────────── */}
+ {showModal &&
}
+
+ );
+};
+
+export default ProfilePage;
diff --git a/front-end/src/pages/profile/ProfileUpdatePage.jsx b/front-end/src/pages/profile/ProfileUpdatePage.jsx
new file mode 100644
index 0000000..1c21886
--- /dev/null
+++ b/front-end/src/pages/profile/ProfileUpdatePage.jsx
@@ -0,0 +1,27 @@
+import { useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import CheckPassword from '../../components/profile/CheckPassword';
+import ProfileUpdate from '../../components/profile/ProfileUpdate';
+import ChangePassword from '../../components/profile/ChangePassword';
+
+const ProfileUpdatePage = () => {
+ const location = useLocation();
+ const initialMode = location.state?.mode || 'update';
+ const [step, setStep] = useState('check');
+
+ if (step === 'check') {
+ return setStep(initialMode)} />;
+ }
+
+ if (step === 'update') {
+ return ;
+ }
+
+ if (step === 'password') {
+ return ;
+ }
+
+ return null;
+};
+
+export default ProfileUpdatePage;
diff --git a/front-end/src/pages/profile/UserFeedPage.jsx b/front-end/src/pages/profile/UserFeedPage.jsx
new file mode 100644
index 0000000..2ab795d
--- /dev/null
+++ b/front-end/src/pages/profile/UserFeedPage.jsx
@@ -0,0 +1,15 @@
+import { useParams } from 'react-router-dom';
+import UserFeedDetail from '../../components/profile/UserFeedDetail';
+
+const UserFeedPage = () => {
+ const { feedId } = useParams();
+
+ return (
+ <>
+ 게시글
+
+ >
+ );
+};
+
+export default UserFeedPage;
diff --git a/front-end/src/pages/schedule/CalenderPage.jsx b/front-end/src/pages/schedule/CalenderPage.jsx
new file mode 100644
index 0000000..ce0b0a2
--- /dev/null
+++ b/front-end/src/pages/schedule/CalenderPage.jsx
@@ -0,0 +1,26 @@
+import styled from 'styled-components';
+import Calender from '../../components/schedule/Calender';
+
+const Container = styled.div`
+ padding: 8vh 2vh 3vh;
+ background-color: #fafafa;
+`;
+
+const Title = styled.h2`
+ font-size: 2.4vh;
+ font-weight: 600;
+ margin-bottom: 2vh;
+ border-bottom: 2px solid #ddd;
+ display: inline-block;
+`;
+
+const CalenderPage = () => {
+ return (
+
+ 전체 일정
+
+
+ );
+};
+
+export default CalenderPage;
diff --git a/front-end/src/pages/schedule/MyScheduleListPage.jsx b/front-end/src/pages/schedule/MyScheduleListPage.jsx
new file mode 100644
index 0000000..0c5e336
--- /dev/null
+++ b/front-end/src/pages/schedule/MyScheduleListPage.jsx
@@ -0,0 +1,34 @@
+import { useState } from 'react';
+import MyScheduleList from '../../components/schedule/MyScheduleList';
+
+const MyScheduleListPage = () => {
+ const [filter, setFilter] = useState('전체');
+
+ return (
+
+
경기 일정
+
+ {/* 카테고리 탭 (업그레이드) */}
+
+ {['전체', '예정', '진행중', '완료'].map((cat) => (
+ setFilter(cat)}
+ className={`px-[2vh] py-[1vh] rounded-full text-[1.6vh] font-semibold transition border
+ ${
+ filter === cat
+ ? 'bg-green-500 text-white border-green-500 shadow'
+ : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-100'
+ }`}
+ >
+ {cat}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default MyScheduleListPage;
diff --git a/front-end/src/pages/teamfeed/TeamFeedDetailPage.jsx b/front-end/src/pages/teamfeed/TeamFeedDetailPage.jsx
new file mode 100644
index 0000000..a4aeef0
--- /dev/null
+++ b/front-end/src/pages/teamfeed/TeamFeedDetailPage.jsx
@@ -0,0 +1,78 @@
+import { useParams, useNavigate } from "react-router-dom";
+import styled from "styled-components";
+import TeamFeedDetailInfo from "../../components/teamfeed/TeamFeedDetailInfo";
+
+// 전체 페이지 래퍼
+const Container = styled.div`
+ margin-top: 9vh;
+ background-color: #fafafa;
+ min-height: 100vh;
+`;
+
+// 상단 헤더
+const HeaderBar = styled.header`
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ background-color: #e8f5e9; // 연초록
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 16px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+ border-bottom: 1px solid #d0e0d0;
+`;
+
+// 왼쪽 ← 버튼
+const BackButton = styled.button`
+ position: absolute;
+ left: 16px;
+ background: none;
+ border: none;
+ font-size: 1.2rem;
+ color: #388e3c;
+ cursor: pointer;
+ font-weight: 600;
+`;
+
+// 중앙 제목
+const Title = styled.h1`
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0;
+`;
+
+const Section = styled.section`
+ padding: rem 0.5rem;
+`;
+
+const Separator = styled.div`
+ height: 8px;
+ background-color: #f5f5f5;
+ margin: 2rem 0;
+ border-radius: 1px;
+`;
+
+const TeamFeedDetailPage = () => {
+ const { teamFeedId } = useParams();
+ const navigate = useNavigate();
+
+ return (
+
+
+ navigate(-1)}>←
+ 게시글 보기
+
+
+
+
+
+
+ );
+};
+
+export default TeamFeedDetailPage;
diff --git a/front-end/src/pages/teamfeed/TeamFeedListPage.jsx b/front-end/src/pages/teamfeed/TeamFeedListPage.jsx
new file mode 100644
index 0000000..9c33198
--- /dev/null
+++ b/front-end/src/pages/teamfeed/TeamFeedListPage.jsx
@@ -0,0 +1,68 @@
+import { useParams } from "react-router-dom";
+import styled from "styled-components";
+import TeamInfo from "../../components/teamfeed/TeamInfo";
+import TeamFeedDetailList from "../../components/teamfeed/TeamFeedDetailList";
+
+const PageWrapper = styled.div`
+ margin-top: 9vh;
+ padding: 0 2vh;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const ContentWrapper = styled.div`
+ width: 100%;
+ max-width: 960px;
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+`;
+
+const Title = styled.h2`
+ font-size: 2rem;
+ font-weight: 700;
+ color: #222;
+ margin-bottom: 0.5rem;
+`;
+
+const Divider = styled.hr`
+ width: 100%;
+ border: none;
+ height: 2px;
+ background-color: #e0e0e0;
+ margin-bottom: 1rem;
+`;
+
+const CardSection = styled.section`
+ background-color: #fff;
+ padding: 2rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
+`;
+
+const TeamFeedListPage = () => {
+ const { teamId } = useParams();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default TeamFeedListPage;
diff --git a/front-end/src/pages/teams/TeamDetailPage.jsx b/front-end/src/pages/teams/TeamDetailPage.jsx
new file mode 100644
index 0000000..a2aa72f
--- /dev/null
+++ b/front-end/src/pages/teams/TeamDetailPage.jsx
@@ -0,0 +1,22 @@
+import { useParams } from 'react-router-dom';
+import styled from 'styled-components';
+import TeamInfo from '../../components/teams/TeamInfo';
+
+const PageWrapper = styled.div`
+ padding: 8vh 2vh 2vh;
+ background-color: #f9f9f9;
+ min-height: 100vh;
+`;
+
+const TeamDetailPage = () => {
+ const { teamId } = useParams();
+ sessionStorage.setItem('teamId', teamId);
+
+ return (
+
+
+
+ );
+};
+
+export default TeamDetailPage;
diff --git a/front-end/src/pages/teams/TeamListPage.jsx b/front-end/src/pages/teams/TeamListPage.jsx
new file mode 100644
index 0000000..8dd5434
--- /dev/null
+++ b/front-end/src/pages/teams/TeamListPage.jsx
@@ -0,0 +1,65 @@
+import { useState, useEffect } from 'react';
+import TeamCreateModal from '../../components/teams/TeamCreateModal';
+import TeamSearch from '../../components/teams/TeamSearch';
+import TeamList from '../../components/teams/TeamList';
+
+const TeamListPage = () => {
+ const [showModal, setShowModal] = useState(false);
+ const [refreshFlag, setRefreshFlag] = useState(false);
+ const [searchKeyword, setSearchKeyword] = useState('');
+
+ const handleRefresh = () => setRefreshFlag((prev) => !prev);
+
+ const handleSearch = (keyword) => {
+ setSearchKeyword(keyword);
+ handleRefresh();
+ };
+
+ return (
+
+
+
+
+
Team List
+
모든 팀을 조회할 수 있어요
+
+
+
+
+
+
+
+
+
+
+ {/* 팀 생성 버튼 */}
+
+
+
setShowModal(true)}
+ className="w-[6.5vh] h-[6.5vh] border-2 border-green-500 text-green-500 bg-white rounded-full cursor-pointer shadow-lg flex items-center justify-center hover:bg-green-50 active:scale-95 transition-transform"
+ >
+
+
+
+
+
+ {/* 툴팁 */}
+
+
+
+
+ {showModal && (
+
setShowModal(false)}
+ onSuccess={handleRefresh}
+ />
+ )}
+
+ );
+};
+
+export default TeamListPage;
diff --git a/front-end/src/pages/teams/TeamUpdatePage.jsx b/front-end/src/pages/teams/TeamUpdatePage.jsx
new file mode 100644
index 0000000..2a92ef8
--- /dev/null
+++ b/front-end/src/pages/teams/TeamUpdatePage.jsx
@@ -0,0 +1,25 @@
+import TeamUpdate from '../../components/teams/TeamUpdate';
+import TeamMemberList from '../../components/teams/TeamMemberList';
+import TeamSaveDelete from '../../components/teams/TeamSaveDelete';
+import { useState } from 'react';
+
+const TeamUpdatePage = () => {
+ const [team, setTeam] = useState(null);
+ const [logoFile, setLogoFile] = useState(null);
+ const teamId = sessionStorage.getItem('teamId');
+
+ return (
+
+ {/* 팀 정보 수정 */}
+
+
+ {/* 팀원 관리 */}
+
+
+ {/* 저장 / 삭제 버튼 */}
+
+
+ );
+};
+
+export default TeamUpdatePage;
diff --git a/front-end/tailwind.config.js b/front-end/tailwind.config.js
new file mode 100644
index 0000000..5c0e85f
--- /dev/null
+++ b/front-end/tailwind.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
+ theme: {
+ extend: {
+ spacing: {
+ 'app-width': '75vh',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/front-end/vite.config.js b/front-end/vite.config.js
index 8b0f57b..ed07e96 100644
--- a/front-end/vite.config.js
+++ b/front-end/vite.config.js
@@ -1,7 +1,14 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
-// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
-})
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://52.78.12.127:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+});