diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e62de55..e38cd5d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,16 +4,8 @@ on: branches: - develop jobs: - build-and-deploy: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [windows-latest, macos-latest] - - outputs: - build_outcome: ${{ steps.build_app.outcome }} - deploy_outcome: ${{ steps.deploy_web.outcome }} - + build: + runs-on: ubuntu-20.04 steps: - name: Checkout source code uses: actions/checkout@v3 @@ -23,73 +15,44 @@ jobs: - name: Get commit message and author id: get_commit_info run: | - echo "message=$(git log --format=%s -n 1)" >> $GITHUB_OUTPUT - echo "author=$(git log --format=%an -n 1)" >> $GITHUB_OUTPUT - echo "author_username=$(git log --format=%ae -n 1 | cut -d@ -f1)" >> $GITHUB_OUTPUT + echo "::set-output name=message::$(git log --format=%s -n 1)" + echo "::set-output name=author::$(git log --format=%an -n 1)" + echo "::set-output name=author_username::$(git log --format=%ae -n 1 | cut -d@ -f1)" - name: Install dependencies run: yarn install - # 웹 빌드 및 S3 업로드 (macOS 환경에서만) - - name: Build Web App - id: build_web - if: matrix.os == 'macos-latest' + - name: Generate build + id: build env: VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} VITE_OAUTH_KAKAO_REST_API_KEY: ${{ secrets.VITE_OAUTH_KAKAO_REST_API_KEY }} VITE_OAUTH_KAKAO_CLIENT_SECRET_CODE: ${{ secrets.VITE_OAUTH_KAKAO_CLIENT_SECRET_CODE }} VITE_OAUTH_KAKAO_REDIRECT_URI: ${{ secrets.VITE_OAUTH_KAKAO_REDIRECT_URI }} - VITE_DMG_DOWNLOAD_URL: ${{ secrets.VITE_DMG_DOWNLOAD_URL }} - VITE_EXE_DOWNLOAD_URL: ${{ secrets.VITE_EXE_DOWNLOAD_URL }} - run: | - echo "Building web app..." - yarn build + run: yarn build + continue-on-error: true - - name: Upload Web App to AWS S3 - id: deploy_web - if: matrix.os == 'macos-latest' + - name: Deploy to S3 + id: deploy + if: steps.build.outcome == 'success' env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | - echo "Deploying web build to S3..." - aws s3 sync dist/web s3://alignlab-client --delete --region ap-northeast-2 - - # Electron 빌드 및 GitHub Releases로 Publish - - name: Build and Publish Electron App - id: build_app - env: - VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} - VITE_OAUTH_KAKAO_REST_API_KEY: ${{ secrets.VITE_OAUTH_KAKAO_REST_API_KEY }} - VITE_OAUTH_KAKAO_CLIENT_SECRET_CODE: ${{ secrets.VITE_OAUTH_KAKAO_CLIENT_SECRET_CODE }} - VITE_OAUTH_KAKAO_REDIRECT_URI: ${{ secrets.VITE_OAUTH_KAKAO_REDIRECT_URI }} - VITE_DMG_DOWNLOAD_URL: ${{ secrets.VITE_DMG_DOWNLOAD_URL }} - VITE_EXE_DOWNLOAD_URL: ${{ secrets.VITE_EXE_DOWNLOAD_URL }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn electron:publish + aws s3 sync --region ap-northeast-2 dist/web s3://alignlab-client --delete + continue-on-error: true - # CloudFront 캐시 무효화 - invalidate-cache: - runs-on: ubuntu-20.04 - needs: [build-and-deploy] - if: success() - steps: - name: Invalidate CloudFront Cache + if: steps.deploy.outcome == 'success' env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} run: | - echo "Invalidating CloudFront cache..." aws cloudfront create-invalidation --region ap-northeast-2 --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*" - # Discord 알림 - 성공 - notify-success: - runs-on: ubuntu-20.04 - needs: [build-and-deploy, invalidate-cache] - if: success() - steps: - name: Discord notification - Success + if: steps.deploy.outcome == 'success' env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_DEPLOY_WEBHOOK }} DISCORD_USERNAME: GitHub @@ -98,28 +61,27 @@ jobs: with: args: | 🎉 배포가 성공적으로 완료되었습니다! - 다운로드 링크: https://github.com/${{ github.repository }}/releases/latest - 웹 앱 링크: https://alignlab.site 브랜치: develop - 커밋: ${{ needs.build-and-deploy.outputs.message }} - 작성자: ${{ needs.build-and-deploy.outputs.author }} + 커밋: ${{ steps.get_commit_info.outputs.message }} + 작성자: ${{ steps.get_commit_info.outputs.author }} - # Discord 알림 - 실패 - notify-failure: - runs-on: ubuntu-20.04 - needs: [build-and-deploy] - if: failure() - steps: - name: Discord notification - Failure + if: steps.build.outcome == 'failure' || steps.deploy.outcome == 'failure' env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_DEPLOY_WEBHOOK }} + DISCORD_USERNAME: GitHub + DISCORD_AVATAR: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png uses: Ilshidur/action-discord@master with: args: | - ❌ ${{ needs.build-and-deploy.outputs.build_outcome == 'failure' && '빌드 중' || '배포 중' }} 오류가 발생했습니다. + ❌ ${{ steps.build.outcome == 'failure' && '빌드 중' || '배포 중' }} 오류가 발생했습니다. 브랜치: develop - 커밋: ${{ needs.build-and-deploy.outputs.message }} + 커밋: ${{ steps.get_commit_info.outputs.message }} 작성자: <@${{ secrets.DISCORD_ID_1 }}> 실패한 워크플로우: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - ${{ needs.build-and-deploy.outputs.build_outcome == 'failure' && '빌드 오류 메시지:' || '' }} - ${{ needs.build-and-deploy.outputs.build_outcome == 'failure' && needs.build-and-deploy.steps.build_app.outputs.stderr || '' }} \ No newline at end of file + ${{ steps.build.outcome == 'failure' && '빌드 오류 메시지:' || '' }} + ${{ steps.build.outcome == 'failure' && steps.build.outputs.stderr || '' }} + + - name: Check deploy result + if: steps.build.outcome == 'failure' || steps.deploy.outcome == 'failure' + run: exit 1 \ No newline at end of file diff --git a/src/api/group.ts b/src/api/group.ts index b9bdd0c..9f0bca0 100644 --- a/src/api/group.ts +++ b/src/api/group.ts @@ -22,6 +22,7 @@ export interface group { userCapacity?: number hasJoined?: boolean ranks?: groupUserRank[] + tagNames?: string[] } export interface groupUserRank { @@ -41,6 +42,7 @@ export interface groupsReq { page: number size: number sort: sort + keyword: string } export interface groupsRes { @@ -70,6 +72,10 @@ export interface MyGroupData { userCount: number userCapacity: number ownerNickname: string + ownerUid: number + isHidden: boolean + joinCode?: string + tagNames: string[] } export const getGroups = async (groupsReq: groupsReq): Promise => { @@ -128,6 +134,15 @@ export const createGroup = async (group: group): Promise => { } } +export const modifyGroup = async (group: group): Promise => { + try { + const res = await axiosInstance.put(`groups/${group.id}`, { ...group }) + return res.data.data + } catch (e) { + throw e + } +} + export const getGroupScores = async (groupdId: string | number): Promise<{ data: GroupUserRankData }> => { try { const res = await axiosInstance.get(`/group-scores?groupId=${groupdId}`) diff --git a/src/assets/icons/crew-edit-icon.svg b/src/assets/icons/crew-edit-icon.svg new file mode 100644 index 0000000..7a6ca32 --- /dev/null +++ b/src/assets/icons/crew-edit-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/crew-search-icon.svg b/src/assets/icons/crew-search-icon.svg new file mode 100644 index 0000000..ca26d60 --- /dev/null +++ b/src/assets/icons/crew-search-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/Crew/CrewItem.tsx b/src/components/Crew/CrewItem.tsx index f5539c5..df9d3c0 100644 --- a/src/components/Crew/CrewItem.tsx +++ b/src/components/Crew/CrewItem.tsx @@ -1,15 +1,40 @@ import { group } from "@/api" import PrivateCrewIcon from "@assets/icons/crew-private-icon.svg?react" import CrewUserIcon from "@assets/icons/crew-user-icon.svg?react" -import { ReactElement } from "react" +import { ReactElement, useCallback } from "react" interface CrewItemProps { group: group + keyword: string onClickDetail: () => void } +// 키워드 강조 함수 +const highlightKeyword = (text: string | undefined, keyword: string): React.ReactNode => { + if (!text) return null + if (!keyword) return text + const parts = text.split(new RegExp(`(${keyword})`, "gi")) + return parts.map((part, index) => + part.toLowerCase() === keyword.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ) +} + const CrewItem = (props: CrewItemProps): ReactElement => { - const { group, onClickDetail } = props + const { group, keyword, onClickDetail } = props + + const createTags = useCallback( + (tags: string[] | undefined): React.ReactElement[] | null => { + if (!tags || tags.length === 0) return null + return tags.map((tag) =>
{highlightKeyword(`#${tag}`, keyword)}
) + }, + [group.id, keyword] + ) return (
{ }} className="flex w-full items-center gap-[24px] bg-white px-[24px] py-[11px] text-[14px] font-semibold leading-[32px]" > - {/* crew name */} -
- {group.isHidden && } -
{group.name}
-
- {/* crew user cnt */} -
- -
{`${group.userCount}/${group.userCapacity}명`}
+
+ {/* crew name */} +
+ {group.isHidden && } +
{highlightKeyword(group.name, keyword)}
+
+ {/* crew user cnt */} +
+ +
{`${group.userCount}/${group.userCapacity}명`}
+
+ {/* crew tags */} + {keyword &&
{createTags(group?.tagNames)}
}
{/* detail button */}
{/* list */} - {isError ? "데이터를 불러오는데 실패했습니다." : createGroupList(data?.data)} + {isError ? "데이터를 불러오는데 실패했습니다." : createGroupList(data?.data, params.keyword)}
) } diff --git a/src/components/Crew/MyCrew/MyCrewHeader.tsx b/src/components/Crew/MyCrew/MyCrewHeader.tsx index 6e67d30..163c2da 100644 --- a/src/components/Crew/MyCrew/MyCrewHeader.tsx +++ b/src/components/Crew/MyCrew/MyCrewHeader.tsx @@ -1,24 +1,62 @@ import CreateCrewIcon from "@assets/icons/crew-create-button-icon.svg?react" +import SearchIcon from "@assets/icons/crew-search-icon.svg?react" interface MyCrewHeaderProps { openCreateModal?: () => void + onSearchGroups?: () => void isDisplayedCreationButton?: boolean + isDisplayedSearch?: boolean + keyword?: string + setKeyword?: React.Dispatch> } export default function MyCrewHeader(props: MyCrewHeaderProps) { - const { openCreateModal, isDisplayedCreationButton = false } = props + const { + openCreateModal, + isDisplayedCreationButton = false, + isDisplayedSearch = false, + onSearchGroups, + keyword, + setKeyword, + } = props + + const setKeywordHandler = (e: React.ChangeEvent): void => { + if (setKeyword) setKeyword(e.target.value) + } + + const onSearchGroupsHandler = (): void => { + if (onSearchGroups) onSearchGroups() + } + return (
나의 크루
- {isDisplayedCreationButton && ( -
- -
크루 만들기
-
- )} +
+ {/* 크루 검색 */} + {isDisplayedSearch && ( +
+ { + if (e.key === "Enter") onSearchGroupsHandler() + }} + className="w-[163px] text-[13px] font-medium outline-none " + placeholder="크루명, 태그로 검색할 수 있어요." + /> + +
+ )} + {isDisplayedCreationButton && ( +
+ +
크루 만들기
+
+ )} +
) } diff --git a/src/components/Crew/MyCrew/MyCrewRankingContainer.tsx b/src/components/Crew/MyCrew/MyCrewRankingContainer.tsx index db42fe3..f0c9004 100644 --- a/src/components/Crew/MyCrew/MyCrewRankingContainer.tsx +++ b/src/components/Crew/MyCrew/MyCrewRankingContainer.tsx @@ -16,13 +16,33 @@ interface MyCrewRankingContainerProps { myRank: groupUserRank | undefined openCreateModal: () => void openInviteModal: () => void + onSearchGroups: () => void + keyword: string + setKeyword: React.Dispatch> } export default function MyCrewRankingContainer(props: MyCrewRankingContainerProps) { - const { isLoading, myGroupData, ranks, myRank, openCreateModal, openInviteModal } = props + const { + isLoading, + myGroupData, + ranks, + myRank, + openCreateModal, + openInviteModal, + onSearchGroups, + keyword, + setKeyword, + } = props return (
- +
diff --git a/src/components/Modal/CreateCrewModal.tsx b/src/components/Modal/CreateCrewModal.tsx index 9354dee..2fbeb3a 100644 --- a/src/components/Modal/CreateCrewModal.tsx +++ b/src/components/Modal/CreateCrewModal.tsx @@ -1,24 +1,63 @@ import ModalContainer from "@components/ModalContainer" import CheckedIcon from "@assets/icons/crew-checked-icon.svg?react" import UnCheckedIcon from "@assets/icons/crew-unckecked-icon.svg?react" -import { useState } from "react" +import { useCallback, useEffect, useState } from "react" import { ModalProps } from "@/contexts/ModalsContext" -import { useCheckGroupName, useCreateGroup } from "@/hooks/useGroupMutation" +import { useCheckGroupName, useCreateGroup, useModifyGroup } from "@/hooks/useGroupMutation" import { group } from "@/api" +import useMyGroup from "@/hooks/useMyGroup" type TPossible = "POSSIBLE" | "IMPOSSIBLE" | "NONCHECKED" +interface ITag { + name: string + isSelected: boolean + onClick?: (name: string) => void +} + +export const Tag = (props: ITag): React.ReactElement => { + const { name, isSelected, onClick } = props + + const onClickHandler = (): void => { + if (onClick) onClick(name) + } + + return ( +
+ {name} +
+ ) +} + +/** + * @todo + * 수정 상태에서 + * 이름 : 원래의 이름과 같은 경우에는 중복체크 할 필요 없음 + * 수정 : 변경 사항이 있는 경우에만 만들기 활성화 + */ + const CreateCrewModal = (props: ModalProps): React.ReactElement => { - const { onClose, onSubmit } = props + const { onClose, onSubmit, isModify } = props const [name, setName] = useState("") const [description, setDescription] = useState("") const [isHidden, setIsHidden] = useState(false) const [joinCode, setJoinCode] = useState("") const [isPossible, setIsPossible] = useState(null) + const [tag, setTag] = useState("") + const [tags, setTags] = useState([]) + const [isComposing, setIsComposing] = useState(false) // 한글 조합 상태 const checkGroupNameMutation = useCheckGroupName() const createGroupMutation = useCreateGroup() + const modifyGroupMutation = useModifyGroup() + + const { myGroupData } = useMyGroup() const onChangeName = (e: React.ChangeEvent): void => { setName(e.target.value) @@ -29,6 +68,12 @@ const CreateCrewModal = (props: ModalProps): React.ReactElement => { if (e.target.value.length <= 300) setDescription(e.target.value) } + const onChangeTag = (e: React.ChangeEvent): void => { + const { value } = e.target + if (/^[^\s]*$/.test(value) && value.length <= 10) setTag(value) + else setIsComposing(false) + } + // Enter 키 입력을 막는 함수 const handleKeyPress = (e: React.KeyboardEvent): void => { if (e.key === "Enter") { @@ -54,6 +99,25 @@ const CreateCrewModal = (props: ModalProps): React.ReactElement => { setIsPossible(_isPossible ? "POSSIBLE" : "IMPOSSIBLE") } + const onSelectTag = (tagName: string): void => { + setTags((prevTags) => prevTags.map((tag) => (tag.name === tagName ? { ...tag, isSelected: !tag.isSelected } : tag))) + } + + const onEnterTag = (e: React.KeyboardEvent): void => { + if (e.key === "Backspace") setIsComposing(false) + if (e.key === "Enter" && !isComposing) { + if (!tag) return + if (tags.findIndex((t) => t.name === tag) === -1) { + setTags([...tags, { name: tag, isSelected: false }]) + } + setTag("") + } + } + + const createTags = (tags: ITag[]): React.ReactElement[] => { + return tags.map((tag) => ) + } + const getNameCheckedMsg = (_isPossible: TPossible | null): string => { if (_isPossible === "POSSIBLE") return "사용가능한 크루명입니다." if (_isPossible === "IMPOSSIBLE") return "이미 사용중인 크루명이에요. 다른 크루명을 사용해주세요." @@ -61,38 +125,118 @@ const CreateCrewModal = (props: ModalProps): React.ReactElement => { return "" } - const canCreate = (): string | boolean => { - return name && description && ((isHidden && joinCode.length === 4) || !isHidden) + const canCreate = (): boolean => { + const checkForm = Boolean(name && description && ((isHidden && joinCode.length === 4) || !isHidden)) + if (isModify) { + const newTagList = tags.map((t) => t.name) + const prevTagList = myGroupData?.tagNames + const prevTagSet = new Set(prevTagList) + const newTagSet = new Set(newTagList) + + const isTagsModified = + !newTagList.every((t) => prevTagSet.has(t)) || !(prevTagList || []).every((t) => newTagSet.has(t)) + + return ( + checkForm && + (name !== myGroupData?.name || + description !== myGroupData.description || + isHidden !== myGroupData.isHidden || + (isHidden && joinCode !== myGroupData.joinCode) || + isTagsModified) + ) + } + + return checkForm } + const canNameCheck = useCallback((): boolean => { + return Boolean(name.length === 0 || isPossible === "POSSIBLE" || (isModify && name === myGroupData?.name)) + }, [name, isPossible, isModify, myGroupData]) + const handleSubmit = (): void => { - if (isPossible === null) { - setIsPossible("NONCHECKED") - return + if (name !== myGroupData?.name || !isModify) { + if (isPossible === null) { + setIsPossible("NONCHECKED") + return + } + if (isPossible === "NONCHECKED") return } - if (isPossible === "NONCHECKED") return let newGroup: group = { name, description } if (isHidden) newGroup = { ...newGroup, joinCode, isHidden } - createGroupMutation.mutate(newGroup, { + if (tags.length > 0) newGroup = { ...newGroup, tagNames: tags.map((t) => t.name) } + + if (isModify) { + newGroup = { ...newGroup, id: Number(myGroupData?.id) } + } + + const mutation = isModify ? modifyGroupMutation : createGroupMutation + + mutation.mutate(newGroup, { onSuccess: (): void => { if (onSubmit && typeof onSubmit === "function") onSubmit() }, }) } + const handleCompositionStart = (): void => { + setIsComposing(true) // 한글 조합 시작 + } + + const handleCompositionEnd = (): void => { + setIsComposing(false) // 조합 종료 + } + + const initModify = useCallback(() => { + if (myGroupData) { + setName(myGroupData.name) + setDescription(myGroupData.description) + setIsHidden(myGroupData.isHidden) + if (myGroupData.tagNames && myGroupData.tagNames.length > 0) + setTags(myGroupData.tagNames.map((t) => ({ name: t, isSelected: false }))) + if (myGroupData.isHidden && myGroupData.joinCode) setJoinCode(myGroupData.joinCode) + } + }, [myGroupData]) + + // 전역 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Backspace") { + // 선택된 태그 삭제 + const hasSelectedTags = tags.some((tag) => tag.isSelected) + if (hasSelectedTags) { + e.preventDefault() // Backspace의 기본 동작 방지 (필요한 경우) + setTags((prevTags) => prevTags.filter((tag) => !tag.isSelected)) + } + } + } + + // 전역 이벤트 리스너 등록 + window.addEventListener("keydown", handleKeyDown) + + return () => { + // 전역 이벤트 리스너 정리 + window.removeEventListener("keydown", handleKeyDown) + } + }, [tags]) + + useEffect(() => { + if (!myGroupData) return + initModify() + }, [initModify, isModify, myGroupData]) + return (
{/* header */}
-
{"크루 만들기"}
+
{!isModify ? "크루 만들기" : "크루 수정하기"}
{/* crew owner */}
-
크루명
+
크루명*
{ /> @@ -123,8 +267,8 @@ const CreateCrewModal = (props: ModalProps): React.ReactElement => {
{/* crew description */} -
-
크루 소개
+
+
크루 소개*