diff --git a/app/(protected)/history/page.jsx b/app/(protected)/history/page.jsx deleted file mode 100644 index facb897..0000000 --- a/app/(protected)/history/page.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; -import HistoryList from "./HistoryList"; - -const HistoryPage = () => { - return ( - - - 比賽紀錄 - - - - ); -}; - -export default HistoryPage; diff --git a/app/(protected)/notifications/page.jsx b/app/(protected)/notifications/page.jsx new file mode 100644 index 0000000..7325982 --- /dev/null +++ b/app/(protected)/notifications/page.jsx @@ -0,0 +1,9 @@ +const NotificationsPage = () => { + return ( +
+

Notifications

+
+ ); +}; + +export default NotificationsPage; diff --git a/app/(protected)/team/TeamInfo.jsx b/app/(protected)/team/TeamInfo.jsx deleted file mode 100644 index 96f8d94..0000000 --- a/app/(protected)/team/TeamInfo.jsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useUser, useTeam, useUserTeams } from "@/hooks/use-data"; -import { FiUsers, FiCheck, FiX, FiEdit2 } from "react-icons/fi"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardHeader, - CardTitle, - CardBtnGroup, -} from "@/components/ui/card"; -import TeamInfoTable from "@/app/(protected)/team/info/TeamInfoTable"; - -const TeamInfo = ({ teamId, className }) => { - const router = useRouter(); - const { user, mutate: mutateUser } = useUser(); - const { mutate: mutateUserTeams } = useUserTeams(); - const { team, isLoading } = useTeam(teamId); - const isInviting = user?.teams.inviting.find((team) => team === teamId); - // TODO: 更新 admin 資料結構 - const isAdmin = team?.admins - ? team?.admins.find((admin) => admin.user_id === user?._id) - : false; - - const handleAccept = async (teamId, accept) => { - if (!window.confirm(accept ? "確認接受邀請?" : "確認拒絕邀請?")) return; - const action = accept ? "accept" : "reject"; - try { - const response = await fetch( - `/api/users/teams?action=${action}&teamId=${teamId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - } - ); - const userTeams = await response.json(); - mutateUserTeams(); - mutateUser({ ...user, teams: userTeams }); - - return router.push(accept ? "/team" : "/user/invitations"); - } catch (error) { - console.log(error); - } - }; - - return ( - - - - - {isLoading ? "Loading..." : team?.name} - - - {isAdmin && ( - - )} - - - {isLoading ? <>Loading... : } - {isInviting && ( - <> -

是否接受此隊伍邀請?

- - - - )} -
- ); -}; - -export default TeamInfo; diff --git a/app/(protected)/team/[teamId]/edit/page.jsx b/app/(protected)/team/[teamId]/edit/page.jsx new file mode 100644 index 0000000..28d5289 --- /dev/null +++ b/app/(protected)/team/[teamId]/edit/page.jsx @@ -0,0 +1,31 @@ +"use client"; +import { useTeam } from "@/hooks/use-data"; +import { useRouter } from "next/navigation"; +import TeamForm from "@/components/team/form"; + +const EditTeamPage = ({ params }) => { + const router = useRouter(); + const { teamId } = params; + const { team, mutate } = useTeam(teamId); + + const onSubmit = async (formData) => { + try { + const res = await fetch(`/api/teams/${teamId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const teamData = await res.json(); + mutate({ ...team, ...teamData }); + return router.push(`/team/${teamId}?tab=about`); + } catch (error) { + console.error(error); + // TODO: 改為彈出式警告 + } + }; + + return ; +}; + +export default EditTeamPage; diff --git a/app/(protected)/team/[teamId]/lineup/page.jsx b/app/(protected)/team/[teamId]/lineup/page.jsx new file mode 100644 index 0000000..28cb7d5 --- /dev/null +++ b/app/(protected)/team/[teamId]/lineup/page.jsx @@ -0,0 +1,33 @@ +"use client"; +import { useTeam, useTeamMembers } from "@/hooks/use-data"; +import Lineup from "@/components/team/lineup"; + +const LineupPage = ({ params }) => { + const { teamId } = params; + const { team, mutate } = useTeam(teamId); + const { members } = useTeamMembers(teamId); + + const handleSave = async (lineups) => { + try { + const response = await fetch(`/api/teams/${team._id}/lineup`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(lineups), + }); + const data = await response.json(); + mutate({ ...team, lineup: data }, false); + } catch (error) { + console.log(error); + } + // if (isRecording) { + // dispatch(matchActions.configMatchSet({ firstServe, lineup })); + // router.push(`/match/${matchId}/confirm`); + // } + }; + + return ; +}; + +export default LineupPage; diff --git a/app/(protected)/team/[teamId]/matches/page.jsx b/app/(protected)/team/[teamId]/matches/page.jsx new file mode 100644 index 0000000..a7442a6 --- /dev/null +++ b/app/(protected)/team/[teamId]/matches/page.jsx @@ -0,0 +1,9 @@ +import TeamMatches from "@/components/team/matches"; + +const TeamMatchesPage = ({ params }) => { + const { teamId } = params; + + return ; +}; + +export default TeamMatchesPage; diff --git a/app/(protected)/team/[teamId]/members/[memberId]/edit/page.jsx b/app/(protected)/team/[teamId]/members/[memberId]/edit/page.jsx new file mode 100644 index 0000000..c08f755 --- /dev/null +++ b/app/(protected)/team/[teamId]/members/[memberId]/edit/page.jsx @@ -0,0 +1,35 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useTeamMembers } from "@/hooks/use-data"; +import MemberForm from "@/components/team/members/form"; + +const EditMemberPage = ({ params }) => { + const router = useRouter(); + const { teamId, memberId } = params; + const { members, mutate } = useTeamMembers(teamId); + const member = members?.find((member) => member._id === memberId) || {}; + + const onSubmit = async (formData) => { + formData.team_id = teamId; + try { + const res = await fetch(`/api/members/${memberId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const member = await res.json(); + const memberIndex = members.findIndex( + (member) => member._id === memberId + ); + members[memberIndex] = member; + mutate([...members], false); + return router.push(`/team/${teamId}/members/${memberId}`); + } catch (error) { + console.error(error); + } + }; + + return ; +}; + +export default EditMemberPage; diff --git a/app/(protected)/team/member/[memberId]/error.jsx b/app/(protected)/team/[teamId]/members/[memberId]/error.jsx similarity index 100% rename from app/(protected)/team/member/[memberId]/error.jsx rename to app/(protected)/team/[teamId]/members/[memberId]/error.jsx diff --git a/app/(protected)/team/[teamId]/members/[memberId]/page.jsx b/app/(protected)/team/[teamId]/members/[memberId]/page.jsx new file mode 100644 index 0000000..0b9f7fb --- /dev/null +++ b/app/(protected)/team/[teamId]/members/[memberId]/page.jsx @@ -0,0 +1,9 @@ +import MembersInfo from "@/components/team/members/info"; + +const MemberPage = ({ params }) => { + const { teamId, memberId } = params; + + return ; +}; + +export default MemberPage; diff --git a/app/(protected)/team/[teamId]/members/new/page.jsx b/app/(protected)/team/[teamId]/members/new/page.jsx new file mode 100644 index 0000000..e02d365 --- /dev/null +++ b/app/(protected)/team/[teamId]/members/new/page.jsx @@ -0,0 +1,30 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useTeamMembers } from "@/hooks/use-data"; +import MemberForm from "@/components/team/members/form"; + +const MemberCreatePage = ({ params }) => { + const router = useRouter(); + const { teamId } = params; + const { members, mutate } = useTeamMembers(teamId); + + const onSubmit = async (formData) => { + formData.team_id = teamId; + try { + const res = await fetch("/api/members", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const member = await res.json(); + mutate([...members, member], false); + return router.push(`/team/${teamId}/members/${member._id}`); + } catch (error) { + console.error(error); + } + }; + + return ; +}; + +export default MemberCreatePage; diff --git a/app/(protected)/team/[teamId]/page.jsx b/app/(protected)/team/[teamId]/page.jsx new file mode 100644 index 0000000..ca02ade --- /dev/null +++ b/app/(protected)/team/[teamId]/page.jsx @@ -0,0 +1,34 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import TeamHero from "@/components/team/hero"; +import ConfirmInvitation from "@/components/team/confirmation"; +import LatestMatch from "@/components/team/feeds/latest-match"; +import TeamInfo from "@/components/team/info"; +import TeamMembers from "@/components/team/members"; + +const TeamPage = ({ params, searchParams }) => { + const { teamId } = params; + const defaultTab = searchParams?.tab || "feeds"; + + return ( + + + + + 動態 + 關於 + 成員 + + + + + + + + + + + + ); +}; + +export default TeamPage; diff --git a/app/(protected)/team/info/[id]/edit/page.jsx b/app/(protected)/team/info/[id]/edit/page.jsx deleted file mode 100644 index 9559cb4..0000000 --- a/app/(protected)/team/info/[id]/edit/page.jsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "@/app/(protected)/team/team-slice"; -import { Card } from "@/components/ui/card"; -import TeamForm from "../../../TeamForm"; - -const EditTeamPage = ({ params }) => { - const router = useRouter(); - const dispatch = useDispatch(); - const { id: teamId } = params; - const teamData = useSelector((state) => state.team); - - const onSubmit = async (formData) => { - try { - const res = await fetch(`/api/teams/${teamId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - - const { teamData } = await res.json(); - dispatch(teamActions.updateTeamOnly(teamData)); - return router.push(`/team/info/${teamId}`); - } catch (error) { - console.error(error); - // TODO: 改為彈出式警告 - // return form.setError("name", { message: "發生未知錯誤,請稍後再試" }); - } - }; - - return ( - - - - ); -}; - -export default EditTeamPage; diff --git a/app/(protected)/team/info/[id]/page.jsx b/app/(protected)/team/info/[id]/page.jsx deleted file mode 100644 index f31df6d..0000000 --- a/app/(protected)/team/info/[id]/page.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import TeamInfo from "../../TeamInfo"; - -const TeamInfoPage = async ({ params }) => { - const { id: teamId } = params; - return ; -}; - -export default TeamInfoPage; diff --git a/app/(protected)/team/info/page.jsx b/app/(protected)/team/info/page.jsx deleted file mode 100644 index 6c5a24e..0000000 --- a/app/(protected)/team/info/page.jsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; -import { useSelector } from "react-redux"; -import { Card } from "@/components/ui/card"; -import TeamInfo from "../TeamInfo"; - -const TeamInfoPage = () => { - const teamData = useSelector((state) => state.team); - const membersData = useSelector((state) => state.team.members); - return ( - - - - ); -}; - -export default TeamInfoPage; diff --git a/app/(protected)/team/lineup/(options)/BenchList.jsx b/app/(protected)/team/lineup/(options)/BenchList.jsx deleted file mode 100644 index d25d361..0000000 --- a/app/(protected)/team/lineup/(options)/BenchList.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "../../team-slice"; -import { FiUserCheck, FiUser, FiChevronLeft } from "react-icons/fi"; -import { CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { ListItem, ListItemText } from "@/app/components/common/List"; -import { Button } from "@/components/ui/button"; - -const BenchList = () => { - const dispatch = useDispatch(); - const { members, editingLineup } = useSelector((state) => state.team); - const liberoCount = editingLineup.liberos.filter( - (player) => player._id - ).length; - const substituteCount = editingLineup.substitutes.length; - const substituteLimit = liberoCount < 2 ? 6 - liberoCount : 6; - const isSubstituteFull = substituteCount >= substituteLimit; - const isEditingStarting = editingLineup.status.optionMode === "substitutes"; - - const handleSubstituteClick = (member, index) => { - if (isEditingStarting) { - dispatch( - teamActions.replaceEditingPlayer({ - _id: member._id, - list: "substitutes", - zone: index, - }) - ); - } else { - dispatch(teamActions.removeSubstitutePlayer(index)); - } - }; - - const handleOtherClick = (member, index) => { - if (isEditingStarting) { - dispatch( - teamActions.replaceEditingPlayer({ - _id: member._id, - list: "others", - zone: index, - }) - ); - } else if (!isSubstituteFull) { - dispatch(teamActions.addSubstitutePlayer(index)); - } - }; - - return ( - <> - - - {`替補名單 (${substituteCount}/${substituteLimit})`} - - {editingLineup.substitutes.map((player, index) => { - const member = members.find((m) => m._id === player._id); - return ( - handleSubstituteClick(member, index)} - > - - - {member.number || " "} - - {member.name} - - ); - })} - -

以上為正式比賽 12 + 2 人名單

- {editingLineup.others.map((player, index) => { - const member = members.find((m) => m._id === player._id); - return ( - handleOtherClick(member, index)}> - - - {member.number || " "} - - {member.name} - - ); - })} - - ); -}; - -export default BenchList; diff --git a/app/(protected)/team/lineup/(options)/LineupConfig.jsx b/app/(protected)/team/lineup/(options)/LineupConfig.jsx deleted file mode 100644 index 8e5ee37..0000000 --- a/app/(protected)/team/lineup/(options)/LineupConfig.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; -import { FiEdit2 } from "react-icons/fi"; -import { teamActions } from "../../team-slice"; -import { Button } from "@/components/ui/button"; -import { CardHeader, CardTitle, CardBtnGroup } from "@/components/ui/card"; -import { ListItem, ListItemText } from "@/app/components/common/List"; - -const LineupConfig = () => { - const dispatch = useDispatch(); - const { starting, liberos, substitutes, others } = useSelector( - (state) => state.team.editingLineup - ); - const startingCount = starting.filter((player) => player._id).length; - const liberoCount = liberos.filter((player) => player._id).length; - - return ( - <> - - 陣容資訊 - - - - -
- - 先發: - {startingCount} 人 - - - 自由: - {liberoCount} 人 - -
-
- - 替補: - {substitutes.length} 人 - - - 其他: - {others.length} 人 - -
- - ); -}; - -export default LineupConfig; diff --git a/app/(protected)/team/lineup/Lineup.jsx b/app/(protected)/team/lineup/Lineup.jsx deleted file mode 100644 index 7138831..0000000 --- a/app/(protected)/team/lineup/Lineup.jsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; -import { useState } from "react"; -import { useRouter, usePathname } from "next/navigation"; -import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "../team-slice"; -import { matchActions } from "@/app/match/match-slice"; -import { FiSave } from "react-icons/fi"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import LineupCourt from "./LineupCourt"; -import LineupOptions from "./LineupOptions"; - -const Lineup = () => { - const router = useRouter(); - const dispatch = useDispatch(); - const pathname = usePathname(); - const isRecording = pathname.includes("match"); - const matchId = useSelector((state) => state.match._id) || "new"; - const { setNum } = useSelector((state) => state.match.status.editingData); - const setData = useSelector((state) => state.match.sets[setNum]); - const [firstServe, setFirstServe] = useState( - setData.meta.firstServe === null ? true : setData.meta.firstServe - ); - const { _id: teamId, editingLineup } = useSelector((state) => state.team); - const { starting, liberos, substitutes, others, status } = editingLineup; - - const handleSave = async () => { - const lineup = { - starting, - liberos, - substitutes, - others, - }; - if (status.edited) { - try { - const response = await fetch(`/api/teams/${teamId}/lineup`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(lineup), - }); - const { teamData } = await response.json(); - dispatch(teamActions.updateTeamOnly(teamData)); - } catch (error) { - console.log(error); - } - } - if (isRecording) { - dispatch(matchActions.configMatchSet({ firstServe, lineup })); - router.push(`/match/${matchId}/confirm`); - } - }; - - const handleCancel = () => { - dispatch(teamActions.resetEditingLineup()); - if (!isRecording) router.push("/team"); - }; - - return ( - <> - - - - - {!status.optionMode && ( - - - {/* {isRecording ? ( - <> - - - 恢復預設 - - - - 確認陣容 - - - ) : ( - <> - - - 取消編輯 - - - - 儲存陣容 - - - )} */} - - )} - - ); -}; - -export default Lineup; diff --git a/app/(protected)/team/lineup/LineupCourt.jsx b/app/(protected)/team/lineup/LineupCourt.jsx deleted file mode 100644 index f452a1d..0000000 --- a/app/(protected)/team/lineup/LineupCourt.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "../team-slice"; -import { FiRefreshCw } from "react-icons/fi"; -import { - Court, - Outside, - Inside, - PlayerCard, - AdjustButton, -} from "@/components/custom/Court"; - -const LineupCourt = () => { - const dispatch = useDispatch(); - const { editingLineup, members } = useSelector((state) => state.team); - const { starting, liberos, status } = editingLineup; - - return ( - - - {status.optionMode === "" ? ( - dispatch(teamActions.rotateLineupCw())}> - - 輪轉 - - ) : ( - - )} - {liberos.map((libero, index) => { - const member = members.find((m) => m._id === libero._id); - return ( - - dispatch( - teamActions.setEditingPlayer({ - _id: member?._id || null, - list: "liberos", - zone: index + 1, - }) - ) - } - onCrossClick={() => dispatch(teamActions.removeEditingPlayer())} - editingMember={status.editingMember} - /> - ); - })} - - - {starting.map((starting, index) => { - const member = members.find((m) => m._id === starting._id); - return ( - - dispatch( - teamActions.setEditingPlayer({ - _id: member?._id || null, - list: "starting", - zone: index + 1, - }) - ) - } - onSwitchClick={() => - dispatch(teamActions.setOptionMode("substitutes")) - } - onCrossClick={() => dispatch(teamActions.removeEditingPlayer())} - editingMember={status.editingMember} - /> - ); - })} - - - ); -}; - -export default LineupCourt; diff --git a/app/(protected)/team/lineup/LineupOptions.jsx b/app/(protected)/team/lineup/LineupOptions.jsx deleted file mode 100644 index e139a94..0000000 --- a/app/(protected)/team/lineup/LineupOptions.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useSelector } from "react-redux"; -import LineupConfig from "./(options)/LineupConfig"; -import MemberInfo from "../member/MemberInfo"; -import BenchList from "./(options)/BenchList"; -import PositionList from "./(options)/PositionList"; - -const LineupOptions = () => { - const { members, editingLineup } = useSelector((state) => state.team); - const { optionMode, editingMember } = editingLineup.status; - const member = members.find((m) => m._id === editingMember._id); - - return ( - <> - {optionMode === "playerInfo" ? ( - - ) : optionMode === "substitutes" || optionMode === "others" ? ( - - ) : optionMode === "positions" ? ( - - ) : ( - - )} - - ); -}; - -export default LineupOptions; diff --git a/app/(protected)/team/lineup/page.jsx b/app/(protected)/team/lineup/page.jsx deleted file mode 100644 index e1e3e79..0000000 --- a/app/(protected)/team/lineup/page.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import Lineup from "./Lineup"; - -const LineupPage = () => { - return ; -}; - -export default LineupPage; diff --git a/app/(protected)/team/member/MemberInfo.jsx b/app/(protected)/team/member/MemberInfo.jsx deleted file mode 100644 index 80fac5b..0000000 --- a/app/(protected)/team/member/MemberInfo.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FiHash, FiUser, FiMail, FiShield } from "react-icons/fi"; -import { ListItem, ListItemText } from "@/app/components/common/List"; -import { Separator } from "@/components/ui/separator"; - -const MemberInfo = ({ member }) => { - return ( - <> -
- - - 背號:{member.number || " "} - - - - 位置:{member?.position || " "} - -
- - - 姓名:{member.name} - - - - - 信箱:{member.meta.email} - - - - - 權限:{member.meta.admin ? "管理者" : "一般成員"} - - - - ); -}; - -export default MemberInfo; diff --git a/app/(protected)/team/member/[memberId]/edit/page.jsx b/app/(protected)/team/member/[memberId]/edit/page.jsx deleted file mode 100644 index bcbe19d..0000000 --- a/app/(protected)/team/member/[memberId]/edit/page.jsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useDispatch, useSelector } from "react-redux"; -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; -import MemberForm from "../../MemberForm"; -import { teamActions } from "../../../team-slice"; - -const EditMemberPage = ({ params }) => { - const router = useRouter(); - const dispatch = useDispatch(); - const { memberId } = params; - const user = useSelector((state) => state.user); - const { _id: teamId } = useSelector((state) => state.team); - const member = useSelector((state) => - state.team.members.find((member) => member._id === memberId) - ); - - const onSubmit = async (formData) => { - formData.team_id = teamId; - try { - const res = await fetch(`/api/members/${memberId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const { teamData, membersData } = await res.json(); - dispatch(teamActions.setTeam({ userData: user, teamData, membersData })); - return router.push(`/team/member/${memberId}`); - } catch (error) { - console.error(error); - } - }; - - return ( - - - 編輯隊員資訊 - - - - ); -}; - -export default EditMemberPage; diff --git a/app/(protected)/team/member/[memberId]/page.jsx b/app/(protected)/team/member/[memberId]/page.jsx deleted file mode 100644 index 7223206..0000000 --- a/app/(protected)/team/member/[memberId]/page.jsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; -import { useSelector } from "react-redux"; -import { FiEdit2 } from "react-icons/fi"; -import { Link } from "@/components/ui/button"; -import { - Card, - CardHeader, - CardTitle, - CardBtnGroup, -} from "@/components/ui/card"; -import MemberInfo from "../MemberInfo"; - -const MemberPage = ({ params }) => { - const { memberId } = params; - const { members } = useSelector((state) => state.team); - const member = members.find((member) => member._id === memberId); - - return ( - - - 隊員詳細資料 - - - 編輯 - - - - - - ); -}; - -export default MemberPage; diff --git a/app/(protected)/team/member/new/page.jsx b/app/(protected)/team/member/new/page.jsx deleted file mode 100644 index a96c559..0000000 --- a/app/(protected)/team/member/new/page.jsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "@/app/(protected)/team/team-slice"; -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; -import MemberForm from "../MemberForm"; - -const MemberCreatePage = () => { - const router = useRouter(); - const dispatch = useDispatch(); - const user = useSelector((state) => state.user); - const { _id: teamId } = useSelector((state) => state.team); - - const onSubmit = async (formData) => { - formData.team_id = teamId; - try { - const res = await fetch("/api/members", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const { teamData, membersData, member } = await res.json(); - - dispatch(teamActions.setTeam({ userData: user, teamData, membersData })); - router.push(`/team/member/${member._id}`); - } catch (error) { - console.error(error); - } - }; - - return ( - - - 新增隊員 - - - - ); -}; - -export default MemberCreatePage; diff --git a/app/(protected)/team/new/page.jsx b/app/(protected)/team/new/page.jsx index b7ae570..97814de 100644 --- a/app/(protected)/team/new/page.jsx +++ b/app/(protected)/team/new/page.jsx @@ -1,14 +1,11 @@ "use client"; +import { useSWRConfig } from "swr"; import { useRouter } from "next/navigation"; -import { useDispatch } from "react-redux"; -import { userActions } from "@/app/(protected)/user/user-slice"; -import { teamActions } from "@/app/(protected)/team/team-slice"; -import { Card } from "@/components/ui/card"; -import TeamForm from "../TeamForm"; +import TeamForm from "@/components/team/form"; const NewTeamPage = () => { const router = useRouter(); - const dispatch = useDispatch(); + const { mutate } = useSWRConfig(); const onSubmit = async (formData) => { try { @@ -18,10 +15,9 @@ const NewTeamPage = () => { body: JSON.stringify(formData), }); - const { userData, teamData, membersData } = await res.json(); - dispatch(userActions.setUser(userData)); - dispatch(teamActions.setTeam({ userData, teamData, membersData })); - return router.push(`/team`); + const team = await res.json(); + mutate(`/api/teams/${team._id}`, team); + return router.push(`/team/${team._id}?tab=about`); } catch (err) { console.log(err); // TODO: 改為彈出式警告 @@ -29,11 +25,7 @@ const NewTeamPage = () => { } }; - return ( - - - - ); + return ; }; export default NewTeamPage; diff --git a/app/(protected)/team/page.jsx b/app/(protected)/team/page.jsx deleted file mode 100644 index 171c710..0000000 --- a/app/(protected)/team/page.jsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useSelector } from "react-redux"; -import { BsGrid3X2Gap } from "react-icons/bs"; -import { FiChevronRight } from "react-icons/fi"; -import { Link } from "@/components/ui/button"; -import { - Card, - CardHeader, - CardTitle, - CardBtnGroup, -} from "@/components/ui/card"; -import TeamMembers from "./TeamMembers"; - -const TeamPage = () => { - const router = useRouter(); - const { name: teamName } = useSelector((state) => state.team); - - return ( - - - router.push("/team/info")}> - {teamName} - - - - - - - - - - - ); -}; - -export default TeamPage; diff --git a/app/(protected)/team/team-slice.js b/app/(protected)/team/team-slice.js deleted file mode 100644 index 55c05ff..0000000 --- a/app/(protected)/team/team-slice.js +++ /dev/null @@ -1,255 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -const initialState = { - admin: false, - _id: "", - name: "", - nickname: "", - members: [], - lineup: { - starting: [], - liberos: [], - substitutes: [], - others: [], - }, - editingLineup: { - status: { - stage: "starting", - edited: false, - optionMode: "", - editingMember: { - _id: null, - list: "", - zone: null, - }, - }, - starting: [], - liberos: [], - substitutes: [], - others: [], - }, - matches: [], - stats: {}, -}; - -const teamSlice = createSlice({ - name: "team", - initialState, - reducers: { - setTeam: (_, action) => { - // TODO: 修改 /api/teams 的 response,不要返回 userData - const { userData, teamData, membersData } = action.payload; - if (!teamData) return; - - const userId = userData._id; - const admin = membersData.find((member) => member.meta.user_id === userId) - .meta.admin; - membersData.sort((a, b) => a.number - b.number); - - return { - admin, - _id: teamData._id, - name: teamData.name, - nickname: teamData.nickname, - members: membersData, - lineup: teamData.lineup, - editingLineup: { - ...teamData.lineup, - status: initialState.editingLineup.status, - }, - matches: teamData.matches, - stats: teamData.stats, - }; - }, - updateTeamOnly: (state, action) => { - state.name = action.payload.name; - state.nickname = action.payload.nickname; - state.lineup = action.payload.lineup; - state.editingLineup = { - ...action.payload.lineup, - status: initialState.editingLineup.status, - }; - state.matches = action.payload.matches; - state.stats = action.payload.stats; - }, - resetTeam: () => { - return { - ...initialState, - }; - }, - rotateLineupCw: (state) => { - state.editingLineup.status.edited = true; - const newStarting = state.editingLineup.starting.slice(1); - newStarting.push(state.editingLineup.starting[0]); - state.editingLineup.starting = newStarting; - }, - rotateLineupCcw: (state) => { - state.editingLineup.status.edited = true; - const newStarting = state.editingLineup.starting.slice(0, -1); - newStarting.unshift(state.editingLineup.starting[5]); - state.editingLineup.starting = newStarting; - }, - setCompositionEditingStage: (state, action) => { - const stage = action.payload; - state.editingLineup.status.stage = stage; - }, - selectCompositionPlayer: (state, action) => { - const { stage } = state.editingLineup.status; - const { player, index, origin } = action.payload; - state.editingLineup.status.edited = true; - if (stage === origin) { - state.editingLineup[stage].splice(index, 1); - state.editingLineup.others.push(player); - } else { - state.editingLineup[stage].push(player); - state.editingLineup[origin].splice(index, 1); - } - }, - setOptionMode: (state, action) => { - const mode = action.payload; - state.editingLineup.status.optionMode = mode; - if (!mode) { - state.editingLineup.status.editingMember = - initialState.editingLineup.status.editingMember; - } - }, - setEditingPlayer: (state, action) => { - const { _id, list, zone } = action.payload; - if ( - list === state.editingLineup.status.editingMember.list && - zone === state.editingLineup.status.editingMember.zone - ) { - state.editingLineup.status.editingMember = - initialState.editingLineup.status.editingMember; - state.editingLineup.status.optionMode = ""; - } else { - state.editingLineup.status.editingMember = { - _id, - list, - zone, - }; - state.editingLineup.status.optionMode = _id - ? "playerInfo" - : "substitutes"; - } - }, - removeEditingPlayer: (state) => { - const { list, zone } = state.editingLineup.status.editingMember; - state.editingLineup.status.edited = true; - state.editingLineup.others.push(state.editingLineup[list][zone - 1]); - state.editingLineup[list][zone - 1] = { _id: null }; - state.editingLineup.status.editingMember = - initialState.editingLineup.status.editingMember; - state.editingLineup.status.optionMode = ""; - }, - replaceEditingPlayer: (state, action) => { - const { _id, list, zone } = action.payload; - const editingMember = state.editingLineup.status.editingMember; - const replacingPlayer = state.editingLineup[list][zone]; - state.editingLineup.status.edited = true; - state.editingLineup[list].splice(zone, 1); - if (editingMember._id) { - state.editingLineup[list].push( - state.editingLineup[editingMember.list][editingMember.zone - 1] - ); - } - state.editingLineup[editingMember.list][editingMember.zone - 1] = - replacingPlayer; - state.editingLineup.status.editingMember._id = _id; - state.editingLineup.status.optionMode = "playerInfo"; - }, - addSubstitutePlayer: (state, action) => { - const index = action.payload; - state.editingLineup.status.edited = true; - state.editingLineup.substitutes.push(state.editingLineup.others[index]); - state.editingLineup.others.splice(index, 1); - }, - removeSubstitutePlayer: (state, action) => { - const index = action.payload; - state.editingLineup.status.edited = true; - state.editingLineup.others.push(state.editingLineup.substitutes[index]); - state.editingLineup.substitutes.splice(index, 1); - }, - setPlayerPosition: (state, action) => { - const { editingMember } = state.editingLineup.status; - const position = action.payload; - state.editingLineup.status.edited = true; - if (position === "") { - if (editingMember.zone <= 6) { - state.editingLineup.substitutes.push({ - _id: state.editingLineup.starting[editingMember.zone - 1]._id, - }); - state.editingLineup.starting[editingMember.zone - 1] = { - _id: null, - }; - } else { - state.editingLineup.substitutes.push({ - _id: state.editingLineup.liberos[editingMember.zone - 7]._id, - }); - state.editingLineup.liberos[editingMember.zone - 7] = { - _id: null, - }; - } - return; - } - if (editingMember.zone === 0) { - if (editingMember.position === "substitutes") { - state.editingLineup.others.push(editingMember._id); - state.editingLineup.substitutes = - state.editingLineup.substitutes.filter( - (id) => id !== editingMember._id - ); - } else if (editingMember.position === "others") { - state.editingLineup.substitutes.push(editingMember._id); - state.editingLineup.others = state.editingLineup.others.filter( - (id) => id !== editingMember._id - ); - } - } else { - if (editingMember.zone <= 6) { - if (state.editingLineup.starting[editingMember.zone - 1]._id) { - state.editingLineup.substitutes.push({ - _id: state.editingLineup.starting[editingMember.zone - 1]._id, - }); - } - state.editingLineup.starting[editingMember.zone - 1] = { - _id: editingMember._id, - }; - } else { - if (state.editingLineup.liberos[editingMember.zone - 7]?._id) { - state.editingLineup.substitutes.push({ - _id: state.editingLineup.liberos[editingMember.zone - 7]._id, - }); - } - state.editingLineup.liberos[editingMember.zone - 7] = { - _id: editingMember._id, - }; - } - state.editingLineup.substitutes = - state.editingLineup.substitutes.filter( - (id) => id !== editingMember._id - ); - state.editingLineup.others = state.editingLineup.others.filter( - (id) => id !== editingMember._id - ); - } - state.editingLineup.status = { - ...initialState.editingLineup.status, - edited: true, - }; - }, - resetEditingLineup: (state) => { - state.editingLineup = { - status: initialState.editingLineup.status, - starting: state.lineup.starting, - liberos: state.lineup.liberos, - substitutes: state.lineup.substitutes, - others: state.lineup.others, - }; - }, - }, -}); - -export const teamActions = teamSlice.actions; - -export default teamSlice.reducer; diff --git a/app/(protected)/user/invitations/page.jsx b/app/(protected)/user/invitations/page.jsx index db5f52e..b8d16f9 100644 --- a/app/(protected)/user/invitations/page.jsx +++ b/app/(protected)/user/invitations/page.jsx @@ -1,4 +1,4 @@ -import Invitations from "./Invitations"; +import Invitations from "@/components/user/invitations"; const InvitationsPage = () => { return ; diff --git a/app/(protected)/user/page.jsx b/app/(protected)/user/page.jsx index 207ddb4..0cd0233 100644 --- a/app/(protected)/user/page.jsx +++ b/app/(protected)/user/page.jsx @@ -1,14 +1,14 @@ import { signOut } from "@/auth"; import { FiLogOut } from "react-icons/fi"; import { Button } from "@/components/ui/button"; -import Menu from "./Menu"; +import Menu from "@/components/user/menu"; const UserPage = () => { return ( <>
{ "use server"; await signOut(); diff --git a/app/api/members/[memberId]/route.js b/app/api/members/[memberId]/route.js index 8aaaf78..df0d588 100644 --- a/app/api/members/[memberId]/route.js +++ b/app/api/members/[memberId]/route.js @@ -1,12 +1,24 @@ import { NextResponse } from "next/server"; -import verifyJwt from "../../utils/verify-jwt"; +import { auth } from "@/auth"; +import connectToMongoDB from "@/lib/connect-to-mongodb"; import User from "@/app/models/user"; import Team from "@/app/models/team"; import Member from "@/app/models/member"; export const PUT = async (req, { params }) => { try { - const { userData, token } = await verifyJwt(req); + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await connectToMongoDB(); + const user = await User.findById(session.user._id); + if (!user) { + console.error("[PUT /api/users/teams] User not found"); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const formData = await req.json(); const { memberId } = params; @@ -24,7 +36,7 @@ export const PUT = async (req, { params }) => { // only admins can edit members const userIsMember = members.find( - (member) => member.meta.user_id.toString() === userData._id.toString() + (member) => member.meta?.user_id?.toString() === user._id.toString() ); if (!userIsMember) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -103,23 +115,7 @@ export const PUT = async (req, { params }) => { console.log(updatingMember); await updatingMember.save(); - const targetIndex = members.findIndex( - (member) => member._id.toString() === memberId - ); - members[targetIndex] = updatingMember; - const response = NextResponse.json( - { teamData: team, membersData: members, member: updatingMember }, - { status: 200 } - ); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; + return NextResponse.json(updatingMember, { status: 200 }); } catch (error) { console.log("[put-teams]", error); return NextResponse.json({ error }, { status: 500 }); diff --git a/app/api/members/route.js b/app/api/members/route.js index f48f855..3e2a145 100644 --- a/app/api/members/route.js +++ b/app/api/members/route.js @@ -1,12 +1,25 @@ import { NextResponse } from "next/server"; -import verifyJwt from "../utils/verify-jwt"; +import { auth } from "@/auth"; +import connectToMongoDB from "@/lib/connect-to-mongodb"; import User from "@/app/models/user"; import Team from "@/app/models/team"; import Member from "@/app/models/member"; export const POST = async (req) => { try { - const { userData, token } = await verifyJwt(req); + const session = await auth(); + if (!session) { + console.error("[POST /api/members] Unauthorized"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await connectToMongoDB(); + const user = await User.findById(session.user._id); + if (!user) { + console.error("[POST /api/members] User not found"); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const formData = await req.json(); // find the team @@ -19,7 +32,7 @@ export const POST = async (req) => { // any member can create members const userIsMember = members.find( - (member) => member.meta.user_id.toString() === userData._id.toString() + (member) => member.meta?.user_id?.toString() === user._id.toString() ); if (!userIsMember) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -77,20 +90,7 @@ export const POST = async (req) => { await team.save(); await newMember.save(); - members.push(newMember); - const response = NextResponse.json( - { teamData: team, membersData: members, member: newMember }, - { status: 201 } - ); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; + return NextResponse.json(newMember, { status: 201 }); } catch (error) { console.log("[post-teams]", error); return NextResponse.json({ error }, { status: 500 }); diff --git a/app/api/teams/[id]/lineup/route.js b/app/api/teams/[id]/lineup/route.js deleted file mode 100644 index b52fe5d..0000000 --- a/app/api/teams/[id]/lineup/route.js +++ /dev/null @@ -1,49 +0,0 @@ -import { NextResponse } from "next/server"; -import verifyJwt from "@/app/api/utils/verify-jwt"; -import Team from "@/app/models/team"; -import Member from "@/app/models/member"; - -export const PATCH = async (req, { params }) => { - try { - const teamId = params.id; - const { userData, token } = await verifyJwt(req); - const team = await Team.findById(teamId); - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - const members = await Member.find({ team_id: teamId }); - const isAdmin = members.find( - (member) => member.meta.user_id.toString() === userData._id.toString() - )?.meta.admin; - if (!isAdmin) { - return NextResponse.json( - { error: "You are not authorized to update this team" }, - { status: 401 } - ); - } - - const lineup = await req.json(); - team.lineup = lineup; - - await team.save(); - - const response = NextResponse.json( - { - teamData: team, - }, - { status: 200 } - ); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; - } catch (error) { - console.log("[patch-team-lineup]", error); - return NextResponse.json({ error }, { status: 500 }); - } -}; diff --git a/app/api/teams/[id]/route.js b/app/api/teams/[id]/route.js deleted file mode 100644 index d44b124..0000000 --- a/app/api/teams/[id]/route.js +++ /dev/null @@ -1,113 +0,0 @@ -import { NextResponse } from "next/server"; -import verifyJwt from "../../utils/verify-jwt"; -import signJwt from "../../utils/sign-jwt"; -import hidePassword from "../../utils/hide-password"; -import User from "@/app/models/user"; -import Team from "@/app/models/team"; -import Member from "@/app/models/member"; - -export const GET = async (req, { params }) => { - try { - const { id: teamId } = params; - const query = req.nextUrl.searchParams; - - const teamData = await Team.findById(teamId); - if (!teamData) { - console.log("[get-teams] Team not found"); - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - const membersData = await Member.find({ team_id: teamId }); - - if (query.get("switch") === "true") { - const { userData } = await verifyJwt(); - const matchedTeamId = userData.teams.joined.find( - (id) => id.toString() === teamId - ); - if (!matchedTeamId) { - console.log("[get-teams] User is not a member of this team"); - return NextResponse.json( - { error: "You are not a member of this team" }, - { status: 403 } - ); - } - - const user = await User.findById(userData._id); - const teamIndex = user.teams.joined.findIndex( - (id) => id.toString() === teamId - ); - user.teams.joined.unshift(user.teams.joined.splice(teamIndex, 1)[0]); - await user.save(); - - const token = await signJwt(user); - const hidePasswordUser = hidePassword(user); - - const response = NextResponse.json({ - userData: hidePasswordUser, - teamData, - membersData, - }); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; - } - - return NextResponse.json({ teamData, membersData }, { status: 200 }); - } catch (error) { - console.log("[get-teams]", error); - return NextResponse.json({ error }, { status: 500 }); - } -}; - -export const PATCH = async (req, { params }) => { - try { - const { userData, token } = await verifyJwt(req); - - const { id: teamId } = params; - const { name, nickname } = await req.json(); - const team = await Team.findById(teamId); - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - const members = await Member.find({ team_id: teamId }); - const isAdmin = members.find( - (member) => member.meta.user_id.toString() === userData._id.toString() - )?.meta.admin; - if (!isAdmin) { - return NextResponse.json( - { error: "You are not authorized to update this team" }, - { status: 401 } - ); - } - - if (name) team.name = name; - if (nickname) team.nickname = nickname; - - await team.save(); - - const response = NextResponse.json( - { - teamData: team, - }, - { status: 200 } - ); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; - } catch (error) { - console.log("[update-team]", error); - return NextResponse.json({ error }, { status: 500 }); - } -}; diff --git a/app/api/teams/[teamId]/lineup/route.js b/app/api/teams/[teamId]/lineup/route.js new file mode 100644 index 0000000..2bfb4da --- /dev/null +++ b/app/api/teams/[teamId]/lineup/route.js @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import connectToMongoDB from "@/lib/connect-to-mongodb"; +import User from "@/app/models/user"; +import Team from "@/app/models/team"; +import Member from "@/app/models/member"; + +export const PATCH = async (req, { params }) => { + try { + const session = await auth(); + if (!session) { + console.error("[PATCH /api/teams/[teamId]/lineup] Unauthorized"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await connectToMongoDB(); + const user = await User.findById(session.user._id); + if (!user) { + console.error("[PATCH /api/teams/[teamId]/lineup] User not found"); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { teamId } = params; + + const team = await Team.findById(teamId); + if (!team) { + console.error("[PATCH /api/teams/[teamId]/lineup] Team not found"); + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + const members = await Member.find({ team_id: teamId }); + const isAdmin = members.find( + (member) => member.meta?.user_id?.toString() === user._id.toString() + )?.meta.admin; + if (!isAdmin) { + return NextResponse.json( + { error: "You are not authorized to update this team" }, + { status: 401 } + ); + } + + const lineup = await req.json(); + team.lineup = lineup; + + await team.save(); + + return NextResponse.json(lineup, { status: 200 }); + } catch (error) { + console.error("[PATCH /api/teams/[teamId]/lineup] Error:", error); + return NextResponse.json({ error }, { status: 500 }); + } +}; diff --git a/app/api/teams/[id]/members/route.js b/app/api/teams/[teamId]/members/route.js similarity index 93% rename from app/api/teams/[id]/members/route.js rename to app/api/teams/[teamId]/members/route.js index 9287b2c..1d7abf8 100644 --- a/app/api/teams/[id]/members/route.js +++ b/app/api/teams/[teamId]/members/route.js @@ -4,7 +4,7 @@ import Member from "@/app/models/member"; export const GET = async (req, { params }) => { try { - const { id: teamId } = params; + const { teamId } = params; await connectMongoDB(); const members = await Member.find({ team_id: teamId }); return NextResponse.json(members, { status: 200 }); diff --git a/app/api/teams/[teamId]/route.js b/app/api/teams/[teamId]/route.js new file mode 100644 index 0000000..0f77e2b --- /dev/null +++ b/app/api/teams/[teamId]/route.js @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import connectToMongoDB from "@/lib/connect-to-mongodb"; +import User from "@/app/models/user"; +import Team from "@/app/models/team"; +import Member from "@/app/models/member"; + +export const GET = async (req, { params }) => { + try { + const { teamId } = params; + await connectToMongoDB(); + + const team = await Team.findById(teamId); + if (!team) { + console.log("[get-teams] Team not found"); + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + return NextResponse.json(team, { status: 200 }); + } catch (error) { + console.log("[get-teams]", error); + return NextResponse.json({ error }, { status: 500 }); + } +}; + +export const PATCH = async (req, { params }) => { + try { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await connectToMongoDB(); + const user = await User.findById(session.user._id); + if (!user) { + console.error("[PATCH /api/users/teams] User not found"); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id: teamId } = params; + const { name, nickname } = await req.json(); + const team = await Team.findById(teamId); + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + const members = await Member.find({ team_id: teamId }); + const isAdmin = members.find( + (member) => member.meta.user_id.toString() === user._id.toString() + )?.meta.admin; + if (!isAdmin) { + return NextResponse.json( + { error: "You are not authorized to update this team" }, + { status: 401 } + ); + } + + if (name) team.name = name; + if (nickname) team.nickname = nickname; + + await team.save(); + + return NextResponse.json(team, { status: 200 }); + } catch (error) { + console.log("[update-team]", error); + return NextResponse.json({ error }, { status: 500 }); + } +}; diff --git a/app/api/teams/route.js b/app/api/teams/route.js index ddc85e8..6fce75f 100644 --- a/app/api/teams/route.js +++ b/app/api/teams/route.js @@ -1,21 +1,31 @@ import { NextResponse } from "next/server"; -import verifyJwt from "../utils/verify-jwt"; -import signJwt from "../utils/sign-jwt"; -import hidePassword from "../utils/hide-password"; +import { auth } from "@/auth"; +import connectToMongoDB from "@/lib/connect-to-mongodb"; import User from "@/app/models/user"; import Team from "@/app/models/team"; import Member from "@/app/models/member"; export const POST = async (req) => { try { - const { userData } = await verifyJwt(req); + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await connectToMongoDB(); + const user = await User.findById(session.user._id); + if (!user) { + console.error("[POST /api/users/teams] User not found"); + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const newMember = new Member({ meta: { admin: true, - email: userData.email, - user_id: userData._id, + email: user.email, + user_id: user._id, }, - name: userData.name, + name: user.name, number: 1, }); @@ -35,29 +45,14 @@ export const POST = async (req) => { }, }); - const updatedUser = await User.findById(userData._id); newMember.team_id = newTeam._id; - updatedUser.teams.joined.push(newTeam._id); + user.teams.joined.unshift(newTeam._id); await newMember.save(); await newTeam.save(); - await updatedUser.save(); + await user.save(); - const token = await signJwt(updatedUser); - const user = hidePassword(updatedUser); - const response = NextResponse.json( - { userData: user, teamData: newTeam, membersData: [newMember] }, - { status: 201 } - ); - response.cookies.set({ - name: "token", - value: token, - options: { - httpOnly: true, - maxAge: 60 * 60 * 24 * 30, - }, - }); - return response; + return NextResponse.json(newTeam, { status: 201 }); } catch (error) { console.log("[create-team]", error); return NextResponse.json({ error }, { status: 500 }); diff --git a/app/match/[id]/confirm/Confirm.jsx b/app/match/[id]/confirm/Confirm.jsx index 2081522..e3eab8a 100644 --- a/app/match/[id]/confirm/Confirm.jsx +++ b/app/match/[id]/confirm/Confirm.jsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "@/app/(protected)/team/team-slice"; +import { lineupsActions } from "@/app/store/lineups-slice"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import ConfirmCourt from "./ConfirmCourt"; @@ -22,7 +22,7 @@ const Confirm = () => { body: JSON.stringify(matchData), }); const { teamData, matchId } = await response.json(); - dispatch(teamActions.updateTeamOnly(teamData)); + dispatch(lineupsActions.updateTeamOnly(teamData)); router.push(`/match/${matchId}`); } catch (error) { console.log(error); diff --git a/app/match/[id]/confirm/ConfirmCourt.jsx b/app/match/[id]/confirm/ConfirmCourt.jsx index 1f6ad20..0e31044 100644 --- a/app/match/[id]/confirm/ConfirmCourt.jsx +++ b/app/match/[id]/confirm/ConfirmCourt.jsx @@ -28,7 +28,7 @@ const ConfirmCourt = () => { zone={index + 1} onCardClick={() => dispatch( - teamActions.setRecordingPlayer({ + lineupsActions.setRecordingPlayer({ _id: member?._id || null, list: "liberos", zone: index + 1, @@ -51,7 +51,7 @@ const ConfirmCourt = () => { zone={index + 1} onCardClick={() => dispatch( - teamActions.setRecordingPlayer({ + lineupsActions.setRecordingPlayer({ _id: member?._id || null, list: "starting", zone: index + 1, @@ -59,9 +59,9 @@ const ConfirmCourt = () => { ) } onSwitchClick={() => - dispatch(teamActions.setOptionMode("substitutes")) + dispatch(lineupsActions.setOptionMode("substitutes")) } - onCrossClick={() => dispatch(teamActions.removeEditingPlayer())} + onCrossClick={() => dispatch(lineupsActions.removeEditingPlayer())} editingMember={recording} /> ); diff --git a/app/match/[id]/lineup/page.jsx b/app/match/[id]/lineup/page.jsx index fd90c3a..0799555 100644 --- a/app/match/[id]/lineup/page.jsx +++ b/app/match/[id]/lineup/page.jsx @@ -1,5 +1,5 @@ "use client"; -import Lineup from "@/app/(protected)/team/lineup/Lineup"; +import Lineup from "@/components/team/lineup"; const LineupPage = () => { return ; diff --git a/app/store/lineups-slice.js b/app/store/lineups-slice.js new file mode 100644 index 0000000..5863d42 --- /dev/null +++ b/app/store/lineups-slice.js @@ -0,0 +1,178 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const initialState = { + status: { + edited: false, + lineupNum: 0, + optionMode: "", + editingMember: { + _id: null, + list: "", + zone: null, + }, + }, + lineups: [], +}; + +const lineupsSlice = createSlice({ + name: "lineups", + initialState, + reducers: { + initiate: (state, action) => { + const lineups = action.payload; + return { + ...state, + status: initialState.status, + lineups: [lineups], + }; + }, + rotateLineup: (state) => { + const { lineupNum } = state.status; + state.status.edited = true; + const newStarting = state.lineups[lineupNum].starting.slice(1); + newStarting.push(state.lineups[lineupNum].starting[0]); + state.lineups[lineupNum].starting = newStarting; + }, + setOptionMode: (state, action) => { + const mode = action.payload; + state.status.optionMode = mode; + if (!mode) { + state.status.editingMember = initialState.status.editingMember; + } + }, + setEditingPlayer: (state, action) => { + const { _id, list, zone } = action.payload; + if ( + list === state.status.editingMember.list && + zone === state.status.editingMember.zone + ) { + state.status.editingMember = initialState.status.editingMember; + state.status.optionMode = ""; + } else { + state.status.editingMember = { + _id, + list, + zone, + }; + state.status.optionMode = _id ? "playerInfo" : "substitutes"; + } + }, + removeEditingPlayer: (state) => { + const { lineupNum } = state.status; + const { list, zone } = state.status.editingMember; + state.status.edited = true; + state.lineups[lineupNum].others.push( + state.lineups[lineupNum][list][zone - 1] + ); + state.lineups[lineupNum][list][zone - 1] = { _id: null }; + state.status.editingMember = initialState.status.editingMember; + state.status.optionMode = ""; + }, + replaceEditingPlayer: (state, action) => { + const { lineupNum } = state.status; + const { _id, list, zone } = action.payload; + const editingMember = state.status.editingMember; + const replacingPlayer = state.lineups[lineupNum][list][zone]; + state.status.edited = true; + state.lineups[lineupNum][list].splice(zone, 1); + if (editingMember._id) { + state.lineups[lineupNum][list].push( + state.lineups[lineupNum][editingMember.list][editingMember.zone - 1] + ); + } + state.lineups[lineupNum][editingMember.list][editingMember.zone - 1] = + replacingPlayer; + state.status.editingMember._id = _id; + state.status.optionMode = "playerInfo"; + }, + addSubstitutePlayer: (state, action) => { + const { lineupNum } = state.status; + const index = action.payload; + state.status.edited = true; + state.lineups[lineupNum].substitutes.push( + state.lineups[lineupNum].others[index] + ); + state.lineups[lineupNum].others.splice(index, 1); + }, + removeSubstitutePlayer: (state, action) => { + const { lineupNum } = state.status; + const index = action.payload; + state.status.edited = true; + state.lineups[lineupNum].others.push( + state.lineups[lineupNum].substitutes[index] + ); + state.lineups[lineupNum].substitutes.splice(index, 1); + }, + setPlayerPosition: (state, action) => { + const { lineupNum, editingMember } = state.status; + const position = action.payload; + state.status.edited = true; + if (position === "") { + if (editingMember.zone <= 6) { + state.lineups[lineupNum].substitutes.push({ + _id: state.lineups[lineupNum].starting[editingMember.zone - 1]._id, + }); + state.lineups[lineupNum].starting[editingMember.zone - 1] = { + _id: null, + }; + } else { + state.lineups[lineupNum].substitutes.push({ + _id: state.lineups[lineupNum].liberos[editingMember.zone - 7]._id, + }); + state.lineups[lineupNum].liberos[editingMember.zone - 7] = { + _id: null, + }; + } + return; + } + if (editingMember.zone === 0) { + if (editingMember.position === "substitutes") { + state.lineups[lineupNum].others.push(editingMember._id); + state.lineups[lineupNum].substitutes = state.lineups[ + lineupNum + ].substitutes.filter((id) => id !== editingMember._id); + } else if (editingMember.position === "others") { + state.lineups[lineupNum].substitutes.push(editingMember._id); + state.lineups[lineupNum].others = state.lineups[ + lineupNum + ].others.filter((id) => id !== editingMember._id); + } + } else { + if (editingMember.zone <= 6) { + if (state.lineups[lineupNum].starting[editingMember.zone - 1]._id) { + state.lineups[lineupNum].substitutes.push({ + _id: state.lineups[lineupNum].starting[editingMember.zone - 1] + ._id, + }); + } + state.lineups[lineupNum].starting[editingMember.zone - 1] = { + _id: editingMember._id, + }; + } else { + if (state.lineups[lineupNum].liberos[editingMember.zone - 7]?._id) { + state.lineups[lineupNum].substitutes.push({ + _id: state.lineups[lineupNum].liberos[editingMember.zone - 7]._id, + }); + } + state.lineups[lineupNum].liberos[editingMember.zone - 7] = { + _id: editingMember._id, + }; + } + state.lineups[lineupNum].substitutes = state.lineups[ + lineupNum + ].substitutes.filter((id) => id !== editingMember._id); + state.lineups[lineupNum].others = state.lineups[ + lineupNum + ].others.filter((id) => id !== editingMember._id); + } + state.status = { + ...initialState.status, + edited: true, + }; + }, + }, +}); + +export const lineupsActions = lineupsSlice.actions; + +export default lineupsSlice.reducer; diff --git a/app/store/store.js b/app/store/store.js index 3e465ea..f991208 100644 --- a/app/store/store.js +++ b/app/store/store.js @@ -1,13 +1,13 @@ import { configureStore } from "@reduxjs/toolkit"; import userReducer from "../(protected)/user/user-slice"; -import teamReducer from "../(protected)/team/team-slice"; +import lineupsReducer from "@/app/store/lineups-slice"; import matchReducer from "../match/match-slice"; const store = configureStore({ reducer: { user: userReducer, - team: teamReducer, + lineups: lineupsReducer, match: matchReducer, }, middleware: (getDefaultMiddleware) => diff --git a/components/custom/Court.jsx b/components/custom/Court.jsx index 0dabcae..289a373 100644 --- a/components/custom/Court.jsx +++ b/components/custom/Court.jsx @@ -154,6 +154,15 @@ export const PlayerCard = ({ ); }; +export const LoadingCard = () => { + return ( + + + + + ); +}; + export const AdjustButton = ({ children, onClick }) => { return (
{ + return ( + + + + +
+
+
+
+ + ); +}; + +export default LoadingCard; diff --git a/components/custom/loading/court.jsx b/components/custom/loading/court.jsx new file mode 100644 index 0000000..5473da8 --- /dev/null +++ b/components/custom/loading/court.jsx @@ -0,0 +1,29 @@ +import { + Court, + Outside, + Inside, + LoadingCard, + AdjustButton, +} from "@/components/custom/Court"; + +const LoadingCourt = ({ className }) => { + return ( + + + + + + + + + + + + + + + + ); +}; + +export default LoadingCourt; diff --git a/components/layout/Nav.jsx b/components/layout/Nav.jsx index 69f9302..c8e8c81 100644 --- a/components/layout/Nav.jsx +++ b/components/layout/Nav.jsx @@ -1,6 +1,7 @@ "use client"; -import { cn } from "@/lib/utils"; import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { useUser } from "@/hooks/use-data"; import { usePathname } from "next/navigation"; import { useDispatch } from "react-redux"; import { matchActions } from "@/app/match/match-slice"; @@ -8,7 +9,7 @@ import { FiHome as HomeIcon, FiUsers as TeamIcon, FiPlusSquare as RecordIcon, - FiClock as HistoryIcon, + FiBell as NotificationsIcon, FiMenu as MenuIcon, } from "react-icons/fi"; @@ -27,6 +28,11 @@ const NavLink = ({ children, className, ...props }) => ( export const Nav = () => { const dispatch = useDispatch(); const pathname = usePathname(); + const { user } = useUser(); + const defaultTeamId = user?.teams?.joined[0] || null; + const defaultTeamUrl = defaultTeamId + ? `/team/${defaultTeamId}` + : "/user/invitations"; const active = (path) => { const activeClass = @@ -41,7 +47,7 @@ export const Nav = () => { 首頁 - + 隊伍 @@ -53,9 +59,9 @@ export const Nav = () => { > - - - 紀錄 + + + 通知 diff --git a/components/team/confirmation/index.jsx b/components/team/confirmation/index.jsx new file mode 100644 index 0000000..dd5b56c --- /dev/null +++ b/components/team/confirmation/index.jsx @@ -0,0 +1,60 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useUser, useUserTeams } from "@/hooks/use-data"; +import { FiCheck, FiX, FiCheckCircle } from "react-icons/fi"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +const ConfirmInvitation = ({ teamId, className }) => { + const router = useRouter(); + const { user, mutate: mutateUser } = useUser(); + const { mutate: mutateUserTeams } = useUserTeams(); + const isInviting = user?.teams.inviting.find((team) => team === teamId); + + const handleAccept = async (teamId, accept) => { + if (!window.confirm(accept ? "確認接受邀請?" : "確認拒絕邀請?")) return; + const action = accept ? "accept" : "reject"; + try { + const response = await fetch( + `/api/users/teams?action=${action}&teamId=${teamId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + } + ); + const userTeams = await response.json(); + mutateUserTeams(); + mutateUser({ ...user, teams: userTeams }, false); + + if (accept) return; + + return router.push("/user/invitations"); + } catch (error) { + console.log(error); + } + }; + if (!isInviting) return null; + + return ( + + + 是否接受此隊伍邀請? + 這個隊伍想邀請您加入成為他們的成員。 +
+ + +
+
+ ); +}; + +export default ConfirmInvitation; diff --git a/components/team/feeds/latest-match.jsx b/components/team/feeds/latest-match.jsx new file mode 100644 index 0000000..e8914ca --- /dev/null +++ b/components/team/feeds/latest-match.jsx @@ -0,0 +1,29 @@ +import { FiChevronRight, FiClock } from "react-icons/fi"; +import { Link } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardBtnGroup, +} from "@/components/ui/card"; + +const LatestMatch = ({ teamId }) => { + return ( + + + + + 近期比賽表現 + + + + 查看更多 + + + + + + ); +}; + +export default LatestMatch; diff --git a/app/(protected)/team/TeamForm.jsx b/components/team/form.jsx similarity index 75% rename from app/(protected)/team/TeamForm.jsx rename to components/team/form.jsx index e72b9ab..914559e 100644 --- a/app/(protected)/team/TeamForm.jsx +++ b/components/team/form.jsx @@ -2,9 +2,8 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import Link from "next/link"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, @@ -25,19 +24,17 @@ const formSchema = z }) .required(); -const TeamForm = ({ teamData, onSubmit }) => { - const teamId = teamData?._id; - +const TeamForm = ({ team, onSubmit, className }) => { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - name: teamId ? teamData.name : "", - nickname: teamId ? teamData.nickname : "", + name: team?.name || "", + nickname: team?.nickname || "", }, }); return ( - <> + 編輯隊伍資訊 @@ -68,15 +65,9 @@ const TeamForm = ({ teamData, onSubmit }) => { )} /> - + - - {teamId ? "取消編輯" : "取消建立"} - - + ); }; diff --git a/components/team/hero/index.jsx b/components/team/hero/index.jsx new file mode 100644 index 0000000..c2d5fd6 --- /dev/null +++ b/components/team/hero/index.jsx @@ -0,0 +1,16 @@ +"use client"; +import { useTeam } from "@/hooks/use-data"; + +const TeamHero = ({ teamId }) => { + const { team } = useTeam(teamId); + + return ( +
+

+ {team?.name || "Loading..."} +

+
+ ); +}; + +export default TeamHero; diff --git a/components/team/info/index.jsx b/components/team/info/index.jsx new file mode 100644 index 0000000..638cfe8 --- /dev/null +++ b/components/team/info/index.jsx @@ -0,0 +1,19 @@ +"use client"; +import { useTeam } from "@/hooks/use-data"; +import { Card } from "@/components/ui/card"; +import TeamInfoTable from "@/components/team/info/table"; +import LoadingCard from "@/components/custom/loading/card"; + +const TeamInfo = ({ teamId, className }) => { + const { team, isLoading } = useTeam(teamId); + + if (isLoading) return ; + + return ( + + + + ); +}; + +export default TeamInfo; diff --git a/app/(protected)/team/info/TeamInfoTable.jsx b/components/team/info/table.jsx similarity index 61% rename from app/(protected)/team/info/TeamInfoTable.jsx rename to components/team/info/table.jsx index 8b252ac..cd2fec6 100644 --- a/app/(protected)/team/info/TeamInfoTable.jsx +++ b/components/team/info/table.jsx @@ -1,4 +1,5 @@ -import { FiInfo, FiUsers } from "react-icons/fi"; +import { FiInfo, FiUsers, FiEdit2 } from "react-icons/fi"; +import { Link } from "@/components/ui/button"; import { Table, TableBody, @@ -13,12 +14,21 @@ const TeamInfoTable = ({ team, className }) => { { key: "簡稱", value: team.nickname, icon: }, { key: "人數", value: team.members.length, icon: }, ]; + const isAdmin = true; + // TODO: 更新 admin 資料結構 return ( - 隊伍資訊 + 隊伍資訊 + + {isAdmin && ( + + + + )} + @@ -30,7 +40,9 @@ const TeamInfoTable = ({ team, className }) => { {key} - {value} + + {value} + ))} diff --git a/components/team/lineup/court.jsx b/components/team/lineup/court.jsx new file mode 100644 index 0000000..0aee77f --- /dev/null +++ b/components/team/lineup/court.jsx @@ -0,0 +1,87 @@ +import { useDispatch, useSelector } from "react-redux"; +import { lineupsActions } from "@/app/store/lineups-slice"; +import { FiRefreshCw } from "react-icons/fi"; +import { + Court, + Outside, + Inside, + PlayerCard, + AdjustButton, +} from "@/components/custom/Court"; + +const LineupCourt = ({ members }) => { + const dispatch = useDispatch(); + const { lineups, status } = useSelector((state) => state.lineups); + + return ( + + + {status.optionMode === "" ? ( + dispatch(lineupsActions.rotateLineup())}> + + 輪轉 + + ) : ( + + )} + {lineups[status.lineupNum]?.liberos && + lineups[status.lineupNum].liberos.map((libero, index) => { + const member = members?.find((m) => m._id === libero._id); + return ( + + dispatch( + lineupsActions.setEditingPlayer({ + _id: member?._id || null, + list: "liberos", + zone: index + 1, + }) + ) + } + onCrossClick={() => + dispatch(lineupsActions.removeEditingPlayer()) + } + editingMember={status.editingMember} + /> + ); + })} + + + {lineups[status.lineupNum]?.starting && + lineups[status.lineupNum].starting.map((starting, index) => { + const member = members?.find((m) => m._id === starting._id); + return ( + + dispatch( + lineupsActions.setEditingPlayer({ + _id: member?._id || null, + list: "starting", + zone: index + 1, + }) + ) + } + onSwitchClick={() => + dispatch(lineupsActions.setOptionMode("substitutes")) + } + onCrossClick={() => + dispatch(lineupsActions.removeEditingPlayer()) + } + editingMember={status.editingMember} + /> + ); + })} + + + ); +}; + +export default LineupCourt; diff --git a/components/team/lineup/index.jsx b/components/team/lineup/index.jsx new file mode 100644 index 0000000..63b7bfc --- /dev/null +++ b/components/team/lineup/index.jsx @@ -0,0 +1,63 @@ +"use client"; +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { lineupsActions } from "@/app/store/lineups-slice"; +import { FiSave } from "react-icons/fi"; +import { Button } from "@/components/ui/button"; +import LineupCourt from "@/components/team/lineup/court"; +import LineupOptions from "@/components/team/lineup/options"; +import LoadingCourt from "@/components/custom/loading/court"; +import LoadingCard from "@/components/custom/loading/card"; + +const Lineup = ({ team, members, handleSave }) => { + const dispatch = useDispatch(); + const { lineups, status } = useSelector((state) => state.lineups); + // const pathname = usePathname(); + // const isRecording = pathname.includes("match"); + // const { setNum } = useSelector((state) => state.match.status.editingData); + // const setData = useSelector((state) => state.match.sets[setNum]); + // const [firstServe, setFirstServe] = useState( + // setData.meta.firstServe === null ? true : setData.meta.firstServe + // ); + + useEffect(() => { + if (team && team.lineup) dispatch(lineupsActions.initiate(team.lineup)); + }, [team, dispatch]); + + if (!team || !members) { + return ( + <> + + +
+
+ + ); + } + + return ( + <> + + + {!status.optionMode && ( +
+ +
+ )} + + ); +}; + +export default Lineup; diff --git a/components/team/lineup/options/config.jsx b/components/team/lineup/options/config.jsx new file mode 100644 index 0000000..f2eb8d7 --- /dev/null +++ b/components/team/lineup/options/config.jsx @@ -0,0 +1,114 @@ +import { useDispatch, useSelector } from "react-redux"; +import { FiUser } from "react-icons/fi"; +import { lineupsActions } from "@/app/store/lineups-slice"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const LineupConfig = ({ members, className }) => { + const dispatch = useDispatch(); + const { lineups, status } = useSelector((state) => state.lineups); + const liberoCount = lineups[status.lineupNum]?.liberos.filter( + (player) => player._id + ).length; + const substituteCount = lineups[status.lineupNum]?.substitutes.length; + const substituteLimit = liberoCount < 2 ? 6 - liberoCount : 6; + const othersCount = lineups[status.lineupNum]?.others.length; + + return ( + + + 陣容資訊 + +
+ + + +
+ + 替補名單 ({substituteCount}/{substituteLimit}) + + +
+
+
+
+ + {lineups[status.lineupNum]?.substitutes && + lineups[status.lineupNum].substitutes.map((player) => { + const member = members?.find((m) => m._id === player._id); + return ( + + + + + + {member?.number} + + {member?.name} + + ); + })} + +
+ + + + +
+ + 其他名單 ({othersCount}) + + +
+
+
+
+ + {lineups[status.lineupNum]?.others && + lineups[status.lineupNum].others.map((player) => { + const member = members?.find((m) => m._id === player._id); + return ( + + + + + + {member?.number} + + {member?.name} + + ); + })} + +
+ + ); +}; + +export default LineupConfig; diff --git a/components/team/lineup/options/index.jsx b/components/team/lineup/options/index.jsx new file mode 100644 index 0000000..e074cc3 --- /dev/null +++ b/components/team/lineup/options/index.jsx @@ -0,0 +1,31 @@ +import { useSelector } from "react-redux"; +import MemberInfo from "@/components/team/members/info"; +import LineupConfig from "@/components/team/lineup/options/config"; +import Substitutes from "@/components/team/lineup/options/substitutes"; +import Positions from "@/components/team/lineup/options/positions"; + +const LineupOptions = ({ team, members, className }) => { + const { lineups, status } = useSelector((state) => state.lineups); + const { optionMode, editingMember } = status; + const member = members?.find((m) => m._id === editingMember._id) || null; + + return ( + <> + {optionMode === "playerInfo" ? ( + + ) : optionMode === "substitutes" || optionMode === "others" ? ( + + ) : optionMode === "positions" ? ( + + ) : ( + + )} + + ); +}; + +export default LineupOptions; diff --git a/app/(protected)/team/lineup/(options)/PositionList.jsx b/components/team/lineup/options/positions.jsx similarity index 63% rename from app/(protected)/team/lineup/(options)/PositionList.jsx rename to components/team/lineup/options/positions.jsx index d592a23..1733266 100644 --- a/app/(protected)/team/lineup/(options)/PositionList.jsx +++ b/components/team/lineup/options/positions.jsx @@ -1,14 +1,13 @@ import { useDispatch, useSelector } from "react-redux"; -import { teamActions } from "../../team-slice"; -import { FiUserCheck, FiUserX } from "react-icons/fi"; +import { lineupsActions } from "@/app/store/lineups-slice"; +import { Button } from "@/components/ui/button"; import { CardHeader, CardTitle } from "@/components/ui/card"; -import { ListItem, ListItemText } from "@/app/components/common/List"; -const PositionList = () => { +const Positions = () => { const dispatch = useDispatch(); - const { starting, status } = useSelector((state) => state.team.editingLineup); + const { lineups, status } = useSelector((state) => state.lineups); + const { starting } = lineups[status.lineupNum]; const { editingMember } = status; - const isEditingBenches = editingMember.zone <= 0; const isEditingLiberos = editingMember.zone > 6; const oppositeZone = editingMember.zone > 3 ? editingMember.zone - 3 : editingMember.zone + 3; @@ -77,42 +76,27 @@ const PositionList = () => { 選擇位置 - {isEditingBenches ? ( - dispatch(teamActions.setPlayerPosition(""))}> - - {editingMember.position === "others" ? ( - - ) : ( - - )} - - - {editingMember.position === "others" - ? "移入替補球員名單" - : "移出替補球員名單"} - - - ) : ( - <> - {positions.map((position, index) => ( - - dispatch(teamActions.setPlayerPosition(position.value)) - } - disabled={position.disabled} - > - - {position.value} - - {position.text} - - ))} - - )} + <> + {positions.map((position) => ( + + ))} + ); }; -export default PositionList; +export default Positions; diff --git a/components/team/lineup/options/substitutes.jsx b/components/team/lineup/options/substitutes.jsx new file mode 100644 index 0000000..0e542f9 --- /dev/null +++ b/components/team/lineup/options/substitutes.jsx @@ -0,0 +1,100 @@ +import { useDispatch, useSelector } from "react-redux"; +import { lineupsActions } from "@/app/store/lineups-slice"; +import { FiUserCheck, FiUser, FiChevronLeft } from "react-icons/fi"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +const Substitutes = ({ members, className }) => { + const dispatch = useDispatch(); + const { lineups, status } = useSelector((state) => state.lineups); + const liberoCount = lineups[status.lineupNum].liberos.filter( + (player) => player._id + ).length; + const substituteCount = lineups[status.lineupNum].substitutes.length; + const substituteLimit = liberoCount < 2 ? 6 - liberoCount : 6; + const isSubstituteFull = substituteCount >= substituteLimit; + const isEditingStarting = status.optionMode === "substitutes"; + + const handleSubstituteClick = (member, index) => { + if (isEditingStarting) { + dispatch( + lineupsActions.replaceEditingPlayer({ + _id: member._id, + list: "substitutes", + zone: index, + }) + ); + } else { + dispatch(lineupsActions.removeSubstitutePlayer(index)); + } + }; + + const handleOtherClick = (member, index) => { + if (isEditingStarting) { + dispatch( + lineupsActions.replaceEditingPlayer({ + _id: member._id, + list: "others", + zone: index, + }) + ); + } else if (!isSubstituteFull) { + dispatch(lineupsActions.addSubstitutePlayer(index)); + } + }; + + return ( + + + + {`替補名單 (${substituteCount}/${substituteLimit})`} + + {lineups[status.lineupNum].substitutes.map((player, index) => { + const member = members.find((m) => m._id === player._id); + return ( + + ); + })} + + {lineups[status.lineupNum].others.map((player, index) => { + const member = members.find((m) => m._id === player._id); + return ( + + ); + })} + + ); +}; + +export default Substitutes; diff --git a/components/team/matches/index.jsx b/components/team/matches/index.jsx new file mode 100644 index 0000000..1066849 --- /dev/null +++ b/components/team/matches/index.jsx @@ -0,0 +1,12 @@ +import { Card } from "@/components/ui/card"; +import TeamMatchesTable from "@/components/team/matches/table"; + +const TeamMatches = ({ teamId, className }) => { + return ( + + + + ); +}; + +export default TeamMatches; diff --git a/app/(protected)/history/HistoryList.jsx b/components/team/matches/table.jsx similarity index 97% rename from app/(protected)/history/HistoryList.jsx rename to components/team/matches/table.jsx index 6cd705d..1832f1a 100644 --- a/app/(protected)/history/HistoryList.jsx +++ b/components/team/matches/table.jsx @@ -82,9 +82,9 @@ const DataTable = ({ columns, data }) => { ); }; -const HistoryList = () => { +const TeamMatchesTable = () => { const { matches } = useSelector((state) => state.team); return ; }; -export default HistoryList; +export default TeamMatchesTable; diff --git a/app/(protected)/team/member/MemberForm.jsx b/components/team/members/form.jsx similarity index 59% rename from app/(protected)/team/member/MemberForm.jsx rename to components/team/members/form.jsx index 38331b7..1dbb0f8 100644 --- a/app/(protected)/team/member/MemberForm.jsx +++ b/components/team/members/form.jsx @@ -2,8 +2,8 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useSelector } from "react-redux"; -import { Button, Link } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, @@ -32,22 +32,23 @@ const formSchema = z.object({ admin: z.coerce.boolean().optional(), }); -const MemberForm = ({ member = null, onSubmit }) => { - const { admin: isAdmin } = useSelector((state) => state.team); - +const MemberForm = ({ member, onSubmit, className }) => { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: member?.name || "", number: member?.number || "", position: member?.position || "", - email: member?.meta.email || "", - admin: member?.meta.admin ? "true" : "false", + email: member?.meta?.email || "", + admin: member?.meta?.admin ? "true" : "false", }, }); return ( - <> + + + 編輯隊員資訊 +
{ )} /> - {isAdmin && ( - <> - - ( - - 信箱 - - - - 請輸入信箱 - - )} - /> - ( - - 權限 - - - 一般成員 - - - 管理者 - - - - - 是否有權限變更隊伍與隊員資訊 - - - )} - /> - - )} + + ( + + 信箱 + + + + 請輸入信箱 + + )} + /> + ( + + 權限 + + + 一般成員 + + + 管理者 + + + + 是否有權限變更隊伍與隊員資訊 + + )} + /> - {member ? ( - - 取消編輯 - - ) : ( - - 取消新增 - - )} - +
); }; diff --git a/components/team/members/index.jsx b/components/team/members/index.jsx new file mode 100644 index 0000000..7b60e69 --- /dev/null +++ b/components/team/members/index.jsx @@ -0,0 +1,31 @@ +"use client"; +import { useTeamMembers } from "@/hooks/use-data"; +import { FiPlus } from "react-icons/fi"; +import { BsGrid3X2Gap } from "react-icons/bs"; +import { Link } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import TeamMembersTable from "./table"; + +const TeamMembers = ({ teamId, className }) => { + const { members, isLoading } = useTeamMembers(teamId); + + if (isLoading) return <>loading...; + + return ( + +
+ + + 編輯陣容 + + + + 新增隊員 + +
+ +
+ ); +}; + +export default TeamMembers; diff --git a/components/team/members/info/index.jsx b/components/team/members/info/index.jsx new file mode 100644 index 0000000..b957a78 --- /dev/null +++ b/components/team/members/info/index.jsx @@ -0,0 +1,22 @@ +"use client"; +import { useTeam, useTeamMembers } from "@/hooks/use-data"; +import { Card } from "@/components/ui/card"; +import MembersInfoTable from "@/components/team/members/info/table"; + +const MembersInfo = ({ teamId, memberId, className }) => { + const { team } = useTeam(teamId); + const { members, isLoading } = useTeamMembers(teamId); + const member = members?.find((member) => member._id === memberId) || {}; + + return ( + + {isLoading ? ( + <>Loading... + ) : ( + + )} + + ); +}; + +export default MembersInfo; diff --git a/components/team/members/info/table.jsx b/components/team/members/info/table.jsx new file mode 100644 index 0000000..c195c1a --- /dev/null +++ b/components/team/members/info/table.jsx @@ -0,0 +1,71 @@ +import { + FiHash, + FiUser, + FiUsers, + FiMail, + FiShield, + FiEdit2, +} from "react-icons/fi"; +import { Link } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const MembersInfoTable = ({ team, member, className }) => { + const contents = [ + { key: "隊伍", value: team?.name, icon: }, + { key: "背號", value: member?.number, icon: }, + { key: "位置", value: member?.position, icon: }, + { key: "姓名", value: member?.name, icon: }, + { key: "信箱", value: member?.meta?.email, icon: }, + { + key: "權限", + value: member?.meta?.admin ? "管理者" : "一般成員", + icon: , + }, + ]; + const isAdmin = true; + // TODO: 更新 admin 資料結構 + + return ( + + + + 成員資訊 + + {isAdmin && ( + + + + )} + + + + + {contents.map(({ key, value, icon }) => ( + + + {icon} + + + {key} + + + {value} + + + ))} + +
+ ); +}; + +export default MembersInfoTable; diff --git a/app/(protected)/team/TeamMembers.jsx b/components/team/members/table.jsx similarity index 86% rename from app/(protected)/team/TeamMembers.jsx rename to components/team/members/table.jsx index d51cea1..7988d39 100644 --- a/app/(protected)/team/TeamMembers.jsx +++ b/components/team/members/table.jsx @@ -1,10 +1,7 @@ -"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useSelector } from "react-redux"; import { FiUser, - FiPlus, FiShield, FiClock, FiCheckCircle, @@ -12,7 +9,7 @@ import { } from "react-icons/fi"; import { HiArrowsUpDown } from "react-icons/hi2"; import { Badge } from "@/components/ui/badge"; -import { Button, Link } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { flexRender, getCoreRowModel, @@ -97,7 +94,7 @@ const columns = [ }, ]; -const DataTable = ({ columns, data }) => { +const TeamMembersTable = ({ data, teamId }) => { const [sorting, setSorting] = useState(data); const router = useRouter(); const table = useReactTable({ @@ -140,7 +137,9 @@ const DataTable = ({ columns, data }) => { router.push(`/team/member/${row.original._id}`)} + onClick={() => + router.push(`/team/${teamId}/members/${row.original._id}`) + } > {row.getVisibleCells().map((cell) => ( @@ -161,18 +160,4 @@ const DataTable = ({ columns, data }) => { ); }; -const TeamMembers = () => { - const { members } = useSelector((state) => state.team); - - return ( - <> - - - - 新增隊員 - - - ); -}; - -export default TeamMembers; +export default TeamMembersTable; diff --git a/components/ui/alert.jsx b/components/ui/alert.jsx index 4fb38ad..3f578dc 100644 --- a/components/ui/alert.jsx +++ b/components/ui/alert.jsx @@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; const alertVariants = cva( - "relative w-full rounded-md border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + "relative w-full flex flex-col gap-2 rounded-md border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:text-foreground [&>svg~*]:pl-7", { variants: { variant: { diff --git a/components/ui/button.jsx b/components/ui/button.jsx index f5543f0..ee9cea7 100644 --- a/components/ui/button.jsx +++ b/components/ui/button.jsx @@ -19,14 +19,14 @@ const buttonVariants = cva( secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + link: "text-primary underline-offset-4 xl:hover:underline", option_win: "bg-[rgba(183,210,216,1)] text-foreground shadow hover:bg-primary/80", option_lose: "bg-[rgba(254,215,204,1)] text-foreground shadow hover:bg-destructive/80", }, size: { - default: "h-9 rounded-md px-4 py-2 text-sm svg-[1.25rem]", + default: "h-9 rounded-md px-2 py-2 text-sm svg-[1.25rem]", xs: "h-4 rounded-md text-xs svg-[1rem]", sm: "h-8 rounded-md p-0 md:px-3 text-xs svg-[1rem]", lg: "h-10 rounded-md p-0 md:px-8 text-lg svg-[1.5rem]", diff --git a/components/ui/table.jsx b/components/ui/table.jsx index 07443a1..92a9109 100644 --- a/components/ui/table.jsx +++ b/components/ui/table.jsx @@ -3,7 +3,7 @@ import * as React from "react" import { cn } from "@/lib/utils" const Table = React.forwardRef(({ className, ...props }, ref) => ( -
+
( + +)); +Tabs.displayName = TabsPrimitive.Root.displayName; + +const TabsList = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/app/(protected)/user/invitations/Invitations.jsx b/components/user/invitations/index.jsx similarity index 54% rename from app/(protected)/user/invitations/Invitations.jsx rename to components/user/invitations/index.jsx index 2136832..065f8e5 100644 --- a/app/(protected)/user/invitations/Invitations.jsx +++ b/components/user/invitations/index.jsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useUser, useUserTeams } from "@/hooks/use-data"; import { FiUsers, FiPlus, FiCheck, FiX } from "react-icons/fi"; -import { Button, Link } from "@/components/ui/button"; +import { Link } from "@/components/ui/button"; import { Card, CardHeader, @@ -10,7 +10,7 @@ import { CardDescription, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { ListItem, ListItemText } from "@/app/components/common/List"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; const Invitations = ({ className }) => { const router = useRouter(); @@ -32,7 +32,7 @@ const Invitations = ({ className }) => { mutateUserTeams(); mutateUser({ ...user, teams: userTeams }); - return accept ? router.push("/team") : null; + return accept ? router.push(`/team/${teamId}`) : null; } catch (error) { console.log(error); } @@ -43,30 +43,38 @@ const Invitations = ({ className }) => { 隊伍邀請 - {isLoading ? ( - <>loading... - ) : ( - teams.inviting.map((team) => ( - - - {team.name} - - - - )) - )} +
+ + {isLoading ? ( + + Loading... + + ) : ( + teams.inviting.map((team) => ( + + + + + router.push(`/team/${team._id}`)}> + {team.name} + + handleAccept(team._id, true)} + > + + + handleAccept(team._id, false)} + > + + + + )) + )} + +
diff --git a/app/(protected)/user/Menu.jsx b/components/user/menu/index.jsx similarity index 89% rename from app/(protected)/user/Menu.jsx rename to components/user/menu/index.jsx index 2d1488e..ee93d71 100644 --- a/app/(protected)/user/Menu.jsx +++ b/components/user/menu/index.jsx @@ -3,8 +3,7 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { useUserTeams } from "@/hooks/use-data"; +import { useUser, useUserTeams } from "@/hooks/use-data"; import { FiChevronDown, FiSettings, @@ -20,8 +19,7 @@ import { Separator } from "@/components/ui/separator"; const Menu = ({ className }) => { const router = useRouter(); - const { data, update } = useSession(); - const user = data?.user || null; + const { user, mutate: mutateUser } = useUser(); const { teams, isLoading: isUserTeamsLoading, @@ -30,7 +28,7 @@ const Menu = ({ className }) => { const [extendTeams, setExtendTeams] = useState(false); const handleTeamSwitch = async (index, team) => { - if (index === 0) return router.push("/team"); + if (index === 0) return router.push(`/team/${team._id}`); try { const response = await fetch( `/api/users/teams?action=switch&teamId=${team._id}`, @@ -40,9 +38,10 @@ const Menu = ({ className }) => { } ); const userTeams = await response.json(); - await update({ ...data, user: { ...data.user, teams: userTeams } }); + mutateUser({ ...user, teams: userTeams }); mutateUserTeams(); - return router.push("/team"); + + return router.push(`/team/${team._id}`); } catch (error) { console.log(error); // TODO: 錯誤提示訊息 @@ -113,7 +112,7 @@ const Menu = ({ className }) => { key={team._id} variant="ghost" size="wide" - onClick={() => router.push(`/team/info/${team._id}`)} + onClick={() => router.push(`/team/${team._id}`)} > {team.name || ""} diff --git a/hooks/use-data.js b/hooks/use-data.js index e742951..b50c6b9 100644 --- a/hooks/use-data.js +++ b/hooks/use-data.js @@ -42,22 +42,13 @@ export const useTeam = (teamId, fetcher = defaultFetcher, options = {}) => { fetcher, { dedupingInterval: 5 * 60 * 1000, ...options } ); - const { teamData, membersData } = data || {}; - return { - data, - team: teamData, - members: membersData, - error, - isLoading, - isValidating, - mutate, - }; + return { team: data, error, isLoading, isValidating, mutate }; }; export const useTeamMembers = ( teamId, - fetcher = customFetcher, + fetcher = defaultFetcher, options = {} ) => { // TODO: 新增 conditional fetching diff --git a/middleware.js b/middleware.js index a35f2bd..8636020 100644 --- a/middleware.js +++ b/middleware.js @@ -29,6 +29,15 @@ export default auth((req) => { return NextResponse.next(); } + if (isSignedIn && nextUrl.pathname === "/team") { + const defaultTeamId = req.auth?.user?.teams?.joined[0]; + if (!defaultTeamId) { + return NextResponse.redirect(new URL("/user/invitations", nextUrl)); + } + + return NextResponse.redirect(new URL(`/team/${defaultTeamId}`, nextUrl)); + } + if (!isSignedIn && !isPublicRoute) { return NextResponse.redirect(new URL("/auth/sign-in", nextUrl)); } diff --git a/package-lock.json b/package-lock.json index e625e23..be62a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@reduxjs/toolkit": "^2.2.5", "@tanstack/react-table": "^8.17.3", "@vercel/analytics": "^1.2.2", @@ -2593,6 +2594,37 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", diff --git a/package.json b/package.json index 6f59281..956e54e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@reduxjs/toolkit": "^2.2.5", "@tanstack/react-table": "^8.17.3", "@vercel/analytics": "^1.2.2",