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
22 changes: 22 additions & 0 deletions frontend/api/lectures/saveAudioFile.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<SaveAudioFileResult | null>>(
ENDPOINTS.LECTURES.SAVE_RECORDING(lectureId),
formData,
{
headers: { "Content-Type": "multipart/form-data" },
}
);

return response.data;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Msg[]>([
{ 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<HTMLDivElement>(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 (
Expand All @@ -70,38 +66,37 @@ export default function ChatPanel() {
</div>

<div ref={bodyRef} className={styles.body}>
{msgs.map((m) => {
const tsText = m.ts ? fmt(m.ts) : "";
return (
<div
key={m.id}
className={`${styles.row} ${
m.role === "teacher" ? styles.teacher : styles.student
}`}
>
<ChatBox
isAnonymous={true}
nickname=""
profilePicture=""
message={m.text}
timestamp={tsText}
variant={m.role === "teacher" ? "teacher" : "student"}
/>
</div>
);
})}
{messages.map((m, i) => (
<div
key={i}
className={`${styles.row} ${
m.role === "TEACHER" ? styles.teacher : styles.student
}`}
>
<ChatBox
isAnonymous={true}
nickname={m.senderName ?? ""}
profilePicture=""
message={m.content}
timestamp={fmt(m.timestamp)}
variant={m.role === "TEACHER" ? "teacher" : "student"}
/>
</div>
))}
</div>

<form className={styles.inputRow} onSubmit={onSubmit}>
<BasicInput
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="답변 입력하기"
placeholder={connected ? "답변 입력하기" : "연결 중..."}
disabled={!connected}
iconRight={
<IconButton
ariaLabel="전송"
onClick={send}
icon={<SendHorizontal size={18} color="#9AA4B2" />}
disabled={!connected}
/>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,4 @@
min-height: 0 !important;
line-height: 2;
padding-block: 3px !important;
}

.docBtnZ {
position: relative;
z-index: 2000;
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -28,10 +26,10 @@ export default function LectureLiveHeader({
onEndLecture?: () => void;
}) {
const { tool, setTool } = useLive();
const docBtnRef = useRef<HTMLSpanElement>(null);
const [openDoc, setOpenDoc] = useState(false);

const [endOpen, setEndOpen] = useState(false);
const [saving, setSaving] = useState(false);


const engine = useMemo(() => getRecordingEngine(), []);
const [recState, setRecState] = useState<RecState>(engine.getSnapshot().state);
Expand All @@ -56,14 +54,33 @@ export default function LectureLiveHeader({
const handleConfirmEnd = async () => {
if (isRecording) {
try {
await engine.stop();
setSaving(true);
await new Promise<void>((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));
};

Expand All @@ -75,23 +92,7 @@ export default function LectureLiveHeader({
<div className={styles.left}>
<DocumentSideButtonConnected />

<span ref={docBtnRef} className={styles.docBtnZ}>
<IconButton
ariaLabel="문서 불러오기"
onClick={() => setOpenDoc((v) => !v)}
icon={<FileText />}
/>
</span>

<ToolPopover
open={openDoc}
anchorRef={docBtnRef}
onClose={() => setOpenDoc(false)}
align="start"
side="bottom"
>
<LectureNotePopover onPicked={() => setOpenDoc(false)} />
</ToolPopover>
<LectureNoteButton />

<span className={styles.divider} />
<PenToolButtons value={tool} onChange={selectTool} />
Expand All @@ -113,8 +114,14 @@ export default function LectureLiveHeader({
</div>

{endOpen && (
<ConfirmModal onConfirm={handleConfirmEnd} onClose={handleCancelEnd}>
{isRecording ? (
<ConfirmModal
onConfirm={handleConfirmEnd}
onClose={handleCancelEnd}
disableActions={saving}
>
{saving ? (
<>녹음 파일 저장 중입니다... ⏳</>
) : isRecording ? (
<>
지금 녹음이 진행 중입니다.
<br />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,9 +61,9 @@ export function LectureLiveProvider({ children }: { children: React.ReactNode })
});

const [doc, setDocState] = useState<DocState>({
url: "/file/기말보고서_졸업을하자.pdf",
type: "pdf",
name: "기말보고서_졸업을하자.pdf",
url: "",
type: "unknown",
name: "",
});

const drawStoreRef = useRef<Map<number, HTMLCanvasElement>>(new Map());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading