diff --git a/public/images/thumbnail_mock.jpg b/public/images/thumbnail_mock.jpg new file mode 100644 index 0000000..d498c62 Binary files /dev/null and b/public/images/thumbnail_mock.jpg differ diff --git a/src/app/globals.css b/src/app/globals.css index 1a297dc..74e875b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -57,6 +57,9 @@ --color-blue-bright: #e0eeff; --color-blue-main: #0248c2; + /* Overlay */ + --color-overlay-hover: rgba(86, 75, 96, 0.58); + /* ========================= Typography ========================= */ @@ -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 ========================= */ @@ -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; diff --git a/src/app/instructor/layout.tsx b/src/app/instructor/layout.tsx index 9b6cafe..18d287f 100644 --- a/src/app/instructor/layout.tsx +++ b/src/app/instructor/layout.tsx @@ -11,7 +11,7 @@ const InstructorLayout = ({ children }: { children: ReactNode }) => {
}> - +
{children}
diff --git a/src/app/instructor/revision/[commissionId]/page.tsx b/src/app/instructor/revision/[commissionId]/page.tsx new file mode 100644 index 0000000..1a39457 --- /dev/null +++ b/src/app/instructor/revision/[commissionId]/page.tsx @@ -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([]); + const [comments, setComments] = useState>({}); + 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 ( +
+

+ {draftRevisionDetail.title} +

+
+ + +
+ + +
+
+ +
+ ); +}; + +export default Page; diff --git a/src/app/instructor/write/page.tsx b/src/app/instructor/write/page.tsx index 1db6de1..073b2ea 100644 --- a/src/app/instructor/write/page.tsx +++ b/src/app/instructor/write/page.tsx @@ -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 = () => { diff --git a/src/features/instructor/home/model/home.ts b/src/features/instructor/home/model/home.ts index ee02c72..f2e5116 100644 --- a/src/features/instructor/home/model/home.ts +++ b/src/features/instructor/home/model/home.ts @@ -130,7 +130,7 @@ export const modifyingStatusData: ModifyingItem[] = [ { commissionId: 45, title: "한국사 능력검정 — 중급 핵심 요약", - isSubmitted: true, + isSubmitted: false, hasUpdate: true, finalDeadline: "2026-07-10", }, diff --git a/src/features/instructor/home/ui/ModifyingCommissionsRow.tsx b/src/features/instructor/home/ui/ModifyingCommissionsRow.tsx index af58fb7..9573f50 100644 --- a/src/features/instructor/home/ui/ModifyingCommissionsRow.tsx +++ b/src/features/instructor/home/ui/ModifyingCommissionsRow.tsx @@ -1,3 +1,5 @@ +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"; @@ -5,7 +7,8 @@ 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 (
@@ -20,7 +23,11 @@ const ModifyingCommissionsRow = ({ item }: { item: ModifyingItem }) => { {isSubmitted ? ( ) : ( - )} diff --git a/src/shared/lib/hooks/useDragScrollbar.ts b/src/shared/lib/hooks/useDragScrollbar.ts new file mode 100644 index 0000000..bacdde3 --- /dev/null +++ b/src/shared/lib/hooks/useDragScrollbar.ts @@ -0,0 +1,75 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; + +interface UseDragScrollbarProps { + scrollRef: RefObject; +} + +export const useDragScrollbar = ({ scrollRef }: UseDragScrollbarProps) => { + const trackRef = useRef(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 }; +}; diff --git a/src/shared/ui/CommentCard.tsx b/src/shared/ui/CommentCard.tsx new file mode 100644 index 0000000..f3b6f7c --- /dev/null +++ b/src/shared/ui/CommentCard.tsx @@ -0,0 +1,15 @@ +interface CommentCardProps { + title: string; + comment: string; +} + +const CommentCard = ({ title, comment }: CommentCardProps) => { + return ( +
+

{title}

+

{comment}

+
+ ); +}; + +export default CommentCard; diff --git a/src/shared/ui/DragScrollbar.tsx b/src/shared/ui/DragScrollbar.tsx new file mode 100644 index 0000000..b29f010 --- /dev/null +++ b/src/shared/ui/DragScrollbar.tsx @@ -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; + className?: string; +} + +const DragScrollbar = ({ scrollRef, className }: DragScrollbarProps) => { + const { trackRef, progress, handleTrackMouseDown } = useDragScrollbar({ scrollRef }); + const percent = `${progress * 100}%`; + + return ( +
+
handleTrackMouseDown(e.clientX)} + > +
+
+
+
+ ); +}; + +export default DragScrollbar; diff --git a/src/shared/ui/SidebarMenu.tsx b/src/shared/ui/SidebarMenu.tsx index aa02a84..2405179 100644 --- a/src/shared/ui/SidebarMenu.tsx +++ b/src/shared/ui/SidebarMenu.tsx @@ -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]; diff --git a/src/shared/ui/Thumbnail.tsx b/src/shared/ui/Thumbnail.tsx new file mode 100644 index 0000000..5ea9d41 --- /dev/null +++ b/src/shared/ui/Thumbnail.tsx @@ -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 ( +
+ {alt} +
+ +
+ ); +}; + +export default Thumbnail; diff --git a/src/shared/ui/modal/DraftModal.tsx b/src/shared/ui/modal/DraftModal.tsx new file mode 100644 index 0000000..0accdea --- /dev/null +++ b/src/shared/ui/modal/DraftModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useId, useRef } from "react"; + +import { CloseCircleIcon } from "@/shared/assets/icons"; +import DragScrollbar from "@/shared/ui/DragScrollbar"; + +interface DraftModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + fileUrls: string[]; +} + +const DraftModal = ({ isOpen, onClose, title, fileUrls }: DraftModalProps) => { + const scrollRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ {title} +

+ +
+
+ {fileUrls.map((fileUrl, index) => ( +
e.preventDefault()} + > + 시안 이미지 +
+ ))} +
+ +
+
+ ); +}; + +export default DraftModal; diff --git a/src/shared/ui/Modal.tsx b/src/shared/ui/modal/Modal.tsx similarity index 100% rename from src/shared/ui/Modal.tsx rename to src/shared/ui/modal/Modal.tsx diff --git a/src/widgets/instructor/revision/config/revision.ts b/src/widgets/instructor/revision/config/revision.ts new file mode 100644 index 0000000..2d5f640 --- /dev/null +++ b/src/widgets/instructor/revision/config/revision.ts @@ -0,0 +1,9 @@ +export const REVISION_CATEGORIES = [ + "레이아웃 수정", + "타이포 수정", + "디자인 수정", + "색상 수정", + "기타", +]; + +export const MAX_SELECTABLE_COUNT = 2; diff --git a/src/widgets/instructor/revision/index.ts b/src/widgets/instructor/revision/index.ts new file mode 100644 index 0000000..ecb315a --- /dev/null +++ b/src/widgets/instructor/revision/index.ts @@ -0,0 +1,2 @@ +export { default as RevisionCategorySection } from "@/widgets/instructor/revision/ui/RevisionCategorySection"; +export { default as RevisionCommentSection } from "@/widgets/instructor/revision/ui/RevisionCommentSection"; diff --git a/src/widgets/instructor/revision/model/revision.ts b/src/widgets/instructor/revision/model/revision.ts new file mode 100644 index 0000000..768542d --- /dev/null +++ b/src/widgets/instructor/revision/model/revision.ts @@ -0,0 +1,111 @@ +// [강사] 작업 중 시안 조회 (수정 요청 페이지) +export type DraftRevisionDetail = { + commissionId: number; + title: string; + currentDraft: { + draftId: number; + thumbnailUrl: string; + designerComment: string; + }; + remainingRevisionCount: number; + maxRevisionCount: number; +}; + +export const draftRevisionDetailData: DraftRevisionDetail[] = [ + { + commissionId: 42, + title: "중등 수학 — 방정식과 함수 개념서", + currentDraft: { + draftId: 88, + thumbnailUrl: "", + designerComment: + "지난번 요청해 주신 수정사항을 반영해 보았는데, 막상 적용해 보니 전체적인 톤과 잘 어울리지 않는 것 같아 원래 방향을 살리는 쪽으로 다시 제안드립니다. 검토 후 의견 부탁드립니다.", + }, + remainingRevisionCount: 1, + maxRevisionCount: 3, + }, + { + commissionId: 44, + title: "초등 국어 — 받아쓰기 및 독해 기초", + currentDraft: { + draftId: 90, + thumbnailUrl: "", + designerComment: "", + }, + remainingRevisionCount: 2, + maxRevisionCount: 3, + }, + { + commissionId: 45, + title: "한국사 능력검정 — 중급 핵심 요약", + currentDraft: { + draftId: 95, + thumbnailUrl: "", + designerComment: + "요청하신 색상 톤을 기준으로 전체적인 배색과 명도 균형을 다시 잡아보았습니다. 기존 시안에서는 색감이 다소 차갑게 느껴질 수 있다는 의견을 반영해, 주조색의 채도를 살짝 낮추고 보조색과의 대비를 조정하여 전체적으로 한층 따뜻하고 차분한 분위기가 느껴지도록 손봤습니다. 또한 텍스트와 배경 간의 명도 차이를 다시 점검하여 가독성도 함께 보완했습니다. 혹시 색감의 톤이나 특정 영역의 배색에서 더 조정이 필요하다고 느끼시는 부분이 있다면 편하게 말씀해 주시면 바로 반영하도록 하겠습니다. 확인 부탁드립니다.", + }, + remainingRevisionCount: 3, + maxRevisionCount: 3, + }, + { + commissionId: 46, + title: "고등 화학 I — 원소와 화학 반응", + currentDraft: { + draftId: 92, + thumbnailUrl: "", + designerComment: "", + }, + remainingRevisionCount: 0, + maxRevisionCount: 3, + }, +]; + +// [강사] 수정된 시안 상세 조회 +export type DraftFiles = { + commissionId: number; + draftId: number; + fileUrls: string[]; +}; + +export const draftFilesData: DraftFiles[] = [ + { + commissionId: 42, + draftId: 88, + fileUrls: [ + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + ], + }, + { + commissionId: 44, + draftId: 90, + fileUrls: [ + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + ], + }, + { + commissionId: 45, + draftId: 95, + fileUrls: [ + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + ], + }, + { + commissionId: 46, + draftId: 92, + fileUrls: [ + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + "/images/thumbnail_mock.jpg", + ], + }, +]; diff --git a/src/widgets/instructor/revision/ui/RevisionCategorySection.tsx b/src/widgets/instructor/revision/ui/RevisionCategorySection.tsx new file mode 100644 index 0000000..8c846df --- /dev/null +++ b/src/widgets/instructor/revision/ui/RevisionCategorySection.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; + +import { CheckboxFillIcon, CheckboxWhiteIcon } from "@/shared/assets/icons"; +import { cn } from "@/shared/lib/utils/cn"; +import CommentCard from "@/shared/ui/CommentCard"; +import DraftModal from "@/shared/ui/modal/DraftModal"; +import Thumbnail from "@/shared/ui/Thumbnail"; +import { REVISION_CATEGORIES } from "@/widgets/instructor/revision/config/revision"; + +interface RevisionCategorySectionProps { + draftTitle: string; + designerComment?: string; + remainingRevisionCount: number; + maxRevisionCount: number; + selectedCategories: string[]; + onToggleCategory: (category: string) => void; + fileUrls: string[]; +} + +const RevisionCategorySection = ({ + draftTitle, + designerComment, + remainingRevisionCount, + maxRevisionCount, + selectedCategories, + onToggleCategory, + fileUrls, +}: RevisionCategorySectionProps) => { + const [isDraftModalOpen, setIsDraftModalOpen] = useState(false); + + const titleSection = ( +
+
+

