Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9ece139
#29 [FEAT] 수정 요청 페이지 동적 라우팅 및 사이드바 활성화 유지
waldls Jun 8, 2026
23b1601
#29 [FEAT] 썸네일 컴포넌트 구현
waldls Jun 8, 2026
ef82bf5
#29 [FEAT] 수정 요청 초기화면 퍼블리싱
waldls Jun 8, 2026
d0f23b9
#29 [FEAT] 최종 시안 선택 모달 연결
waldls Jun 8, 2026
9206491
#29 [FEAT] 선택한 카테고리에 맞는 인풋 섹션 생기도록 구현
waldls Jun 8, 2026
be0422c
#29 [FEAT] 코멘트 카드 컴포넌트 구현
waldls Jun 8, 2026
a12a814
#29 [REFACTOR] 썸네일 컴포넌트 W/H props로 크기 제어
waldls Jun 8, 2026
6d720f6
#29 [REFACTOR] 썸네일 컴포넌트 className으로 크기 제어하도록 수정
waldls Jun 8, 2026
6074b18
#29 [FEAT] mockData 기반 구조 수정 및 잔여 횟수 0회일 때 렌더링 처리
waldls Jun 8, 2026
a026c3c
#29 [FEAT] 디자이너 코멘트 유무에 따른 레이아웃 분기 처리
waldls Jun 8, 2026
725e6d0
#29 [MOVE] Modal 컴포넌트 modal 폴더 아래로 이동
waldls Jun 8, 2026
447f1aa
#29 [FEAT] 시안 확인 모달 구현
waldls Jun 8, 2026
bf87839
#29 [REFACTOR] 시안 모달 API 명세에 맞게 변경
waldls Jun 8, 2026
ce4395a
#29 [REFACTOR] 시안 모달에 실제 시안 제목/이미지 목록 연결
waldls Jun 8, 2026
bb70826
#29 [FEAT] 시안 모달 이미지 우클릭 및 드래그 저장 방지 처리
waldls Jun 8, 2026
29edd1d
#29 [CHORE] mockData 수정
waldls Jun 8, 2026
9ee457e
#29 [FIX] 시안 모달 이미지 sizes 속성 추가
waldls Jun 8, 2026
f1e4e79
#29 [REFACTOR] 불필요한 overlay-button 컬러 토큰 제거 후 bg-white/18로 대체
waldls Jun 8, 2026
b4a7a05
#29 [REFACTOR] 시안 모달 접근성 보완 (role/aria-label 추가)
waldls Jun 8, 2026
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
Binary file added public/images/thumbnail_mock.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
--color-blue-bright: #e0eeff;
--color-blue-main: #0248c2;

/* Overlay */
--color-overlay-hover: rgba(86, 75, 96, 0.58);

/* =========================
Typography
========================= */
Expand Down Expand Up @@ -175,6 +178,12 @@
========================= */
--shadow-dropdown: 0 0 20px 4px rgba(0, 0, 0, 0.05);

/* =========================
Blur Tokens
========================= */
--blur-hover: 1.7px;
--blur-button: 2px;

/* =========================
Radius Tokens
========================= */
Expand All @@ -185,6 +194,7 @@
--radius-12: 12px;
--radius-14: 14px;
--radius-20: 20px;
--radius-24: 24px;
--radius-32: 32px;
--radius-48: 48px;
--radius-100: 100px;
Expand Down
2 changes: 1 addition & 1 deletion src/app/instructor/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const InstructorLayout = ({ children }: { children: ReactNode }) => {
<div className="bg-gray-10 flex flex-1 overflow-hidden">
<Sidebar bottom={<SidebarMenu label="로그아웃" />}>
<SidebarMenu label="새 외주 작성" href="/instructor/write" />
<SidebarMenu label="진행 중 외주" href="/instructor" />
<SidebarMenu label="진행 중 외주" href="/instructor" matchPrefix="/instructor/revision" />
<SidebarMenu label="마이페이지" href="/instructor/my" />
</Sidebar>
<main className="scrollbar-hide flex flex-1 flex-col overflow-y-auto">{children}</main>
Expand Down
108 changes: 108 additions & 0 deletions src/app/instructor/revision/[commissionId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

import Button from "@/shared/ui/Button";
import Modal from "@/shared/ui/modal/Modal";
import { RevisionCategorySection, RevisionCommentSection } from "@/widgets/instructor/revision";
import { MAX_SELECTABLE_COUNT } from "@/widgets/instructor/revision/config/revision";
import {
draftFilesData,
draftRevisionDetailData,
} from "@/widgets/instructor/revision/model/revision";

const Page = () => {
const router = useRouter();
const { commissionId } = useParams<{ commissionId: string }>();
const draftRevisionDetail = draftRevisionDetailData.find(
detail => detail.commissionId === Number(commissionId),
);
const draftFiles = draftFilesData.find(files => files.commissionId === Number(commissionId));
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [comments, setComments] = useState<Record<string, string>>({});
const [isFinalizeModalOpen, setIsFinalizeModalOpen] = useState(false);

const isFinalizeActive = selectedCategories.length === 0;
const isSubmitActive =
selectedCategories.length > 0 &&
selectedCategories.every(category => (comments[category] ?? "").trim().length > 0);

const handleToggleCategory = (category: string) => {
setSelectedCategories(prev => {
if (prev.includes(category)) {
return prev.filter(selected => selected !== category);
}
if (prev.length >= MAX_SELECTABLE_COUNT) {
return prev;
}
return [...prev, category];
});
};

const handleChangeComment = (category: string, value: string) => {
setComments(prev => ({ ...prev, [category]: value }));
};

const handleCloseFinalizeModal = () => {
setIsFinalizeModalOpen(false);
};

const handleConfirmFinalize = () => {
setIsFinalizeModalOpen(false);
router.push("/instructor");
};

if (draftRevisionDetail == null) {
return null;
}

return (
<div className="mx-auto flex w-235 flex-col items-center pt-16 pb-19.5">
<h1 className="text-title2-sb w-full py-4 pb-8 text-left text-black">
{draftRevisionDetail.title}
</h1>
<div className="flex flex-col gap-10">
<RevisionCategorySection
draftTitle={draftRevisionDetail.title}
designerComment={draftRevisionDetail.currentDraft.designerComment}
remainingRevisionCount={draftRevisionDetail.remainingRevisionCount}
maxRevisionCount={draftRevisionDetail.maxRevisionCount}
selectedCategories={selectedCategories}
onToggleCategory={handleToggleCategory}
fileUrls={draftFiles?.fileUrls ?? []}
/>
<RevisionCommentSection
comments={comments}
selectedCategories={selectedCategories}
onChangeComment={handleChangeComment}
/>
<div className="flex w-full flex-row justify-end gap-4">
<Button
className="w-fit"
variant={isFinalizeActive ? "medium_primary" : "medium_disabled"}
onClick={isFinalizeActive ? () => setIsFinalizeModalOpen(true) : undefined}
>
최종 시안으로 선택하기
</Button>
<Button className="w-fit" variant={isSubmitActive ? "medium_primary" : "medium_disabled"}>
수정사항 전달하기
</Button>
Comment on lines +88 to +90

@coderabbitai coderabbitai Bot Jun 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

"수정사항 전달하기" 버튼에 onClick 핸들러가 누락되었습니다.

isSubmitActive 로직으로 버튼 활성화 상태는 제어하고 있지만, 실제로 수정사항을 제출하는 onClick 핸들러가 구현되지 않았습니다. 사용자가 카테고리를 선택하고 코멘트를 입력한 후 이 버튼을 클릭해도 아무 동작이 발생하지 않습니다.

🔧 onClick 핸들러 추가 예시
+  const handleSubmitRevision = () => {
+    // TODO: API 호출로 수정사항 전달
+    console.log('Submit revision:', { selectedCategories, comments });
+  };

   <Button 
     className="w-fit" 
-    variant={isSubmitActive ? "medium_primary" : "medium_disabled"}
+    variant={isSubmitActive ? "medium_primary" : "medium_disabled"}
+    onClick={isSubmitActive ? handleSubmitRevision : undefined}
   >
     수정사항 전달하기
   </Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/instructor/revision/`[commissionId]/page.tsx around lines 88 - 90,
The Button currently rendering "수정사항 전달하기" lacks an onClick handler; add a
handler (e.g., handleSubmit or onSubmitRevisions) in the same component and
attach it to the Button so clicks do something, have the handler first check
isSubmitActive, gather the selected category state and comment input state used
in this page, call the existing submission API/function (or create one like
submitRevision) and handle loading/success/error states (disable button or show
spinner while submitting and show feedback on result). Ensure you reference the
Button element and isSubmitActive when wiring the onClick and implement the
handler near the component logic so it can access selectedCategories/comment
state and any API helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 로직 연결 예정

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@waldls, 이해했습니다. 나중에 API 연결 시 isSubmitActive 조건 체크와 함께 핸들러를 붙여주시면 됩니다.

추후 작업 추적을 위해 GitHub 이슈를 생성해 드릴까요?


🧠 Learnings used
Learnt from: waldls
Repo: Ditda-Official/Ditda-Frontend PR: 28
File: src/widgets/instructor/my/model/my.ts:14-17
Timestamp: 2026-06-07T17:37:21.449Z
Learning: In Ditda-Frontend, `myInfoData` in `src/widgets/instructor/my/model/my.ts` and `commissionHistoryData` in `src/features/instructor/my/model/my.ts` are independent mock/dummy data objects. Their stat fields (e.g., `totalCommissionCount`, `ongoingCommissionCount`) do not need to match the actual length or contents of the commission history mock array — they are placeholder values only.

Learnt from: waldls
Repo: Ditda-Official/Ditda-Frontend PR: 18
File: src/constants/write.ts:65-82
Timestamp: 2026-05-27T08:26:49.054Z
Learning: In Ditda-Frontend (TypeScript/Next.js), exported constants should be named in SCREAMING_SNAKE_CASE (e.g., BASIC_INFO_FIELDS, PAGE_OPTIONS). The camelCase naming guideline should apply to functions and non-exported/regular variables, but not to exported constants.

</div>
</div>
<Modal
isOpen={isFinalizeModalOpen}
type="double"
title={"최종 시안으로 선택하시겠습니까?"}
description={"현재 디자인을 최종시안으로 선택하시면 \n더 이상 수정을 요청할 수 없습니다."}
confirmLabel="확인"
cancelLabel="취소"
onConfirm={handleConfirmFinalize}
onCancel={handleCloseFinalizeModal}
onClose={handleCloseFinalizeModal}
/>
</div>
);
};

export default Page;
2 changes: 1 addition & 1 deletion src/app/instructor/write/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

import { useWriteFormStore } from "@/features/instructor/write";
import Modal from "@/shared/ui/Modal";
import Modal from "@/shared/ui/modal/Modal";
import { Step1Content, Step2Content, Step3Content } from "@/widgets/instructor/write";

const WritePageContent = () => {
Expand Down
2 changes: 1 addition & 1 deletion src/features/instructor/home/model/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const modifyingStatusData: ModifyingItem[] = [
{
commissionId: 45,
title: "한국사 능력검정 — 중급 핵심 요약",
isSubmitted: true,
isSubmitted: false,
hasUpdate: true,
finalDeadline: "2026-07-10",
},
Expand Down
11 changes: 9 additions & 2 deletions src/features/instructor/home/ui/ModifyingCommissionsRow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useRouter } from "next/navigation";

import { getDDay } from "@/features/instructor/home/lib/getDDay";
import { ModifyingItem } from "@/features/instructor/home/model/home";
import { cn } from "@/shared/lib/utils/cn";
import Button from "@/shared/ui/Button";
import Tag from "@/shared/ui/Tag";

const ModifyingCommissionsRow = ({ item }: { item: ModifyingItem }) => {
const { title, finalDeadline, isSubmitted, hasUpdate } = item;
const router = useRouter();
const { commissionId, title, finalDeadline, isSubmitted, hasUpdate } = item;

return (
<div className="border-b-gray-10 hover:bg-gray-5 flex h-15 cursor-pointer items-center border-b py-3 transition-colors duration-150">
Expand All @@ -20,7 +23,11 @@ const ModifyingCommissionsRow = ({ item }: { item: ModifyingItem }) => {
{isSubmitted ? (
<button className="text-body1-m text-purple-70 w-20">전송완료</button>
) : (
<Button variant="small_secondary" className="w-fit">
<Button
variant="small_secondary"
className="w-fit"
onClick={() => router.push(`/instructor/revision/${commissionId}`)}
>
확인하기
</Button>
)}
Expand Down
75 changes: 75 additions & 0 deletions src/shared/lib/hooks/useDragScrollbar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";

interface UseDragScrollbarProps {
scrollRef: RefObject<HTMLElement | null>;
}

export const useDragScrollbar = ({ scrollRef }: UseDragScrollbarProps) => {
const trackRef = useRef<HTMLDivElement>(null);
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);

useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || isDragging) return;

const syncProgress = () => {
const max = scrollEl.scrollWidth - scrollEl.clientWidth;
setProgress(max > 0 ? scrollEl.scrollLeft / max : 0);
};

syncProgress();
scrollEl.addEventListener("scroll", syncProgress);
return () => scrollEl.removeEventListener("scroll", syncProgress);
}, [scrollRef, isDragging]);

useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;

const handleWheel = (e: WheelEvent) => {
e.preventDefault();
scrollEl.scrollLeft += e.deltaY;
};

scrollEl.addEventListener("wheel", handleWheel, { passive: false });
return () => scrollEl.removeEventListener("wheel", handleWheel);
}, [scrollRef]);

const moveToClientX = useCallback(
(clientX: number) => {
const track = trackRef.current;
const scrollEl = scrollRef.current;
if (!track || !scrollEl) return;

const { left, width } = track.getBoundingClientRect();
const ratio = Math.min(Math.max((clientX - left) / width, 0), 1);
const max = scrollEl.scrollWidth - scrollEl.clientWidth;

setProgress(ratio);
scrollEl.scrollLeft = ratio * max;
},
[scrollRef],
);

useEffect(() => {
if (!isDragging) return;

const handleMouseMove = (e: MouseEvent) => moveToClientX(e.clientX);
const handleMouseUp = () => setIsDragging(false);

document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, moveToClientX]);

const handleTrackMouseDown = (clientX: number) => {
setIsDragging(true);
moveToClientX(clientX);
};

return { trackRef, progress, handleTrackMouseDown };
};
15 changes: 15 additions & 0 deletions src/shared/ui/CommentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
interface CommentCardProps {
title: string;
comment: string;
}

const CommentCard = ({ title, comment }: CommentCardProps) => {
return (
<div className="bg-purple-5 border-purple-10 rounded-12 flex w-full flex-col gap-2.5 border px-6 py-4">
<h1 className="text-main-main text-body1-sb">{title}</h1>
<h2 className="text-gray-80 text-body2-r scrollbar-hide h-16.5 overflow-y-auto">{comment}</h2>
</div>
);
};

export default CommentCard;
34 changes: 34 additions & 0 deletions src/shared/ui/DragScrollbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import type { RefObject } from "react";

import { useDragScrollbar } from "@/shared/lib/hooks/useDragScrollbar";
import { cn } from "@/shared/lib/utils/cn";

interface DragScrollbarProps {
scrollRef: RefObject<HTMLElement | null>;
className?: string;
}

const DragScrollbar = ({ scrollRef, className }: DragScrollbarProps) => {
const { trackRef, progress, handleTrackMouseDown } = useDragScrollbar({ scrollRef });
const percent = `${progress * 100}%`;

return (
<div className={cn("flex h-12 items-center", className)}>
<div
ref={trackRef}
className="bg-purple-10 relative h-2 w-full cursor-pointer"
onMouseDown={e => handleTrackMouseDown(e.clientX)}
>
<div className="bg-purple-30 absolute inset-y-0 left-0" style={{ width: percent }} />
<div
className="rounded-48 border-main-main absolute top-1/2 size-12 -translate-x-1/2 -translate-y-1/2 cursor-grab border-4 bg-white active:cursor-grabbing"
style={{ left: percent }}
/>
</div>
</div>
);
Comment on lines +17 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

접근성 개선 필요: 키보드 탐색 및 스크린리더 지원

커스텀 스크롤바가 마우스 인터랙션만 지원하고 키보드 접근성과 스크린리더 지원이 없습니다:

  • 스크롤바 트랙과 썸에 적절한 ARIA role이 없습니다 (예: role="scrollbar", aria-valuemin, aria-valuemax, aria-valuenow)
  • 키보드로 포커스하고 조작할 수 없습니다 (화살표 키로 스크롤 조정)
  • 스크린리더 사용자가 현재 스크롤 위치를 알 수 없습니다
♿ 접근성 개선 제안
  return (
    <div className={cn("flex h-12 items-center", className)}>
      <div
        ref={trackRef}
        className="bg-purple-10 relative h-2 w-full cursor-pointer"
        onMouseDown={e => handleTrackMouseDown(e.clientX)}
+       role="scrollbar"
+       aria-orientation="horizontal"
+       aria-valuemin={0}
+       aria-valuemax={100}
+       aria-valuenow={Math.round(progress * 100)}
+       aria-label="수평 스크롤"
+       tabIndex={0}
+       onKeyDown={e => {
+         // 화살표 키로 스크롤 조정 로직 추가
+       }}
      >
        <div className="bg-purple-30 absolute inset-y-0 left-0" style={{ width: percent }} />
        <div
          className="rounded-48 border-main-main absolute top-1/2 size-12 -translate-x-1/2 -translate-y-1/2 cursor-grab border-4 bg-white active:cursor-grabbing"
          style={{ left: percent }}
+         aria-hidden="true"
        />
      </div>
    </div>
  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/ui/DragScrollbar.tsx` around lines 17 - 31, The custom scrollbar
only supports mouse input; add keyboard and screen-reader support by making the
thumb focusable (add a thumbRef and tabindex=0 on the thumb element) and giving
the track or thumb appropriate ARIA attributes (e.g., role="scrollbar" or
role="slider", aria-valuemin="0", aria-valuemax="100",
aria-valuenow={numericPercent}, and aria-label or aria-valuetext). Implement a
key handler (e.g., handleThumbKeyDown) on the thumb to respond to
ArrowLeft/ArrowRight (and optionally Home/End/PageUp/PageDown) to update percent
and call the same update logic used by handleTrackMouseDown; ensure percent is
kept as a numeric value for aria-valuenow and converted to a string for style
left/width, and ensure trackRef/handleTrackMouseDown logic updates the thumb
position and aria attributes consistently.

};

export default DragScrollbar;
7 changes: 5 additions & 2 deletions src/shared/ui/SidebarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import { SIDEBAR_ICON_MAP } from "@/shared/config/sidebarMenu";
interface SidebarMenuProps {
label: string;
href?: string;
matchPrefix?: string;
}

const SidebarMenu = ({ label, href }: SidebarMenuProps) => {
const SidebarMenu = ({ label, href, matchPrefix }: SidebarMenuProps) => {
const pathname = usePathname();
const [isHovered, setIsHovered] = useState(false);

const isSelected = href ? pathname === href : false;
const isSelected = href
? pathname === href || (!!matchPrefix && pathname.startsWith(matchPrefix))
: false;
const isActive = isSelected || isHovered;
const icons = SIDEBAR_ICON_MAP[label];

Expand Down
40 changes: 40 additions & 0 deletions src/shared/ui/Thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Image from "next/image";

import { SearchIcon } from "@/shared/assets/icons";
import { cn } from "@/shared/lib/utils/cn";

interface ThumbnailProps {
src?: string;
alt?: string;
className?: string;
onDetailClick?: () => void;
}

const Thumbnail = ({
src = "/images/thumbnail_mock.jpg",
alt = "썸네일",
className,
onDetailClick,
}: ThumbnailProps) => {
return (
<div
className={cn(
"rounded-12 bg-gray-20 group relative h-full w-full overflow-hidden",
className,
)}
>
<Image src={src} alt={alt} fill sizes="250px" loading="eager" className="object-cover" />
<div className="bg-overlay-hover backdrop-blur-hover absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100" />
<button
type="button"
onClick={onDetailClick}
className="backdrop-blur-button text-body2-m rounded-12 absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-row items-center gap-2.5 bg-white/18 px-4 py-2 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
자세히 보기
<SearchIcon className="size-5" />
</button>
</div>
);
};

export default Thumbnail;
Loading
Loading