diff --git a/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureHeader/LectureHeader.tsx b/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureHeader/LectureHeader.tsx index 5eb63d30..79692ee4 100644 --- a/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureHeader/LectureHeader.tsx +++ b/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureHeader/LectureHeader.tsx @@ -9,6 +9,8 @@ import MakeQuizModal from "@/components/Modal/MakeQuizModal/MakeQuizModal"; import { FetchLectureDetailResult } from "@/types/lectures/fetchLectureDetailTypes"; import { fetchLectureDetail } from "@/api/lectures/fetchLectureDetail"; import { useLectureDetail } from "../LectureDetailContext"; +import { useRouter } from "next/navigation"; +import { ROUTES } from "@/constants/routes"; export default function LectureHeader() { const { lectureId, setClassId, refreshKey } = useLectureDetail(); @@ -17,6 +19,8 @@ export default function LectureHeader() { const [showQuizModal, setShowQuizModal] = useState(false); const [loading, setLoading] = useState(true); + const router = useRouter(); + const fetchData = useCallback(async () => { try { setLoading(true); @@ -43,8 +47,7 @@ export default function LectureHeader() { }, [lectureId, setClassId, fetchData, refreshKey]); const handleStartLecture = () => { - // TODO: 강의 시작 로직 구현 - console.log("강의 시작"); + router.push(ROUTES.teacherLectureLive(lectureId)); }; const handleQuizModalClose = () => { diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.module.scss new file mode 100644 index 00000000..607f6781 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.module.scss @@ -0,0 +1,17 @@ +@import "@/styles/variables"; + +.wrap { + position: relative; + display: inline-flex; +} + +.badge { + position: absolute; + top: 6px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: #ef4444; + box-shadow: 0 0 0 2px #fff; +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx new file mode 100644 index 00000000..2bda7008 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useState } from "react"; +import IconButton from "@/components/Button/IconButton/IconButton"; +import { MessageCircleMore } from "lucide-react"; +import { useLive } from "../../LectureLiveProvider"; +import styles from "./ChatingButton.module.scss"; + +export default function ChatingButton({ + className, + onPress, +}: { + className?: string; + onPress?: () => void; +}) { + const { panels, togglePanel } = useLive(); + const isOpen = panels.chat; + const [unread, setUnread] = useState(false); + + useEffect(() => { + if (isOpen) setUnread(false); + }, [isOpen]); + + useEffect(() => { + const onNew = () => { + if (!isOpen) setUnread(true); + }; + window.addEventListener("live:chat:new", onNew as EventListener); + return () => window.removeEventListener("live:chat:new", onNew as EventListener); + }, [isOpen]); + + const onClick = () => { + onPress?.(); + togglePanel("chat"); + }; + + return ( + + } + /> + {unread && !isOpen && } + + ); +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.module.scss new file mode 100644 index 00000000..1b79c032 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.module.scss @@ -0,0 +1,60 @@ +@import "@/styles/variables"; + +.wrap { + height: 100%; + display: grid; + grid-template-rows: auto 1fr auto; +} + +.header { + padding: $spacing-sm $spacing-sm 0 $spacing-sm; + display: flex; + align-items: center; + justify-content: space-between; +} + +.headerTitle { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + color: $color-neutral-1; +} + +.closeBtn :global(button) { + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 8px; + + &:hover { background: $color-neutral-7; } +} + +.body { + display: flex; + flex-direction: column; + overflow: auto; + padding: $spacing-sm; + gap: $spacing-xs; +} + +.row { + display: flex; +} + +.teacher { justify-content: flex-end; } +.student { justify-content: flex-start; } + +.inputRow { + padding: $spacing-sm; + border-top: 1px solid $color-neutral-7; + + :global(.inputBox) { + padding-right: 6px; + } +} + +.inputRow :global(.iconRight) { + display: inline-flex; + align-items: center; +} 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 new file mode 100644 index 00000000..26c09040 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import styles from "./ChatingPanel.module.scss"; +import IconButton from "@/components/Button/IconButton/IconButton"; +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; +}; + +export default function ChatPanel() { + const { togglePanel } = useLive(); + + const [msgs, setMsgs] = useState([ + { id: "seed1", text: "질문이요~", role: "student", ts: Date.now()}, + ]); + 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() }, + ]); + setText(""); + }; + + const onSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + send(); + }; + + useEffect(() => { + const el = bodyRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [msgs]); + + const pad = (n: number) => n.toString().padStart(2, "0"); + const fmt = (ts: number) => { + 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 ( +
+
+ Question +
+ } /> +
+
+ +
+ {msgs.map((m) => { + const tsText = m.ts ? fmt(m.ts) : ""; + return ( +
+ +
+ ); + })} +
+ +
+ setText(e.target.value)} + placeholder="답변 입력하기" + iconRight={ + } + /> + } + /> + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/DocumentSideButton/DocumentSideButton.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/DocumentSideButton/DocumentSideButton.tsx new file mode 100644 index 00000000..c4a64303 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/DocumentSideButton/DocumentSideButton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import IconButton from "@/components/Button/IconButton/IconButton"; +import { PanelLeft, PanelLeftDashed } from "lucide-react"; +import { useLive } from "../LectureLiveProvider"; + +export type DocumentSideButtonProps = { + open: boolean; + onToggle: () => void; + ariaLabelOpen?: string; + ariaLabelClose?: string; + disabled?: boolean; +}; + +export default function DocumentSideButton({ + open, + onToggle, + ariaLabelOpen = "슬라이드 패널 열기", + ariaLabelClose = "슬라이드 패널 닫기", + disabled = false, +}: DocumentSideButtonProps) { + const label = open ? ariaLabelClose : ariaLabelOpen; + + return ( + : } + /> + ); +} + +export function DocumentSideButtonConnected() { + const { panels, togglePanel } = useLive(); + return ( + togglePanel("files")} + /> + ); +} \ No newline at end of file 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 new file mode 100644 index 00000000..620aa3b2 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.module.scss @@ -0,0 +1,48 @@ +@import "@/styles/variables"; + +.container { + width: 100%; + background: $color-white; + border-bottom: 1px solid $color-neutral-7; +} + +.inner { + height: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px $spacing-md; +} + +.left { + display: flex; + align-items: center; + gap: $spacing-xs; +} + +.divider { + width: 1px; + height: 20px; + background: $color-neutral-7; + margin: 0 $spacing-xs; +} + + +.left svg[data-active="true"] { + color: $color-blue; + stroke: $color-blue; +} + + +.endBtn { + align-self: center; + height: auto !important; + min-height: 0 !important; + line-height: 2; + padding-block: 3px !important; +} + +.docBtnZ { + position: relative; + z-index: 2000; + } \ 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 new file mode 100644 index 00000000..bb6519f7 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveHeader/LectureLiveHeader.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useRef, 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"; + +export default function LectureLiveHeader({ + onToggleChat, + onEndLecture, +}: { + onUploadDoc?: () => void; + onToggleChat?: () => void; + onToggleAudio?: () => void; + onEndLecture?: () => void; +}) { + const { tool, setTool } = useLive(); + const docBtnRef = useRef(null); + const [openDoc, setOpenDoc] = useState(false); + + const [endOpen, setEndOpen] = useState(false); + + const engine = useMemo(() => getRecordingEngine(), []); + const [recState, setRecState] = useState(engine.getSnapshot().state); + const isRecording = recState === "recording"; + + useEffect(() => { + const off = engine.subscribe("state", setRecState); + return () => off(); + }, [engine]); + + const selectTool = (t: Tool) => setTool(t); + const closePen = () => setTool("pencilOff"); + + const handleEndLectureClick = () => { + closePen(); + setEndOpen(true); + }; + + const router = useRouter(); + const { lectureId } = useParams<{ lectureId: string }>(); + + const handleConfirmEnd = async () => { + if (isRecording) { + try { + await engine.stop(); + } catch (e) { + console.error(e); + } + } + setEndOpen(false); + onEndLecture?.(); + + router.push(ROUTES.teacherLectureDetail(lectureId)); + }; + + const handleCancelEnd = () => setEndOpen(false); + + return ( +
+
+
+ + + + setOpenDoc((v) => !v)} + icon={} + /> + + + setOpenDoc(false)} + align="start" + side="bottom" + > + setOpenDoc(false)} /> + + + + + + + + + { + closePen(); + onToggleChat?.(); + }} + /> +
+ + + 강의 종료 + +
+ + {endOpen && ( + + {isRecording ? ( + <> + 지금 녹음이 진행 중입니다. +
+ 종료하면 현재까지의 녹음이 저장됩니다. +
+
+ 종료하시겠습니까? + + ) : ( + <>강의를 종료하시겠습니까? + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx new file mode 100644 index 00000000..0c0d9150 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureLiveProvider.tsx @@ -0,0 +1,148 @@ +"use client"; + +import React, { createContext, useContext, useMemo, useState, useRef, useCallback } from "react"; + +export type DocType = "pdf" | "pptx" | "unknown"; +type DocState = { url: string; type: DocType; name: string }; + +export type Tool = "pencilOff" | "pen" | "eraser" | "highlighter"; +export type Panel = "files" | "chat"; + +type PenOptions = { color: string; size: number }; +type HighlighterOptions = { color: string; size: number; alpha: number }; +type EraserOptions = { size: number }; + + +interface LiveState { + tool: Tool; + isDrawing: boolean; + + panels: Record; + + pen: PenOptions; + highlighter: HighlighterOptions; + eraser: EraserOptions; + doc: DocState; + + setDoc: (d: DocState) => void; + setTool: (t: Tool) => void; + togglePanel: (p: Panel) => void; + + setPen: (patch: Partial) => void; + setHighlighter: (patch: Partial) => void; + setEraser: (patch: Partial) => void; + + getPageCanvas: (page: number, w: number, h: number, dpr: number) => HTMLCanvasElement; + getPageCanvasOrNull: (page: number) => HTMLCanvasElement | null; + clearPage: (page: number) => void; + + resetDrawings: () => void; +} + +const LiveCtx = createContext(null); + +export function LectureLiveProvider({ children }: { children: React.ReactNode }) { + const [tool, setToolState] = useState("pencilOff"); + + const [panels, setPanels] = useState>({ + files: true, + chat: true, + }); + + const [pen, setPenState] = useState({ + color: "#111111", + size: 3, + }); + + const [highlighter, setHighlighterState] = useState({ + color: "#fcf1b7", + size: 12, + alpha: 0.02, + }); + + const [doc, setDocState] = useState({ + url: "/file/기말보고서_졸업을하자.pdf", + type: "pdf", + name: "기말보고서_졸업을하자.pdf", + }); + + const drawStoreRef = useRef>(new Map()); + const resetDrawings = useCallback(() => drawStoreRef.current.clear(), []); + + const [eraser, setEraserState] = useState({ size: 12 }); + + const setDoc = (d: DocState) => { + setDocState(d); + resetDrawings(); + window.dispatchEvent(new CustomEvent("live:doc-changed")); + }; + + + const getPageCanvas = useCallback((page: number, w: number, h: number, dpr: number) => { + let c = drawStoreRef.current.get(page); + if (!c) { + c = document.createElement("canvas"); + c.width = Math.max(1, Math.round(w * dpr)); + c.height = Math.max(1, Math.round(h * dpr)); + drawStoreRef.current.set(page, c); + } + return c; + }, []); + + const getPageCanvasOrNull = useCallback((page: number) => { + return drawStoreRef.current.get(page) ?? null; + }, []); + + const clearPage = useCallback((page: number) => { + const c = drawStoreRef.current.get(page); + if (c) c.getContext("2d")!.clearRect(0, 0, c.width, c.height); + }, []); + + const setTool = (t: Tool) => setToolState(t); + + const togglePanel = (p: Panel) => + setPanels((prev) => ({ ...prev, [p]: !prev[p] })); + + const setPen = (patch: Partial) => + setPenState((prev) => ({ ...prev, ...patch })); + + const setHighlighter = (patch: Partial) => + setHighlighterState((prev) => ({ ...prev, ...patch })); + + const isDrawing = + tool === "pen" || tool === "eraser" || tool === "highlighter"; + + const setEraser = (patch: Partial) => + setEraserState((prev) => ({ ...prev, ...patch })); + + const value = useMemo( + () => ({ + tool, + isDrawing, + panels, + pen, + highlighter, + eraser, + doc, + setDoc, + resetDrawings, + getPageCanvas, + getPageCanvasOrNull, + clearPage, + setTool, + togglePanel, + setPen, + setHighlighter, + setEraser, + }), + [tool, isDrawing, panels, pen, highlighter, eraser, doc, resetDrawings, getPageCanvas, getPageCanvasOrNull, clearPage] + ); + + return {children}; +} + +export const useLive = () => { + const ctx = useContext(LiveCtx); + if (!ctx) throw new Error("useLive must be used within LectureLiveProvider"); + return ctx; +}; \ No newline at end of file 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 new file mode 100644 index 00000000..44ccb2dd --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.module.scss @@ -0,0 +1,54 @@ +@import "@/styles/variables"; + +.grid { + --left-col: 200px; + --right-col: 360px; + + --grid-gap: 10px; + --grid-pad: 10px; + + + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + + display: grid; + grid-template-columns: var(--left-col) 1fr var(--right-col); + gap: var(--grid-gap); + padding: var(--grid-pad); +} + + +.left, .center, .right { + border: 1px solid $color-neutral-7; + border-radius: 16px; + background: $color-white; + + + min-height: 0; + overflow: hidden; +} + + +.left { overflow-y: auto; } +.right { overflow-y: auto; } + + +.center { + display: flex; + flex-direction: column; + padding: $spacing-xs; + min-height: 0; + position: relative; + isolation: isolate; +} + + +@media (max-width: 767px) { + .grid { + --left-col: 0px; + --right-col: 0px; + grid-template-columns: 1fr; + } +} \ 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 new file mode 100644 index 00000000..e0aaa8c0 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import styles from "./LectureMainGrid.module.scss"; +import { useLive } from "../LectureLiveProvider"; +import ChatPanel from "../Chating/ChatingPanel/ChatingPanel"; +import dynamic from "next/dynamic"; + +const PageThumbsSidebar = dynamic( + () => import("../LectureNote/PageThumbsSidebar/PageThumbsSidebar"), + { ssr: false, loading: () => null } +); + +const DocumentViewer = dynamic( + () => import("../LectureNote/DocumentViewer/DocumentViewer"), + { ssr: false, loading: () => null } +); + +const DrawingCanvasOverlay = dynamic( + () => import("../LectureNote/DrawingCanvasOverlay/DrawingCanvasOverlay"), + { ssr: false, loading: () => null } +); + +export default function LectureMainGrid() { + const { panels, doc } = useLive(); + const [count, setCount] = useState(1); + const [currentPage, setCurrentPage] = useState(0); + + const resolvedType: "pdf" | "pptx" | undefined = + doc.type === "pdf" || doc.type === "pptx" ? doc.type : undefined; + + useEffect(() => { + const resetOnDocChange = () => setCurrentPage(0); + window.addEventListener("live:doc-changed", resetOnDocChange); + return () => window.removeEventListener("live:doc-changed", resetOnDocChange); + }, []); + + useEffect(() => { + const isTypingTarget = (t: EventTarget | null) => { + if (!(t instanceof HTMLElement)) return false; + const tag = t.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || t.isContentEditable; + }; + + const onKey = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return; + + if (e.key === " " || e.key === "Enter" || e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + setCurrentPage((p) => Math.min(count - 1, p + 1)); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + setCurrentPage((p) => Math.max(0, p - 1)); + } + }; + + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [count, doc.url]); + + return ( +
+ + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.module.scss new file mode 100644 index 00000000..5a033a93 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.module.scss @@ -0,0 +1,34 @@ +@import "@/styles/variables"; + +.viewer { + height: 100%; + display: grid; + grid-template-rows: 1fr; + min-height: 0; +} + +.stage { + position: relative; + height: 100%; + min-height: 0; + overflow: hidden; + display: grid; + place-items: center; + padding: 1px; + isolation: isolate; +} + +.docBox { + position: relative; + display: grid; + place-items: center; + z-index: 0; +} + +.pptxFrame { + width: 100%; + height: 100%; + border: 0; + position: relative; + z-index: 0; +} \ No newline at end of file diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.tsx new file mode 100644 index 00000000..b8b79903 --- /dev/null +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/DocumentViewer/DocumentViewer.tsx @@ -0,0 +1,128 @@ +"use client"; + +import styles from "./DocumentViewer.module.scss"; +import { useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import { DocType } from "../../LectureLiveProvider"; +import type { PageProps } from "react-pdf"; + + +const PDFDocument = dynamic(() => import("react-pdf").then(m => m.Document), { ssr: false }); +const PDFPage = dynamic(() => import("react-pdf").then(m => m.Page), { ssr: false }); + +export function getDocType(url: string): DocType { + const m = url.split("?")[0].toLowerCase(); + if (m.endsWith(".pdf")) return "pdf"; + if (m.endsWith(".pptx")) return "pptx"; + return "unknown"; +} + +export function toAbsoluteUrl(pathOrUrl: string) { + if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl; + if (typeof window === "undefined") return pathOrUrl; + return new URL(pathOrUrl, window.location.origin).toString(); +} + +export default function DocumentViewer({ + fileUrl, + currentPage, + onChangePage, + onLoad, + typeOverride, +}: { + fileUrl: string; + currentPage: number; + onChangePage: (i: number) => void; + onLoad?: (numPages: number) => void; + typeOverride?: DocType; +}) { + const type = useMemo( + () => (typeOverride && typeOverride !== "unknown" ? typeOverride : getDocType(fileUrl)), + [fileUrl, typeOverride] + ); + + useEffect(() => { + if (type !== "pdf") return; + (async () => { + const m = await import("react-pdf"); + m.pdfjs.GlobalWorkerOptions.workerSrc = + `https://unpkg.com/pdfjs-dist@${m.pdfjs.version}/build/pdf.worker.min.mjs`; + })(); + }, [type]); + + const stageRef = useRef(null); + const [stageW, setStageW] = useState(0); + const [stageH, setStageH] = useState(0); + const [naturalW, setNaturalW] = useState(1); + const [naturalH, setNaturalH] = useState(1); + + useEffect(() => { + if (!stageRef.current) return; + const el = stageRef.current; + const measure = () => { + const cs = getComputedStyle(el); + const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); + const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); + setStageW(el.clientWidth - padX); + setStageH(el.clientHeight - padY); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const SAFE_PAD = 2; + const scale = + type === "pdf" && naturalW && naturalH + ? Math.min((stageW - SAFE_PAD) / naturalW, (stageH - SAFE_PAD) / naturalH) + : 1; + + const handleDocLoad = ({ numPages }: { numPages: number }) => { + if (type !== "pdf") return; + onLoad?.(numPages); + if (currentPage > numPages - 1) onChangePage(numPages - 1); + }; + +const handlePageLoad: NonNullable = (page) => { + if (type !== "pdf") return; + const vp = page.getViewport({ scale: 1 }); + setNaturalW(vp.width); + setNaturalH(vp.height); + }; + + const docBoxStyle: React.CSSProperties = + type === "pdf" + ? { width: Math.max(1, naturalW * scale), height: Math.max(1, naturalH * scale) } + : { width: "100%", height: "100%" }; + + return ( +
+
+
+ {type === "pdf" && ( + + + + )} + + {type === "pptx" && ( +