+ 수정 요청하기 ( + {remainingRevisionCount} + /{maxRevisionCount}) +

+ {remainingRevisionCount === 0 && ( + + )} +
+

시안 수정은 총 3회 수정이 가능합니다.

+
+ ); + + const categoryCheckboxes = ( +
+ {REVISION_CATEGORIES.map(category => { + const isSelected = selectedCategories.includes(category); + const CheckboxIcon = isSelected ? CheckboxFillIcon : CheckboxWhiteIcon; + return ( +
{ + if (remainingRevisionCount === 0) return; + onToggleCategory(category); + }} + > + +

{category}

+
+ ); + })} +
+ ); + + if (designerComment) { + return ( + <> +
+
+
+ setIsDraftModalOpen(true)} + /> +
+ {titleSection} + +
+
+
+
+

+ 수정하고 싶은 카테고리를 골라주세요 +

+

1회 수정에 최대 2개까지 가능합니다.

+
+
+ {categoryCheckboxes} +
+
+
+ setIsDraftModalOpen(false)} + title={draftTitle} + fileUrls={fileUrls} + /> + + ); + } + + return ( + <> +
+
+ {titleSection} +
+ setIsDraftModalOpen(true)} /> +
+
+

+ 수정하고 싶은 카테고리를 골라주세요 +

+

1회 수정에 최대 2개까지 가능합니다.

+
+
+ {categoryCheckboxes} +
+
+
+
+ setIsDraftModalOpen(false)} + title={draftTitle} + fileUrls={fileUrls} + /> + + ); +}; + +export default RevisionCategorySection; diff --git a/src/widgets/instructor/revision/ui/RevisionCommentSection.tsx b/src/widgets/instructor/revision/ui/RevisionCommentSection.tsx new file mode 100644 index 0000000..7074f8e --- /dev/null +++ b/src/widgets/instructor/revision/ui/RevisionCommentSection.tsx @@ -0,0 +1,34 @@ +import TextField from "@/shared/ui/input/TextField"; + +interface RevisionCommentSectionProps { + comments: Record; + onChangeComment: (category: string, value: string) => void; + selectedCategories: string[]; +} + +const RevisionCommentSection = ({ + comments, + onChangeComment, + selectedCategories, +}: RevisionCommentSectionProps) => { + if (selectedCategories.length === 0) { + return null; + } + + return ( +
+ {selectedCategories.map(category => ( +
+

{category}

+ onChangeComment(category, event.target.value)} + /> +
+ ))} +
+ ); +}; + +export default RevisionCommentSection;