diff --git a/src/App.tsx b/src/App.tsx index 9fa45b7..16e5a83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -164,6 +164,7 @@ function AppShell() { useAuthGuard(pathname) const userName = useUserStore((s) => s.user?.nickname ?? '') + const profileImageUrl = useUserStore((s) => s.user?.profileImageUrl) const fetchUser = useUserStore((s) => s.fetchUser) useEffect(() => { @@ -180,7 +181,7 @@ function AppShell() { } return ( - + }> diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 9e72cfc..c7b6d42 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -23,8 +23,10 @@ export default function ConfirmModal({ return (
-

{title}

- {description &&

{description}

} +

{title}

+ {description && ( +

{description}

+ )}
{/* 아바타 (클릭 시 나의 구인구직/마이페이지/설정 드롭다운) */} - + )} diff --git a/src/components/HeaderProfileMenu.tsx b/src/components/HeaderProfileMenu.tsx index c87c032..999103a 100644 --- a/src/components/HeaderProfileMenu.tsx +++ b/src/components/HeaderProfileMenu.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import { navigate } from '../utils/navigation' import { actionMenuItemClass, actionMenuPanelClass } from '../styles/dropdown' +import { Avatar } from './Avatar' interface HeaderProfileMenuProps { userName: string + profileImageUrl?: string | null } const MENU_ITEMS = [ @@ -12,7 +14,7 @@ const MENU_ITEMS = [ { label: '설정', path: '/settings' }, ] as const -export default function HeaderProfileMenu({ userName }: HeaderProfileMenuProps) { +export default function HeaderProfileMenu({ userName, profileImageUrl }: HeaderProfileMenuProps) { const [open, setOpen] = useState(false) const containerRef = useRef(null) const triggerId = useId() @@ -51,9 +53,17 @@ export default function HeaderProfileMenu({ userName }: HeaderProfileMenuProps) aria-expanded={open} aria-controls={open ? menuId : undefined} onClick={() => setOpen((v) => !v)} - className="bg-neutral-3 text-caption-sm text-neutral-7 hover:bg-neutral-4 flex h-10 w-10 items-center justify-center rounded-full font-medium" + className="hover:opacity-80" > - {userName.charAt(0)} + + {userName.charAt(0)} + + } + /> {open && ( diff --git a/src/domains/workspace/ProjectStatusMenu.tsx b/src/domains/workspace/ProjectStatusMenu.tsx index 6a3b04b..abd4ac4 100644 --- a/src/domains/workspace/ProjectStatusMenu.tsx +++ b/src/domains/workspace/ProjectStatusMenu.tsx @@ -99,10 +99,10 @@ export default function ProjectStatusMenu({ className="w-[calc(100vw-32px)] max-w-[780px]" >
-

+

완료로 전환하면 참여자들의 포트폴리오에 자동으로 추가됩니다.

-
+

각자의 프로필 페이지에서 수정 삭제가 가능합니다.

완료로 변경 시 진행 상황 변경이 불가합니다.

diff --git a/src/domains/workspace/VideoCard.tsx b/src/domains/workspace/VideoCard.tsx index 09cdef4..13e2c32 100644 --- a/src/domains/workspace/VideoCard.tsx +++ b/src/domains/workspace/VideoCard.tsx @@ -2,15 +2,14 @@ import type { KeyboardEvent, MouseEvent } from 'react' import { Link } from 'react-router-dom' import ActionMenu from '../../components/ActionMenu' import BookmarkStarIcon from '../../components/icons/BookmarkStarIcon' -import Tag from '../../components/Tag' +import Tag, { type TagVariant } from '../../components/Tag' import { CARD_BASE } from '../../styles/card' -export type VideoCardProgressStatus = 'IN_PROGRESS' | 'DONE' | string - interface VideoCardProps { title: string thumbnailUrl?: string | null - progressStatus: VideoCardProgressStatus + statusLabel: string + statusVariant: TagVariant /** BE VideoItemResDTO.hasUnreadFeedback */ hasUnreadFeedback?: boolean bookmarked?: boolean @@ -26,7 +25,8 @@ interface VideoCardProps { export default function VideoCard({ title, thumbnailUrl, - progressStatus, + statusLabel, + statusVariant, hasUnreadFeedback = false, bookmarked = false, onToggleBookmark, @@ -99,9 +99,7 @@ export default function VideoCard({
- - {progressStatus === 'DONE' ? '완료' : '진행중'} - + {statusLabel}
{hasUnreadFeedback && ( void - statusMenuOpen: boolean - setStatusMenuOpen: (updater: (v: boolean) => boolean) => void - statusMenuRef: RefObject - changeVideoStatus: (status: 'IN_PROGRESS' | 'DONE') => void + projectStatus: ProjectStatus + onProjectStatusChange: (status: ProjectStatus) => void members: MemberSummary[] isAdmin: boolean meId: number | null @@ -32,10 +33,8 @@ export default function VideoDetailHeader({ projectId, videoDetail, toggleBookmark, - statusMenuOpen, - setStatusMenuOpen, - statusMenuRef, - changeVideoStatus, + projectStatus, + onProjectStatusChange, members, isAdmin, meId, @@ -44,6 +43,18 @@ export default function VideoDetailHeader({ onDelete, }: VideoDetailHeaderProps) { const [inviteOpen, setInviteOpen] = useState(false) + const [statusMenuOpen, setStatusMenuOpen] = useState(false) + const statusMenuRef = useRef(null) + const isCompleted = projectStatus === 'COMPLETED' + + useEffect(() => { + if (!statusMenuOpen) return + const closeMenu = (event: PointerEvent) => { + if (!statusMenuRef.current?.contains(event.target as Node)) setStatusMenuOpen(false) + } + document.addEventListener('pointerdown', closeMenu) + return () => document.removeEventListener('pointerdown', closeMenu) + }, [statusMenuOpen]) const headerLeftContent = useMemo(() => { if (!videoDetail) return null @@ -59,26 +70,38 @@ export default function VideoDetailHeader({ > - {/* 영상 진행 상태 변경 API가 없어 로컬 상태만 갱신 (changeVideoStatus 주석 참고) */}
- {statusMenuOpen && ( + {statusMenuOpen && !isCompleted && (
    - {(['IN_PROGRESS', 'DONE'] as const).map((status) => ( + {(Object.keys(PROJECT_STATUS_LABEL) as ProjectStatus[]).map((status) => (
  • ))} @@ -102,10 +125,10 @@ export default function VideoDetailHeader({ }, [ videoDetail, toggleBookmark, + projectStatus, + isCompleted, statusMenuOpen, - setStatusMenuOpen, - statusMenuRef, - changeVideoStatus, + onProjectStatusChange, members, projectId, isAdmin, diff --git a/src/domains/workspace/VideoDetailView.tsx b/src/domains/workspace/VideoDetailView.tsx index c7c92e5..bc2796f 100644 --- a/src/domains/workspace/VideoDetailView.tsx +++ b/src/domains/workspace/VideoDetailView.tsx @@ -15,7 +15,7 @@ import { useFeedbacks } from '../../hooks/useFeedbacks' import { useFeedbackReplies } from '../../hooks/useFeedbackReplies' import { useProjectMembersInvite } from '../../hooks/useProjectMembersInvite' import { ApiError } from '../../types/api' -import type { ProjectLengthType } from '../../types/project' +import type { ProjectLengthType, ProjectStatus } from '../../types/project' export type VideoDetailViewProps = { projectId: number @@ -23,6 +23,8 @@ export type VideoDetailViewProps = { meId: number | null isAdmin?: boolean lengthType: ProjectLengthType | null + projectStatus: ProjectStatus + onProjectStatusChange: (status: ProjectStatus) => void /** 이 프로젝트에서 내가 맡은 역할 — 프로젝트 소개글 태그 옆에 함께 표시 */ myRoleNames?: string[] onBack: () => void @@ -34,6 +36,8 @@ export function VideoDetailView({ meId, isAdmin = false, lengthType, + projectStatus, + onProjectStatusChange, myRoleNames = [], onBack, }: VideoDetailViewProps) { @@ -60,11 +64,7 @@ export function VideoDetailView({ const { videoDetail, load: loadVideoDetail, - statusMenuOpen, - setStatusMenuOpen, - statusMenuRef, toggleBookmark, - changeVideoStatus, confirmDeleteVideo, handleUpdateVideo, } = useVideoDetail(projectId, videoId, onBack) @@ -169,10 +169,8 @@ export function VideoDetailView({ projectId={projectId} videoDetail={videoDetail} toggleBookmark={toggleBookmark} - statusMenuOpen={statusMenuOpen} - setStatusMenuOpen={setStatusMenuOpen} - statusMenuRef={statusMenuRef} - changeVideoStatus={changeVideoStatus} + projectStatus={projectStatus} + onProjectStatusChange={onProjectStatusChange} members={members} isAdmin={isAdmin} meId={meId} diff --git a/src/domains/workspace/VideoFeedbackTab.tsx b/src/domains/workspace/VideoFeedbackTab.tsx index 5ef21da..e4918d9 100644 --- a/src/domains/workspace/VideoFeedbackTab.tsx +++ b/src/domains/workspace/VideoFeedbackTab.tsx @@ -15,6 +15,8 @@ import EditVideoModal from './EditVideoModal' import type { VideoListItem } from '../../types/video' import type { CreateVideoValues } from '../../schemas/video' import { invalidateProjectActivityData } from '../../queries/projectInvalidation' +import { projectStatusLabel } from '../../constants/projectStatus' +import type { ProjectStatus } from '../../types/project' /** 북마크한 영상을 목록 상단으로 */ function sortVideosByBookmark(items: VideoListItem[]): VideoListItem[] { @@ -33,9 +35,10 @@ type EditTarget = { type VideoFeedbackTabProps = { projectId: number + projectStatus: ProjectStatus } -export default function VideoFeedbackTab({ projectId }: VideoFeedbackTabProps) { +export default function VideoFeedbackTab({ projectId, projectStatus }: VideoFeedbackTabProps) { const queryClient = useQueryClient() const [videos, setVideos] = useState([]) const [videosLoading, setVideosLoading] = useState(true) @@ -162,7 +165,8 @@ export default function VideoFeedbackTab({ projectId }: VideoFeedbackTabProps) { key={video.videoId} title={video.title} thumbnailUrl={video.thumbnailUrl} - progressStatus={video.progressStatus} + statusLabel={projectStatusLabel(projectStatus)} + statusVariant={projectStatus === 'COMPLETED' ? 'ghost' : 'secondary'} hasUnreadFeedback={video.hasUnreadFeedback} bookmarked={video.bookmarked} onToggleBookmark={() => void handleToggleBookmark(video)} diff --git a/src/hooks/useVideoDetail.ts b/src/hooks/useVideoDetail.ts index 7bf4b25..5fdc078 100644 --- a/src/hooks/useVideoDetail.ts +++ b/src/hooks/useVideoDetail.ts @@ -1,29 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { deleteVideo, getVideo, updateVideo, updateVideoBookmark } from '../api/videos' -import type { VideoDetail, VideoProgressStatus } from '../types/video' +import type { VideoDetail } from '../types/video' /** 영상 상세 데이터 로딩 + 북마크·진행 상태·수정·삭제를 다루는 훅. * 로딩/에러는 호출부(부모)가 다른 병렬 로딩과 함께 묶어서 관리하므로 load()는 실패 시 그대로 던진다. */ export function useVideoDetail(projectId: number, videoId: number, onDeleted: () => void) { const [videoDetail, setVideoDetail] = useState(null) - const [statusMenuOpen, setStatusMenuOpen] = useState(false) - const statusMenuRef = useRef(null) - - useEffect(() => { - if (!statusMenuOpen) return - const handlePointerDown = (e: PointerEvent) => { - if (!statusMenuRef.current?.contains(e.target as Node)) setStatusMenuOpen(false) - } - // 유튜브 iframe 클릭은 별도 document라 pointerdown이 감지되지 않아 window blur로 보조 감지 - const handleWindowBlur = () => setStatusMenuOpen(false) - document.addEventListener('pointerdown', handlePointerDown) - window.addEventListener('blur', handleWindowBlur) - return () => { - document.removeEventListener('pointerdown', handlePointerDown) - window.removeEventListener('blur', handleWindowBlur) - } - }, [statusMenuOpen]) - const load = useCallback(async () => { const detail = await getVideo(projectId, videoId) setVideoDetail(detail) @@ -41,12 +23,6 @@ export function useVideoDetail(projectId: number, videoId: number, onDeleted: () }) }, [projectId, videoId]) - /** 영상 진행 상태 변경 — 백엔드에 상태 변경 API가 없어 로컬 상태만 갱신 (API 연동 전, 최근 활동 체크박스와 동일한 방식) */ - const changeVideoStatus = useCallback((status: VideoProgressStatus) => { - setStatusMenuOpen(false) - setVideoDetail((prev) => (prev ? { ...prev, progressStatus: status } : prev)) - }, []) - const confirmDeleteVideo = useCallback(async () => { await deleteVideo(projectId, videoId) onDeleted() @@ -73,11 +49,7 @@ export function useVideoDetail(projectId: number, videoId: number, onDeleted: () return { videoDetail, load, - statusMenuOpen, - setStatusMenuOpen, - statusMenuRef, toggleBookmark, - changeVideoStatus, confirmDeleteVideo, handleUpdateVideo, } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 92a7289..c9138f9 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -7,15 +7,16 @@ import { CONTENT_PX } from '../constants/layout' type MainLayoutProps = { children: ReactNode userName?: string + profileImageUrl?: string | null } -export default function MainLayout({ children, userName }: MainLayoutProps) { +export default function MainLayout({ children, userName, profileImageUrl }: MainLayoutProps) { return (
    -
    +
    {children}
    diff --git a/src/pages/ProjectDetailPage.tsx b/src/pages/ProjectDetailPage.tsx index 0fc2832..9a489d6 100644 --- a/src/pages/ProjectDetailPage.tsx +++ b/src/pages/ProjectDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient, type InfiniteData } from '@tanstack/react-query' import { useLocation, useNavigate } from 'react-router-dom' import { getMe } from '../api/users' @@ -26,6 +26,7 @@ import ProjectFileList from '../domains/workspace/ProjectFileList' import { ProjectScheduleTab } from '../domains/workspace/ProjectScheduleTab' import ProjectStatusMenu from '../domains/workspace/ProjectStatusMenu' import { useProjectDetail } from '../hooks/useProjectDetail' +import { useProjectStatusMenu } from '../hooks/useProjectStatusMenu' import { useHeaderSlot } from '../hooks/useHeaderSlot' import type { ProjectActivity, ProjectListResponse } from '../types/project' @@ -144,6 +145,14 @@ export default function ProjectDetailPage({ projectId, videoId = null }: Project ) const [initialFileId, setInitialFileId] = useState(null) const [membersPanelOpen, setMembersPanelOpen] = useState(false) + const [videoCompletionConfirmOpen, setVideoCompletionConfirmOpen] = useState(false) + const videoProjectStatusRef = useRef(null) + const videoProjectStatusMenu = useProjectStatusMenu( + projectId, + project, + setProject, + videoProjectStatusRef, + ) useEffect(() => { getMe() @@ -351,15 +360,37 @@ export default function ProjectDetailPage({ projectId, videoId = null }: Project if (videoId != null) { return ( - + <> + { + if (status === project.status) return + if (status === 'COMPLETED') { + setVideoCompletionConfirmOpen(true) + return + } + void videoProjectStatusMenu.changeStatus(status) + }} + myRoleNames={project.roleNames} + onBack={closeVideo} + /> + setVideoCompletionConfirmOpen(false)} + onConfirm={() => { + setVideoCompletionConfirmOpen(false) + void videoProjectStatusMenu.changeStatus('COMPLETED') + }} + title="완료로 전환하면 참여자들의 포트폴리오에 자동으로 추가됩니다." + description="각자의 프로필 페이지에서 수정 및 삭제가 가능합니다. 완료로 변경하면 진행 상황을 수정할 수 없습니다." + confirmText="확인" + /> + ) } @@ -464,7 +495,9 @@ export default function ProjectDetailPage({ projectId, videoId = null }: Project /> )} - {tab === 'feedback' && } + {tab === 'feedback' && ( + + )}