Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -180,7 +181,7 @@ function AppShell() {
}

return (
<MainLayout userName={userName}>
<MainLayout userName={userName} profileImageUrl={profileImageUrl}>
<RouteLoadingBoundary key={pathname}>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
Expand Down
6 changes: 4 additions & 2 deletions src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ export default function ConfirmModal({
return (
<Modal isOpen={isOpen} onClose={onClose}>
<div className="w-80 text-center">
<h2 className="text-head-sm text-neutral-11 font-semibold">{title}</h2>
{description && <p className="text-body-sm text-neutral-6 mt-2">{description}</p>}
<h2 className="text-head-sm text-neutral-11 font-semibold break-keep">{title}</h2>
{description && (
<p className="text-body-sm text-neutral-6 mt-2 break-keep">{description}</p>
)}
<div className="mt-6 flex gap-2">
<Button variant="primary" size="md" className="flex-1" onClick={onConfirm}>
{confirmText}
Expand Down
5 changes: 3 additions & 2 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import HeaderProfileMenu from './HeaderProfileMenu'
import { CONTENT_PX } from '../constants/layout'
type HeaderProps = {
userName?: string
profileImageUrl?: string | null
}

function BellIcon() {
Expand All @@ -16,7 +17,7 @@ function BellIcon() {
)
}

export default function Header({ userName = '000' }: HeaderProps) {
export default function Header({ userName = '000', profileImageUrl }: HeaderProps) {
const slot = useContext(HeaderSlotContentContext)
const headerLeft = slot?.headerLeft ?? null
const headerRight = slot?.headerRight ?? null
Expand All @@ -43,7 +44,7 @@ export default function Header({ userName = '000' }: HeaderProps) {
</button>

{/* 아바타 (클릭 시 나의 구인구직/마이페이지/설정 드롭다운) */}
<HeaderProfileMenu userName={userName} />
<HeaderProfileMenu userName={userName} profileImageUrl={profileImageUrl} />
</>
)}

Expand Down
16 changes: 13 additions & 3 deletions src/components/HeaderProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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<HTMLDivElement>(null)
const triggerId = useId()
Expand Down Expand Up @@ -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)}
<Avatar
src={profileImageUrl ?? undefined}
size={40}
fallback={
<span className="bg-neutral-3 text-caption-sm text-neutral-7 flex h-full w-full items-center justify-center font-medium">
{userName.charAt(0)}
</span>
}
/>
</button>

{open && (
Expand Down
4 changes: 2 additions & 2 deletions src/domains/workspace/ProjectStatusMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ export default function ProjectStatusMenu({
className="w-[calc(100vw-32px)] max-w-[780px]"
>
<div className="flex flex-col items-center px-6 py-6 text-center sm:px-16 sm:py-8">
<h2 className="text-head-lg text-neutral-11 max-w-[560px] font-bold">
<h2 className="text-head-lg text-neutral-11 max-w-[560px] font-bold break-keep">
완료로 전환하면 참여자들의 포트폴리오에 자동으로 추가됩니다.
</h2>
<div className="text-body-sm text-neutral-6 mt-5 flex flex-col gap-1">
<div className="text-body-sm text-neutral-6 mt-5 flex flex-col gap-1 break-keep">
<p>각자의 프로필 페이지에서 수정 삭제가 가능합니다.</p>
<p>완료로 변경 시 진행 상황 변경이 불가합니다.</p>
</div>
Expand Down
14 changes: 6 additions & 8 deletions src/domains/workspace/VideoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,7 +25,8 @@ interface VideoCardProps {
export default function VideoCard({
title,
thumbnailUrl,
progressStatus,
statusLabel,
statusVariant,
hasUnreadFeedback = false,
bookmarked = false,
onToggleBookmark,
Expand Down Expand Up @@ -99,9 +99,7 @@ export default function VideoCard({

<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Tag variant={progressStatus === 'DONE' ? 'ghost' : 'secondary'}>
{progressStatus === 'DONE' ? '완료' : '진행중'}
</Tag>
<Tag variant={statusVariant}>{statusLabel}</Tag>
</div>
{hasUnreadFeedback && (
<span
Expand Down
69 changes: 46 additions & 23 deletions src/domains/workspace/VideoDetailHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import { useMemo, useState } from 'react'
import type { RefObject } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ActionMenu from '../../components/ActionMenu'
import { Button } from '../../components/Button'
import InlineIcon from '../../components/InlineIcon'
import BookmarkStarIcon from '../../components/icons/BookmarkStarIcon'
import { useHeaderSlot } from '../../hooks/useHeaderSlot'
import type { VideoDetail } from '../../types/video'
import type { MemberSummary } from '../../types/project'
import chevronDownIcon from '../../assets/icons/chevron-down.svg?raw'
import {
PROJECT_STATUS_LABEL,
projectStatusColor,
projectStatusLabel,
} from '../../constants/projectStatus'
import type { ProjectStatus } from '../../types/project'
import MemberListPanel from './MemberListPanel'
import ShareLinkModal from './ShareLinkModal'

type VideoDetailHeaderProps = {
projectId: number
videoDetail: VideoDetail | null
toggleBookmark: () => void
statusMenuOpen: boolean
setStatusMenuOpen: (updater: (v: boolean) => boolean) => void
statusMenuRef: RefObject<HTMLDivElement | null>
changeVideoStatus: (status: 'IN_PROGRESS' | 'DONE') => void
projectStatus: ProjectStatus
onProjectStatusChange: (status: ProjectStatus) => void
members: MemberSummary[]
isAdmin: boolean
meId: number | null
Expand All @@ -32,10 +33,8 @@ export default function VideoDetailHeader({
projectId,
videoDetail,
toggleBookmark,
statusMenuOpen,
setStatusMenuOpen,
statusMenuRef,
changeVideoStatus,
projectStatus,
onProjectStatusChange,
members,
isAdmin,
meId,
Expand All @@ -44,6 +43,18 @@ export default function VideoDetailHeader({
onDelete,
}: VideoDetailHeaderProps) {
const [inviteOpen, setInviteOpen] = useState(false)
const [statusMenuOpen, setStatusMenuOpen] = useState(false)
const statusMenuRef = useRef<HTMLDivElement>(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
Expand All @@ -59,26 +70,38 @@ export default function VideoDetailHeader({
>
<BookmarkStarIcon filled={videoDetail.bookmarked} className="size-4" />
</button>
{/* 영상 진행 상태 변경 API가 없어 로컬 상태만 갱신 (changeVideoStatus 주석 참고) */}
<div className="relative" ref={statusMenuRef}>
<button
type="button"
onClick={() => setStatusMenuOpen((v) => !v)}
className={`text-caption-sm flex items-center gap-1 rounded-[3px] px-[19px] py-1 font-semibold ${videoDetail.progressStatus === 'DONE' ? 'bg-tag-done-bg text-tag-done-text' : 'bg-tag-active-bg text-tag-active-text'}`}
disabled={isCompleted}
title={isCompleted ? '완료된 프로젝트는 진행 상황을 변경할 수 없습니다.' : undefined}
className={`text-caption-sm flex items-center gap-1 rounded-[3px] px-[19px] py-1 font-semibold disabled:cursor-not-allowed ${projectStatusColor(projectStatus)}`}
>
{videoDetail.progressStatus === 'DONE' ? '완료' : '진행중'}
<InlineIcon svg={chevronDownIcon} className="size-3" />
{projectStatusLabel(projectStatus)}
<svg viewBox="0 0 12 12" fill="none" className="size-3">
<path
d="M2.5 4.5L6 8l3.5-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{statusMenuOpen && (
{statusMenuOpen && !isCompleted && (
<ul className="border-neutral-3 bg-bg-primary absolute top-full left-0 z-10 mt-1 w-24 rounded-lg border py-1 shadow-md">
{(['IN_PROGRESS', 'DONE'] as const).map((status) => (
{(Object.keys(PROJECT_STATUS_LABEL) as ProjectStatus[]).map((status) => (
<li key={status}>
<button
type="button"
onClick={() => changeVideoStatus(status)}
onClick={() => {
setStatusMenuOpen(false)
onProjectStatusChange(status)
}}
className="hover:bg-neutral-2 text-caption-lg text-neutral-10 block w-full px-3 py-2 text-left"
>
{status === 'DONE' ? '완료' : '진행중'}
{projectStatusLabel(status)}
</button>
</li>
))}
Expand All @@ -102,10 +125,10 @@ export default function VideoDetailHeader({
}, [
videoDetail,
toggleBookmark,
projectStatus,
isCompleted,
statusMenuOpen,
setStatusMenuOpen,
statusMenuRef,
changeVideoStatus,
onProjectStatusChange,
members,
projectId,
isAdmin,
Expand Down
16 changes: 7 additions & 9 deletions src/domains/workspace/VideoDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ 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
videoId: number
meId: number | null
isAdmin?: boolean
lengthType: ProjectLengthType | null
projectStatus: ProjectStatus
onProjectStatusChange: (status: ProjectStatus) => void
/** 이 프로젝트에서 내가 맡은 역할 — 프로젝트 소개글 태그 옆에 함께 표시 */
myRoleNames?: string[]
onBack: () => void
Expand All @@ -34,6 +36,8 @@ export function VideoDetailView({
meId,
isAdmin = false,
lengthType,
projectStatus,
onProjectStatusChange,
myRoleNames = [],
onBack,
}: VideoDetailViewProps) {
Expand All @@ -60,11 +64,7 @@ export function VideoDetailView({
const {
videoDetail,
load: loadVideoDetail,
statusMenuOpen,
setStatusMenuOpen,
statusMenuRef,
toggleBookmark,
changeVideoStatus,
confirmDeleteVideo,
handleUpdateVideo,
} = useVideoDetail(projectId, videoId, onBack)
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 6 additions & 2 deletions src/domains/workspace/VideoFeedbackTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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<VideoListItem[]>([])
const [videosLoading, setVideosLoading] = useState(true)
Expand Down Expand Up @@ -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)}
Expand Down
Loading
Loading