- {/* (기존 그대로) 의견 버튼 */}
-
- 의견
-
- 3
-
-
+interface OpinionProps {
+ opinions?: OpinionItem[];
+ opinionCount?: number;
+ onDelete?: (id: number) => void;
+ onReply?: (id: number, content: string) => void;
+}
- {/* (기존 그대로) 의견 popover */}
- {opinion.value && (
- <>
- {/* 바깥 클릭 시 닫기 */}
-
+export default function Opinion({
+ opinions = [
+ {
+ id: 0,
+ author: '익명 사용자',
+ content: '이 부분 설명이 명확해요!',
+ timestamp: '방금 전',
+ isMine: true,
+ },
+ {
+ id: 1,
+ author: '이영희',
+ content: '이 부분 설명이 명확해요!',
+ timestamp: '1시간 전',
+ isMine: true,
+ },
+ {
+ id: 2,
+ author: '박민수',
+ content: '이 부분 설명이 명확해요!',
+ timestamp: '1시간 전',
+ isMine: false,
+ isReply: true,
+ parentId: 1,
+ },
+ {
+ id: 3,
+ author: '김철수',
+ content: '이 부분 설명이 명확해요!',
+ timestamp: '1시간 전',
+ isMine: false,
+ },
+ ],
+ opinionCount = 3,
+ onDelete,
+ onReply,
+}: OpinionProps) {
+ const [activeReplyId, setActiveReplyId] = useState
(null);
+ const [replyText, setReplyText] = useState('');
- {
+ if (replyText.trim()) {
+ onReply?.(opinionId, replyText);
+ }
+ setActiveReplyId(null);
+ setReplyText('');
+ };
+
+ return (
+
(
+
+
-
+ 의견
+
+
+ {opinionCount}
+
+
+ )}
+ position="top"
+ align="end"
+ ariaLabel="의견 목록"
+ className="w-popover max-w-[90vw] overflow-hidden rounded-b-lg"
+ >
+ {/* 헤더 */}
+
+ 의견
+
-
- {[0, 1, 2, 3].map((idx) => {
- const isMine = idx < 2;
- const hasReply = idx === 2;
+ {/* 의견 목록 */}
+
+ {opinions.map((opinion) => (
+
+ {/* 의견 항목 */}
+
+ {/* 프로필 이미지 */}
+
- return (
-
-
-
+ {/* 의견 내용 */}
+
+
+
+
+
+ {opinion.author}
+
+ {opinion.timestamp}
-
-
-
-
-
- {isMine ? '익명 사용자' : '김철수'}
-
-
- {idx === 0 ? '방금 전' : '1시간 전'}
-
-
-
- {isMine && (
-
- 삭제
-
-
- )}
-
-
-
- 이 부분 설명이 명확해요!
-
-
+ {opinion.isMine && (
+
onDelete?.(opinion.id)}
+ aria-label="의견 삭제"
+ className="flex items-center gap-1 text-xs font-semibold text-error active:opacity-80"
+ >
+ 삭제
+
+
+ )}
+
-
- {/* ✅ 기존 로직 그대로 유지
- - setActiveReplyIdx: 같은 댓글 다시 누르면 닫기(toggle)
- - setReplyText: 새로 열 때 입력 초기화 */}
-
{
- setActiveReplyIdx((prev) => (prev === idx ? null : idx));
- setReplyText('');
- }}
- >
- 답글
-
-
+
{opinion.content}
+
- {/* 답글 inputBox 렌더링 */}
- {activeReplyIdx === idx && (
-
- setReplyText(e.target.value)}
- placeholder="답글을 입력하세요"
- className="flex-1 h-10 px-3 rounded-lg border border-zinc-200 text-sm outline-none focus:border-indigo-500"
- />
- {/* 서버 붙기 전: 아무 것도 안 하고 닫기만 */}
- {
- setActiveReplyIdx(null);
- setReplyText('');
- }}
- >
- 등록
-
-
- )}
-
-
-
- );
- })}
+ {/* 답글 버튼 */}
+
+ {
+ setActiveReplyId(activeReplyId === opinion.id ? null : opinion.id);
+ setReplyText('');
+ }}
+ aria-expanded={activeReplyId === opinion.id}
+ aria-label={`${opinion.author}에게 답글 달기`}
+ className={clsx(
+ 'flex items-center gap-1 text-xs font-semibold',
+ activeReplyId === opinion.id
+ ? 'text-gray-400'
+ : 'text-main hover:text-main-variant1 active:text-main-variant2',
+ )}
+ >
+ 답글
+
+
+
+
+
+ {/* 답글 입력 */}
+ {activeReplyId === opinion.id && (
+
+
setReplyText(e.target.value)}
+ placeholder="댓글을 입력하세요"
+ aria-label="답글 입력"
+ className="w-full border-b border-gray-400 bg-transparent px-0.5 py-2 text-base font-semibold text-gray-800 outline-none placeholder:text-gray-600 focus:border-main"
+ />
+
+ {
+ setActiveReplyId(null);
+ setReplyText('');
+ }}
+ aria-label="답글 취소"
+ className="px-3 py-1.5 text-xs font-semibold text-gray-800 hover:text-gray-600"
+ >
+ 취소
+
+ handleReplySubmit(opinion.id)}
+ disabled={!replyText.trim()}
+ aria-label="답글 등록"
+ className={clsx(
+ 'rounded-full bg-white px-3 py-1.5 text-xs font-semibold',
+ replyText.trim() ? 'text-main active:text-main-variant2' : 'text-gray-400',
+ )}
+ >
+ 답글
+
+
+
+ )}
- >
- )}
-
+ ))}
+
+
);
-};
-
-export default Opinion;
+}
diff --git a/src/components/script-box/ScriptBox.tsx b/src/components/script-box/ScriptBox.tsx
index 1fe85770..dae66eef 100644
--- a/src/components/script-box/ScriptBox.tsx
+++ b/src/components/script-box/ScriptBox.tsx
@@ -1,122 +1,37 @@
import { useState } from 'react';
-import smallArrowIcon from '../../assets/icons/smallArrowIcon.svg';
-import { useToggle } from '../../hooks/useToggle';
-import Opinion from './Opinion';
-import ScriptBoxEmogi from './ScriptBoxEmogi';
-import ScriptHistory from './ScriptHistory';
-import SlideTitle from './SlideTitle';
+import clsx from 'clsx';
-const ScriptBox = () => {
- // 0. 슬라이드 이름(이름 변경) 버튼
- const slideNameChange = useToggle(false);
- // 0-1. 현재 슬라이드 이름, 나중에 서버에서 받아온 거로 default 채워야함.
- const [slideTitle, setSlideTitle] = useState('슬라이드 1');
- // 0-2. 타이틀 저장 버튼(서버 요청)
- // const handleSaveSlideTitle = () => {
- //
- // };
+import ScriptBoxContent from './ScriptBoxContent';
+import ScriptBoxHeader from './ScriptBoxHeader';
- // 1. 이모지버튼 ( 그대로 유지 )
- const [isEmogiClick, setEmogiClick] = useState(false);
+interface ScriptBoxProps {
+ slideTitle?: string;
+}
- const handleEmogiClick = () => {
- setEmogiClick((prev) => !prev);
- };
-
- // 2. 변경기록 버튼
- const scriptHistory = useToggle(false);
- // 3. 의견 버튼
- const opinion = useToggle(false);
- // 3-1. 답변 (나중에 피드백id랑 매칭해야됨)
- // 어떤 댓글에 답글 입력창이 열려있는지
- const [activeReplyIdx, setActiveReplyIdx] = useState
(null);
-
- // 답글 입력값(일단 1개만)
- const [replyText, setReplyText] = useState('');
+export default function ScriptBox({ slideTitle = '슬라이드 1' }: ScriptBoxProps) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
- // 4. 대본섹션 닫기 토글
- const scriptBoxDock = useToggle(false);
+ const handleToggleCollapse = () => {
+ setIsCollapsed((prev) => !prev);
+ };
return (
- <>
-
- {/* 변경: 상단바 내부 레이아웃(좌/우 정렬) */}
-
- {/* 슬라이드 제목 변경 */}
-
-
- {/* 우측 컨트롤 영역 */}
-
- {/* 이모지 카운트 영역 -> 컴포넌트로 분리 */}
-
-
- {/* 변경기록/의견 버튼 그룹 */}
-
- {/* 변경기록 */}
-
-
- {/* 의견(댓글)기록 */}
-
-
-
- {/* 전체 Script Box 열림 닫힘 버튼*/}
-
-
-
-
-
-
- {/* "대본 입력 영역" textarea */}
-
-
-
-
- >
+
+
+
+
);
-};
-
-export default ScriptBox;
+}
diff --git a/src/components/script-box/ScriptBoxContent.tsx b/src/components/script-box/ScriptBoxContent.tsx
new file mode 100644
index 00000000..e18ea108
--- /dev/null
+++ b/src/components/script-box/ScriptBoxContent.tsx
@@ -0,0 +1,17 @@
+import { useState } from 'react';
+
+export default function ScriptBoxContent() {
+ const [script, setScript] = useState('');
+
+ return (
+
+
+ );
+}
diff --git a/src/components/script-box/ScriptBoxEmogi.tsx b/src/components/script-box/ScriptBoxEmogi.tsx
deleted file mode 100644
index aa62f67d..00000000
--- a/src/components/script-box/ScriptBoxEmogi.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-type ScriptBoxEmogiProps = {
- isEmogiClick: boolean; // 변수명 그대로 유지: isEmogiClick
- handleEmogiClick: () => void; // 핸들러명 그대로 유지: handleEmogiClick
-};
-
-const ScriptBoxEmogi = ({ isEmogiClick, handleEmogiClick }: ScriptBoxEmogiProps) => {
- return (
- <>
- {/* (기존 그대로) 이모지 카운트 영역 */}
-
-
- {/* (기존 그대로) 이모지 버튼 + popover */}
-
-
- ...
-
-
- {isEmogiClick && ( // 받은 이모티콘 서버에서 뿌려줌
-
- )}
-
- >
- );
-};
-
-export default ScriptBoxEmogi;
diff --git a/src/components/script-box/ScriptBoxEmoji.tsx b/src/components/script-box/ScriptBoxEmoji.tsx
new file mode 100644
index 00000000..22850e64
--- /dev/null
+++ b/src/components/script-box/ScriptBoxEmoji.tsx
@@ -0,0 +1,73 @@
+import type { EmojiReaction } from '@/types/script';
+
+import { Popover } from '../common';
+
+const EMOJI_DATA: EmojiReaction[] = [
+ { emoji: '👍', count: 99 },
+ { emoji: '😡', count: 12 },
+];
+
+const EMOJI_EXTENDED_DATA: EmojiReaction[][] = [
+ [
+ { emoji: '😏', count: 15 },
+ { emoji: '❤️', count: 28 },
+ { emoji: '😎', count: 5 },
+ { emoji: '👀', count: 182 },
+ { emoji: '🤪', count: 3 },
+ ],
+ [
+ { emoji: '💡', count: 11 },
+ { emoji: '🙈', count: 488 },
+ { emoji: '💕', count: 2 },
+ { emoji: '😂', count: 46 },
+ { emoji: '🤓', count: 36 },
+ ],
+];
+
+export default function ScriptBoxEmoji() {
+ const trigger = (
+
+ ···
+
+ );
+
+ return (
+
+ {/* 메인 이모지 카운트 */}
+
+ {EMOJI_DATA.map(({ emoji, count }) => (
+
+ {emoji}
+ {count > 99 ? '99+' : count}
+
+ ))}
+
+
+ {/* 이모지 더보기 팝오버 */}
+
+
+ {EMOJI_EXTENDED_DATA.map((row, rowIdx) => (
+
+ {row.map(({ emoji, count }) => (
+
+ {emoji}
+ {count}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/script-box/ScriptBoxHeader.tsx b/src/components/script-box/ScriptBoxHeader.tsx
new file mode 100644
index 00000000..5cd3713d
--- /dev/null
+++ b/src/components/script-box/ScriptBoxHeader.tsx
@@ -0,0 +1,49 @@
+import clsx from 'clsx';
+
+import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react';
+
+import Opinion from './Opinion';
+import ScriptBoxEmoji from './ScriptBoxEmoji';
+import ScriptHistory from './ScriptHistory';
+import SlideTitle from './SlideTitle';
+
+interface ScriptBoxHeaderProps {
+ slideTitle: string;
+ isCollapsed: boolean;
+ onToggleCollapse: () => void;
+}
+
+export default function ScriptBoxHeader({
+ slideTitle,
+ isCollapsed,
+ onToggleCollapse,
+}: ScriptBoxHeaderProps) {
+ return (
+
+ {/* 좌측: 슬라이드 제목 */}
+
+
+ {/* 우측: 이모지, 변경기록, 의견, 접기 버튼 */}
+
+
+ );
+}
diff --git a/src/components/script-box/ScriptHistory.tsx b/src/components/script-box/ScriptHistory.tsx
index 5475862d..c739590e 100644
--- a/src/components/script-box/ScriptHistory.tsx
+++ b/src/components/script-box/ScriptHistory.tsx
@@ -1,99 +1,87 @@
-import refreshIcon from '../../assets/icons/refreshIcon.svg';
+import clsx from 'clsx';
-type ScriptHistoryProps = {
- scriptHistory: {
- value: boolean;
- toggle: () => void;
- off: () => void;
- };
-};
+import RevertIcon from '@/assets/icons/icon-revert.svg?react';
+import type { HistoryItem } from '@/types/script';
-const ScriptHistory = ({ scriptHistory }: ScriptHistoryProps) => {
- return (
-
- {/* (기존 그대로) 변경기록 버튼 */}
-
- 변경 기록
-
-
-
- {/* (기존 그대로) 변경기록 popover */}
- {scriptHistory.value && (
- <>
- {/* 바깥 클릭 시 닫기 */}
-
+import { Popover } from '../common';
-
-
+interface ScriptHistoryProps {
+ currentScript?: string;
+ historyItems?: HistoryItem[];
+ onRestore?: (item: HistoryItem) => void;
+}
-
-
-
-
현재
-
- (현재 대본 내용이 들어올 자리)
-
-
-
-
- {[0, 1, 2, 3].map((idx) => (
-
-
-
- (시간 표시 자리)
-
+export default function ScriptHistory({
+ currentScript = '(현재 대본 내용이 들어올 자리)',
+ historyItems = [
+ { id: '1', timestamp: '(시간 표시 자리)', content: '(이전 대본 내용 자리 #1)' },
+ { id: '2', timestamp: '(시간 표시 자리)', content: '(이전 대본 내용 자리 #2)' },
+ { id: '3', timestamp: '(시간 표시 자리)', content: '(이전 대본 내용 자리 #3)' },
+ { id: '4', timestamp: '(시간 표시 자리)', content: '(이전 대본 내용 자리 #4)' },
+ ],
+ onRestore,
+}: ScriptHistoryProps) {
+ return (
+
(
+
+ 변경 기록
+
+
+ )}
+ position="top"
+ align="end"
+ ariaLabel="대본 변경 기록"
+ className="w-popover max-w-[90vw] overflow-hidden rounded-b-lg"
+ >
+ {/* 헤더 */}
+
+ 대본 변경 기록
+
-
- 복원
-
-
-
+ {/* 콘텐츠 */}
+
+ {/* 현재 대본 */}
+
-
- (이전 대본 내용 자리 #{idx + 1})
-
-
- ))}
+ {/* 히스토리 목록 */}
+ {historyItems.map((item) => (
+
+
+ {item.timestamp}
+ onRestore?.(item)}
+ aria-label={`${item.timestamp} 버전으로 복원`}
+ className={clsx(
+ 'inline-flex items-center gap-1 rounded py-1 pl-2 pr-1.5',
+ 'bg-white text-gray-800 outline-1 -outline-offset-1 outline-gray-200',
+ 'hover:text-gray-600 active:bg-gray-100',
+ )}
+ >
+ 복원
+
+
+
{item.content}
- >
- )}
-
+ ))}
+
+
);
-};
-
-export default ScriptHistory;
+}
diff --git a/src/components/script-box/SlideTitle.tsx b/src/components/script-box/SlideTitle.tsx
index 3c0f29b7..02597aeb 100644
--- a/src/components/script-box/SlideTitle.tsx
+++ b/src/components/script-box/SlideTitle.tsx
@@ -1,87 +1,66 @@
-import React from 'react';
+import { useState } from 'react';
-import smallArrowIcon from '../../assets/icons/smallArrowIcon.svg';
+import clsx from 'clsx';
-type SlideTitleProps = {
- // 변수명 그대로 유지: slideNameChange
- slideNameChange: {
- value: boolean;
- toggle: () => void;
- off: () => void;
- };
+import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react';
+
+import { Popover } from '../common';
+
+interface SlideTitleProps {
+ initialTitle?: string;
+ isCollapsed?: boolean;
+ onSave?: (title: string) => void;
+}
- // 변수명 그대로 유지: slideTitle / setSlideTitle
- slideTitle: string;
- setSlideTitle: React.Dispatch
>;
-};
+export default function SlideTitle({
+ initialTitle = '슬라이드 1',
+ isCollapsed = false,
+ onSave,
+}: SlideTitleProps) {
+ const [title, setTitle] = useState(initialTitle);
+ const [editTitle, setEditTitle] = useState(initialTitle);
+
+ const handleSave = () => {
+ setTitle(editTitle);
+ onSave?.(editTitle);
+ };
-const SlideTitle = ({ slideNameChange, slideTitle, setSlideTitle }: SlideTitleProps) => {
return (
-
+
);
-};
-
-export default SlideTitle;
+}
diff --git a/src/components/script-box/index.ts b/src/components/script-box/index.ts
new file mode 100644
index 00000000..8efe0b14
--- /dev/null
+++ b/src/components/script-box/index.ts
@@ -0,0 +1 @@
+export { default as ScriptBox } from './ScriptBox';
diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts
index 7045a79f..acf16759 100644
--- a/src/constants/navigation.ts
+++ b/src/constants/navigation.ts
@@ -1,10 +1,12 @@
-export const TABS = [
+import type { TabItem, TabKey } from '@/types/navigation';
+
+export const TABS: readonly TabItem[] = [
{ key: 'slide', label: '슬라이드' },
{ key: 'video', label: '영상' },
{ key: 'insight', label: '인사이트' },
] as const;
-export type Tab = (typeof TABS)[number]['key'];
+export type Tab = TabKey;
export const DEFAULT_SLIDE_ID = '1';
diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx
index 0a74aa83..88550d48 100644
--- a/src/pages/SlidePage.tsx
+++ b/src/pages/SlidePage.tsx
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
+import { ScriptBox } from '@/components/script-box';
import { setLastSlideId } from '@/constants/navigation';
export default function SlidePage() {
@@ -18,6 +19,7 @@ export default function SlidePage() {
return (
슬라이드 {slideId}
+
);
}
diff --git a/src/styles/index.css b/src/styles/index.css
index d029f3af..1b064d4f 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -27,4 +27,6 @@
--spacing-15: 3.75rem;
--spacing-18: 4.5rem;
--spacing-25: 6.25rem;
+
+ --width-popover: 26.3125rem; /* 421px */
}
diff --git a/src/types/navigation.ts b/src/types/navigation.ts
new file mode 100644
index 00000000..d8815efe
--- /dev/null
+++ b/src/types/navigation.ts
@@ -0,0 +1,6 @@
+export type TabKey = 'slide' | 'video' | 'insight';
+
+export interface TabItem {
+ key: TabKey;
+ label: string;
+}
diff --git a/src/types/script.ts b/src/types/script.ts
new file mode 100644
index 00000000..4daa7acd
--- /dev/null
+++ b/src/types/script.ts
@@ -0,0 +1,20 @@
+export interface HistoryItem {
+ id: string;
+ timestamp: string;
+ content: string;
+}
+
+export interface OpinionItem {
+ id: number;
+ author: string;
+ content: string;
+ timestamp: string;
+ isMine: boolean;
+ isReply?: boolean;
+ parentId?: number;
+}
+
+export interface EmojiReaction {
+ emoji: string;
+ count: number;
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 595827e4..19bb3bfb 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
- "types": ["vite/client"],
+ "types": ["vite/client", "vite-plugin-svgr/client"],
"skipLibCheck": true,
/* Bundler mode */
diff --git a/vite.config.ts b/vite.config.ts
index d07dd82a..1b5e2494 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,10 +2,11 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
import { defineConfig } from 'vite';
+import svgr from 'vite-plugin-svgr';
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ plugins: [react(), tailwindcss(), svgr()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),