diff --git a/frontend/api/lectures/saveAudioFile.ts b/frontend/api/lectures/saveAudioFile.ts new file mode 100644 index 00000000..c930cabd --- /dev/null +++ b/frontend/api/lectures/saveAudioFile.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { SaveAudioFileResult } from "@/types/lectures/saveAudioFileTypes"; + +export async function saveAudioFile( lectureId: string, blob: Blob) { + + const file = new File([blob], `${lectureId}.mp3`, { type: "audio/mpeg" }); + + const formData = new FormData(); + formData.append("file", file); + + const response = await axiosInstance.post>( + ENDPOINTS.LECTURES.SAVE_RECORDING(lectureId), + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + } + ); + + return response.data; +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx index 26c09040..9beefc2e 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx @@ -7,57 +7,53 @@ import { X, SendHorizontal } from "lucide-react"; import { useLive } from "../../LectureLiveProvider"; import ChatBox from "@/components/ChatBox/ChatBox"; import BasicInput from "@/components/Input/BasicInput/BasicInput"; - -type Msg = { - id: string; - text: string; - role: "teacher" | "student"; - ts?: number; -}; +import { useParams } from "next/navigation"; +import { useLectureChat } from "@/hooks/useLectureChat"; export default function ChatPanel() { const { togglePanel } = useLive(); + const { lectureId } = useParams<{ lectureId: string }>(); - const [msgs, setMsgs] = useState([ - { id: "seed1", text: "질문이요~", role: "student", ts: Date.now()}, - ]); - const [text, setText] = useState(""); + // 소켓 연결 + const { messages, connected, sendMessage } = useLectureChat(lectureId); + const [text, setText] = useState(""); const bodyRef = useRef(null); const closeChat = () => togglePanel("chat"); + // 메시지 전송 const send = () => { const t = text.trim(); if (!t) return; - setMsgs((m) => [ - ...m, - { id: String(Date.now()), text: t, role: "teacher", ts: Date.now() }, - ]); + sendMessage(t); setText(""); }; + // Enter로 전송 const onSubmit: React.FormEventHandler = (e) => { e.preventDefault(); send(); }; + // 새로운 메시지 오면 스크롤 맨 아래로 이동 useEffect(() => { const el = bodyRef.current; if (!el) return; el.scrollTop = el.scrollHeight; - }, [msgs]); + }, [messages]); - const pad = (n: number) => n.toString().padStart(2, "0"); - const fmt = (ts: number) => { + + const fmt = (ts: string) => { const d = new Date(ts); - const yy = pad(d.getFullYear() % 100); - const MM = pad(d.getMonth() + 1); - const dd = pad(d.getDate()); - const hh = pad(d.getHours()); - const mm = pad(d.getMinutes()); - const ss = pad(d.getSeconds()); - return `${yy}.${MM}.${dd} ${hh}:${mm}:${ss}`; + return d.toLocaleString("ko-KR", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); }; return ( @@ -70,38 +66,37 @@ export default function ChatPanel() {
- {msgs.map((m) => { - const tsText = m.ts ? fmt(m.ts) : ""; - return ( -
- -
- ); - })} + {messages.map((m, i) => ( +
+ +
+ ))}
setText(e.target.value)} - placeholder="답변 입력하기" + placeholder={connected ? "답변 입력하기" : "연결 중..."} + disabled={!connected} iconRight={ } + disabled={!connected} /> } /> diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.module.scss index 620aa3b2..ce0bb264 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.module.scss +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.module.scss @@ -40,9 +40,4 @@ min-height: 0 !important; line-height: 2; padding-block: 3px !important; -} - -.docBtnZ { - position: relative; - z-index: 2000; - } \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.tsx index bb6519f7..380f22d5 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.tsx @@ -1,22 +1,20 @@ "use client"; -import { useState, useRef, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useRouter, useParams } from "next/navigation"; import styles from "./LectureLiveHeader.module.scss"; import FitContentButton from "@/components/Button/FitContentButton/FitContentButton"; -import IconButton from "@/components/Button/IconButton/IconButton"; import { DocumentSideButtonConnected } from "../DocumentSideButton/DocumentSideButton"; import PenToolButtons from "../PenTool/PenToolButtons/PenToolButtons"; import { useLive } from "../LectureLiveProvider"; -import { FileText } from "lucide-react"; -import ToolPopover from "../ToolPopover/ToolPopover"; -import LectureNotePopover from "../LectureNote/LectureNotePopover/LectureNotePopover"; import ChatingButton from "../Chating/ChatingButton/ChatingButton"; import RecordingButton from "../Recording/RecordingButton/RecordingButton"; import ConfirmModal from "@/components/Modal/ConfirmModal/ConfirmModal"; import { getRecordingEngine, type RecState } from "../Recording/recordingEngine"; import { ROUTES } from "@/constants/routes"; import { Tool } from "../LectureLiveProvider"; +import LectureNoteButton from "../LectureNote/LectureNoteButton/LectureNoteButton"; +import { saveAudioFile } from "@/api/lectures/saveAudioFile"; export default function LectureLiveHeader({ onToggleChat, @@ -28,10 +26,10 @@ export default function LectureLiveHeader({ onEndLecture?: () => void; }) { const { tool, setTool } = useLive(); - const docBtnRef = useRef(null); - const [openDoc, setOpenDoc] = useState(false); const [endOpen, setEndOpen] = useState(false); + const [saving, setSaving] = useState(false); + const engine = useMemo(() => getRecordingEngine(), []); const [recState, setRecState] = useState(engine.getSnapshot().state); @@ -56,14 +54,33 @@ export default function LectureLiveHeader({ const handleConfirmEnd = async () => { if (isRecording) { try { - await engine.stop(); + setSaving(true); + await new Promise((resolve, reject) => { + const off = engine.subscribe("done", async (blob) => { + try { + if (lectureId) { + await saveAudioFile(lectureId, blob); + console.log("🎤 녹음 파일 저장 완료"); + } + off(); + resolve(); + } catch (e) { + console.error("❌ 녹음 파일 저장 실패:", e); + reject(e); + } + }); + + engine.stop().catch(reject); + }); } catch (e) { - console.error(e); + console.error("녹음 종료 중 오류:", e); + } finally { + setSaving(false); } } + setEndOpen(false); onEndLecture?.(); - router.push(ROUTES.teacherLectureDetail(lectureId)); }; @@ -75,23 +92,7 @@ export default function LectureLiveHeader({
- - setOpenDoc((v) => !v)} - icon={} - /> - - - setOpenDoc(false)} - align="start" - side="bottom" - > - setOpenDoc(false)} /> - + @@ -113,8 +114,14 @@ export default function LectureLiveHeader({
{endOpen && ( - - {isRecording ? ( + + {saving ? ( + <>녹음 파일 저장 중입니다... ⏳ + ) : isRecording ? ( <> 지금 녹음이 진행 중입니다.
diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx index 0c0d9150..f9f05fc3 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useMemo, useState, useRef, useCallback } from "react"; -export type DocType = "pdf" | "pptx" | "unknown"; +export type DocType = "pdf" | "unknown"; type DocState = { url: string; type: DocType; name: string }; export type Tool = "pencilOff" | "pen" | "eraser" | "highlighter"; @@ -61,9 +61,9 @@ export function LectureLiveProvider({ children }: { children: React.ReactNode }) }); const [doc, setDocState] = useState({ - url: "/file/기말보고서_졸업을하자.pdf", - type: "pdf", - name: "기말보고서_졸업을하자.pdf", + url: "", + type: "unknown", + name: "", }); const drawStoreRef = useRef>(new Map()); diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.module.scss index 44ccb2dd..7ca330f0 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.module.scss +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.module.scss @@ -51,4 +51,27 @@ --right-col: 0px; grid-template-columns: 1fr; } +} + +.empty { + height: 100%; + display:flex; align-items:center; justify-content:center; +} +.emptyCard { + padding: 20px 24px; border:1px dashed #d6d6d6; border-radius:12px; + background:#fafafa; text-align:center; +} +.emptyTitle { font-weight:700; margin-bottom:6px; } +.emptyDesc { color:#666; } +.leftEmpty { + height:100%; display:flex; align-items:center; justify-content:center; + color:#777; font-size: 0.92rem; padding: 0 12px; text-align:center; +} + +.inlineIcon { + width: 1.2em; + height: 1.2em; + vertical-align: middle; + margin-bottom: 0.3em; + margin-right: 0.1em; } \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx index e0aaa8c0..9f552e11 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx @@ -5,6 +5,7 @@ import styles from "./LectureMainGrid.module.scss"; import { useLive } from "../LectureLiveProvider"; import ChatPanel from "../Chating/ChatingPanel/ChatingPanel"; import dynamic from "next/dynamic"; +import { FileText } from "lucide-react"; const PageThumbsSidebar = dynamic( () => import("../LectureNote/PageThumbsSidebar/PageThumbsSidebar"), @@ -26,8 +27,8 @@ export default function LectureMainGrid() { const [count, setCount] = useState(1); const [currentPage, setCurrentPage] = useState(0); - const resolvedType: "pdf" | "pptx" | undefined = - doc.type === "pdf" || doc.type === "pptx" ? doc.type : undefined; + const resolvedType: "pdf" | undefined = + doc.type === "pdf" ? doc.type : undefined; useEffect(() => { const resetOnDocChange = () => setCurrentPage(0); @@ -44,6 +45,7 @@ export default function LectureMainGrid() { const onKey = (e: KeyboardEvent) => { if (isTypingTarget(e.target)) return; + if (!doc.url) return; if (e.key === " " || e.key === "Enter" || e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); @@ -58,6 +60,21 @@ export default function LectureMainGrid() { return () => window.removeEventListener("keydown", onKey); }, [count, doc.url]); + const EmptyState = ( +
+
+
강의자료가 선택되지 않았어요
+
+ 상단의 {" "} + + + 버튼을 눌러 자료를 선택해 주세요. +
+
+
+ ); + return (
- - + {doc.url ? ( + <> + + + + ) : ( + EmptyState + )}