diff --git a/src/main/front/src/admin-club/pages/AdminCourseDetailPage/SectionList.tsx b/src/main/front/src/admin-club/pages/AdminCourseDetailPage/SectionList.tsx index 90e778ff..a2f487db 100644 --- a/src/main/front/src/admin-club/pages/AdminCourseDetailPage/SectionList.tsx +++ b/src/main/front/src/admin-club/pages/AdminCourseDetailPage/SectionList.tsx @@ -334,6 +334,9 @@ const SectionList: React.FC = ({ { diff --git a/src/main/front/src/admin-club/pages/AdminCourseDetailPage/components/SectionCourses.tsx b/src/main/front/src/admin-club/pages/AdminCourseDetailPage/components/SectionCourses.tsx index 751e6c4c..b3807b3e 100644 --- a/src/main/front/src/admin-club/pages/AdminCourseDetailPage/components/SectionCourses.tsx +++ b/src/main/front/src/admin-club/pages/AdminCourseDetailPage/components/SectionCourses.tsx @@ -21,6 +21,9 @@ export interface Node { export interface SectionCoursesProps { title: string; description: string; + clubSlug?: string; + courseSlug?: string; + nodeGroupId: string; nodes: Node[]; onMove?: () => void; onDelete?: () => void; @@ -55,6 +58,9 @@ const SectionCourses: React.FC = ({ onMoveDown, disableMoveUp, disableMoveDown, + clubSlug = "", + courseSlug = "", + nodeGroupId, }) => { return ( = ({ justifyContent="space-between" mb={0.2} > - + { + window.open( + `/club/${clubSlug}/course/${courseSlug}/nodegroup/${nodeGroupId}`, + "_blank" + ); + }} + sx={{ cursor: "pointer", "&:hover": { textDecoration: "underline" } }} + > {title} @@ -108,12 +124,13 @@ const SectionCourses: React.FC = ({ )} {onMove && ( - + )} + {onDelete && ( { + if (!data || !Array.isArray(data.nodes)) return; + const hasUploadingVideo = data.nodes.some( + (node: any) => + node.type === "VIDEO" && + node.data?.file?.status && + ["UPLOADED", "TRANSCODING"].includes(node.data.file.status) + ); + if (!hasUploadingVideo) return; + + const interval = setInterval(() => { + refetch && refetch(); + }, 3000); + return () => clearInterval(interval); + }, [data, refetch]); + // 노드 그룹 제목 수정 상태 const [editingTitle, setEditingTitle] = useState(false); const [title, setTitle] = useState(data?.title || ""); @@ -385,12 +402,6 @@ function AdminCourseNodeGroupPage() { > - - - diff --git a/src/main/front/src/admin-club/pages/AdminCourseNodeGroup/NodeVideo.tsx b/src/main/front/src/admin-club/pages/AdminCourseNodeGroup/NodeVideo.tsx index 40721036..94df67cd 100644 --- a/src/main/front/src/admin-club/pages/AdminCourseNodeGroup/NodeVideo.tsx +++ b/src/main/front/src/admin-club/pages/AdminCourseNodeGroup/NodeVideo.tsx @@ -16,7 +16,7 @@ const NodeVideo: React.FC = ({ node, refetch }) => { const [editing, setEditing] = React.useState(false); // 파일이 없거나, 수정 버튼을 누르면 업로드 UI 노출 - if (!node?.data?.file?.playlist || editing) { + if (!node?.data?.file || editing) { return ( = ({ node, refetch }) => { ); } + // 상태별 분기 처리 + const status = node.data.file.status; + const progress = node.data.file.progress; + + if (status === "TRANSCODING") { + return ( + + + 비디오 트랜스코딩 중... + + + + + + + + + {progress != null ? `${progress}%` : "진행률 계산 중..."} + + + + 트랜스코딩이 완료되면 영상이 표시됩니다. + + + + ); + } + + if (status !== "TRANSCODE_COMPLETED") { + // 실패, 업로드 중, 대기 등 기타 상태 + let message = ""; + switch (status) { + case "PENDING": + message = "비디오 업로드 대기 중입니다."; + break; + case "UPLOADING": + message = "비디오 업로드 중입니다."; + break; + case "UPLOADED": + message = + "비디오 업로드가 완료되었습니다. 트랜스코딩을 기다리는 중입니다."; + break; + case "TRANSCODE_FAILED": + message = "비디오 트랜스코딩에 실패했습니다. 다시 업로드해 주세요."; + break; + default: + message = `비디오 상태: ${status}`; + } + return ( + + + {message} + + + + ); + } + return ( diff --git a/src/main/front/src/admin-club/pages/AdminCoursePage.tsx b/src/main/front/src/admin-club/pages/AdminCoursePage.tsx index 3a5a1707..ebdd650f 100644 --- a/src/main/front/src/admin-club/pages/AdminCoursePage.tsx +++ b/src/main/front/src/admin-club/pages/AdminCoursePage.tsx @@ -55,7 +55,7 @@ function AdminCoursePage() { if (coursesLoading) return Loading...; return ( - + - {/* 신규 코스 생성 다이얼로그 */} - setAddDialogOpen(false)}> - 신규 코스 생성 - - - - setNewCourse((c) => ({ ...c, title: e.target.value })) - } - fullWidth - /> - - setNewCourse((c) => ({ - ...c, - slug: e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, ""), - })) - } - helperText="영문 소문자, 숫자, 하이픈만 사용 가능" - /> - - setNewCourse((c) => ({ ...c, description: e.target.value })) - } - fullWidth - multiline - minRows={2} - /> - - 공개 여부 - - - - - - - - - - + ({ ...course, @@ -186,6 +90,101 @@ function AdminCoursePage() { /> + + {/* 신규 코스 생성 다이얼로그 */} + setAddDialogOpen(false)}> + 신규 코스 생성 + + + + setNewCourse((c) => ({ ...c, title: e.target.value })) + } + fullWidth + /> + + setNewCourse((c) => ({ + ...c, + slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + })) + } + helperText="영문 소문자, 숫자, 하이픈만 사용 가능" + /> + + setNewCourse((c) => ({ ...c, description: e.target.value })) + } + fullWidth + multiline + minRows={2} + /> + + 공개 여부 + + + + + + + + + ); } diff --git a/src/main/front/src/admin-club/pages/AdminProgramListPage.tsx b/src/main/front/src/admin-club/pages/AdminProgramListPage.tsx index 41a5bcb9..72d75fa0 100644 --- a/src/main/front/src/admin-club/pages/AdminProgramListPage.tsx +++ b/src/main/front/src/admin-club/pages/AdminProgramListPage.tsx @@ -125,7 +125,7 @@ function AdminProgramListPage() { edge="end" aria-label="view" component={RouterLink} - to={`/club/${club}/program/${program.id}`} + to={`/club/${club}/program/${program.slug}`} target="_blank" rel="noopener noreferrer" > diff --git a/src/main/front/src/components/ClubPage/ClubBadge.tsx b/src/main/front/src/components/ClubPage/ClubBadge.tsx index 3709fc06..3127fde2 100644 --- a/src/main/front/src/components/ClubPage/ClubBadge.tsx +++ b/src/main/front/src/components/ClubPage/ClubBadge.tsx @@ -5,9 +5,16 @@ import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; export interface ClubBadgeProps { text: string; hoverable?: boolean; + noEmoji?: boolean; + background?: React.CSSProperties["background"]; } -const ClubBadge: React.FC = ({ text, hoverable }) => { +const ClubBadge: React.FC = ({ + text, + hoverable, + noEmoji, + background, +}) => { return ( = ({ text, hoverable }) => { display: "inline-flex", alignItems: "center", gap: 1, - background: "rgba(250, 250, 250, 0.14)", + background: background || "rgba(250, 250, 250, 0.14)", color: "#fff", borderRadius: 2, mt: 1, @@ -41,7 +48,9 @@ const ClubBadge: React.FC = ({ text, hoverable }) => { : {}), }} > - + {!noEmoji && ( + + )} + {(filteredPrograms.length === 0 || + filteredPrograms.every((p) => p.isParticipant === "0")) && ( + + + + )} {filteredPrograms.map((program: any) => { const isParticipant = program.isParticipant === "1"; diff --git a/src/main/front/src/components/NodeGroupPage/ImagePreviewWithDownload.tsx b/src/main/front/src/components/NodeGroupPage/ImagePreviewWithDownload.tsx index c61745c9..7017be5e 100644 --- a/src/main/front/src/components/NodeGroupPage/ImagePreviewWithDownload.tsx +++ b/src/main/front/src/components/NodeGroupPage/ImagePreviewWithDownload.tsx @@ -37,7 +37,7 @@ const ImagePreviewWithDownload: React.FC = ({ src, filename }) => { height: "100%", width: "100%", borderRadius: "12px", - objectFit: "cover", + objectFit: "contain", }} /> diff --git a/src/main/front/src/components/NodeGroupPage/MarkdownViwer.tsx b/src/main/front/src/components/NodeGroupPage/MarkdownViwer.tsx index 33fc26a7..8adcc2b5 100644 --- a/src/main/front/src/components/NodeGroupPage/MarkdownViwer.tsx +++ b/src/main/front/src/components/NodeGroupPage/MarkdownViwer.tsx @@ -11,21 +11,40 @@ const MarkdownViewer: React.FC = ({ content }) => { ( - + ), h2: ({ ...props }) => ( - + + ), + h3: ({ ...props }) => ( + ), p: ({ ...props }) => ( - + ), li: ({ ...props }) => (
  • diff --git a/src/main/front/src/components/NodeGroupPage/NextButton.tsx b/src/main/front/src/components/NodeGroupPage/NextButton.tsx index e9200993..232e7b48 100644 --- a/src/main/front/src/components/NodeGroupPage/NextButton.tsx +++ b/src/main/front/src/components/NodeGroupPage/NextButton.tsx @@ -1,6 +1,14 @@ -import React from "react"; +import React, { useState } from "react"; import { useNavigate, useParams } from "react-router"; import { useFetchBe } from "../../tools/api"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from "@mui/material"; type NextNodeGroupButtonProps = { currentNodeGroupId: string; @@ -9,18 +17,17 @@ type NextNodeGroupButtonProps = { function NextNodeGroupButton({ currentNodeGroupId }: NextNodeGroupButtonProps) { const navigate = useNavigate(); const fetchBe = useFetchBe(); - const { club, course_name } = useParams<{ club: string; course_name: string; }>(); + const [openModal, setOpenModal] = useState(false); const fetchNextNodeGroup = async (nodeGroupId: string) => { try { const response = await fetchBe( `/v1/node-group/next?nodeGroupId=${nodeGroupId}` ); - console.log("return: " + response); return response; } catch (error) { console.error("다음 노드그룹 요청 실패:", error); @@ -44,7 +51,7 @@ function NextNodeGroupButton({ currentNodeGroupId }: NextNodeGroupButtonProps) { `/club/${club}/course/${course_name}/nodegroup/${next.nodeGroupId}` ); } else { - alert("마지막 노드 그룹입니다."); + setOpenModal(true); } } catch (error) { console.error("노드 그룹 이동 중 오류:", error); @@ -52,13 +59,45 @@ function NextNodeGroupButton({ currentNodeGroupId }: NextNodeGroupButtonProps) { } }; + const handleGoToCourse = () => { + if (club && course_name) { + navigate(`/club/${club}/course/${course_name}`); + } else { + navigate("/club"); + } + }; + return ( - + <> + + setOpenModal(false)}> + 마지막 강의입니다 + + + 이 코스의 모든 강의를 완료했습니다. +
    + 코스 페이지로 돌아가시겠습니까? +
    +
    + + + + +
    + ); } diff --git a/src/main/front/src/components/course/CourseItem.tsx b/src/main/front/src/components/course/CourseItem.tsx index 47560375..d40117de 100644 --- a/src/main/front/src/components/course/CourseItem.tsx +++ b/src/main/front/src/components/course/CourseItem.tsx @@ -23,7 +23,7 @@ const CourseItem: React.FC = ({ component={Link} to={url} sx={{ - maxWidth: 345, + // maxWidth: 345, position: "relative", overflow: "hidden", transition: diff --git a/src/main/front/src/components/course/CourseList.tsx b/src/main/front/src/components/course/CourseList.tsx index e2f90b4c..357b935d 100644 --- a/src/main/front/src/components/course/CourseList.tsx +++ b/src/main/front/src/components/course/CourseList.tsx @@ -1,6 +1,7 @@ import { Box } from "@mui/system"; import CourseItem from "./CourseItem"; import { useParams } from "react-router"; +import { Grid } from "@mui/material"; export interface CourseListProps { courses?: Array<{ @@ -17,17 +18,18 @@ function CourseList({ courses }: CourseListProps) { const { club } = useParams<{ club: string }>(); if (!courses || courses.length === 0) return No courses available; return ( - + {courses?.map((course, idx) => ( - + + + ))} - + ); } diff --git a/src/main/front/src/pages/CoursePage.tsx b/src/main/front/src/pages/CoursePage.tsx index 9e3947db..e971debe 100644 --- a/src/main/front/src/pages/CoursePage.tsx +++ b/src/main/front/src/pages/CoursePage.tsx @@ -17,6 +17,7 @@ import { useParams, useNavigate } from "react-router"; import type { CourseData } from "../types/courseData.types"; import { LatestComment } from "../types/latestComment.types"; import ClubRunningProgramBanner from "../components/ClubPage/ClubRunningProgramBanner"; +import { currentProgram } from "../utils/currentProgram"; function CoursePage() { const { userId } = useUserData(); @@ -32,6 +33,11 @@ function CoursePage() { queryFn: () => fetchBe("/v1/user/programs"), }); + const { data: programs, isLoading: programsLoading } = useQuery({ + queryKey: ["clubPrograms", clubSlug], + queryFn: () => fetchBe(`/v1/clubs/${clubSlug}/programs`), + }); + // 현재 club에 해당하는 programSlug 찾기 const myProgram = (myPrograms ?? []).find((p) => p.clubSlug === clubSlug); const programSlug = myProgram?.slug; @@ -126,6 +132,9 @@ function CoursePage() { percent = total > 0 ? Math.round((completed / total) * 1000) / 10 / 100 : 0; } + const filteredPrograms = currentProgram(programs ?? []); + console.log(filteredPrograms); + return ( @@ -186,28 +195,32 @@ function CoursePage() { ({ - ...section, - nodeGroups: (section.nodeGroups ?? []).map((group) => { - // nodeGroup 완료 여부 및 진행중 여부 계산 - let isCompleted = false; - let isInProgress = false; - if (courseData && myProgress) { - const courseId = courseData.id; - const courseProgress = myProgress.courseProgress[courseId]; - if (courseProgress?.map) { - const state = courseProgress.map?.[group.id]; - isCompleted = state === "DONE"; - isInProgress = state === "IN_PROGRESS"; - } - } - return { - ...group, - isCompleted, - isInProgress, - }; - }), - }))} + sections={(courseData?.sections ?? []) + .sort((sec1, sec2) => sec1.order - sec2.order) + .map((section) => ({ + ...section, + nodeGroups: (section.nodeGroups ?? []) + .sort((g1, g2) => g1.order - g2.order) + .map((group) => { + // nodeGroup 완료 여부 및 진행중 여부 계산 + let isCompleted = false; + let isInProgress = false; + if (courseData && myProgress) { + const courseId = courseData.id; + const courseProgress = myProgress.courseProgress[courseId]; + if (courseProgress?.map) { + const state = courseProgress.map?.[group.id]; + isCompleted = state === "DONE"; + isInProgress = state === "IN_PROGRESS"; + } + } + return { + ...group, + isCompleted, + isInProgress, + }; + }), + }))} width={260} /> @@ -221,21 +234,40 @@ function CoursePage() { 지금까지 학습한 진도율을 확인하세요. - - - - - 진도율 - - {completed}/{total} - - - - 남은 강의 - - {total - completed}개 + {filteredPrograms.length === 0 || + filteredPrograms.every((p) => p.isParticipant === "0") ? ( + + 진행중인 프로그램이 없습니다. - + ) : ( + <> + + + + + 진도율 + + {completed}/{total} + + + + 남은 강의 + + {total - completed}개 + + + + )} } @@ -331,68 +363,74 @@ function CoursePage() { - {(courseData?.sections ?? []).map((section) => ( - -
    - {(section.nodeGroups ?? []).map((group) => ( - - { - let title = ""; - switch (node.type) { - case "VIDEO": - case "IMAGE": - case "FILE": - case "TEXT": - title = node.data?.title ?? ""; - break; - case "QUIZ": - title = node.data?.question ?? ""; - break; - default: - title = ""; - } - return { - id: node.id, - type: - node.type === "FILE" - ? "doc" - : (node.type.toLowerCase() as - | "video" - | "image" - | "quiz" - | "doc" - | "file" - | "text"), - title, - }; - }) - : [] - } - onTitleClick={() => { - console.log("group id", group.id); - if (clubSlug && courseSlug && group.id) { - navigate( - `/club/${clubSlug}/course/${courseSlug}/nodegroup/${group.id}` - ); - } - }} - onNodeClick={() => { - if (clubSlug && courseSlug && group.id) { - navigate( - `/club/${clubSlug}/course/${courseSlug}/nodegroup/${group.id}` - ); - } - }} - /> - - ))} - - ))} + {(courseData?.sections ?? []) + .sort((a, b) => a.order - b.order) + .map((section) => ( + +
    + {(section.nodeGroups ?? []) + .sort((a, b) => a.order - b.order) + .map((group) => ( + + a.order - b.order) + .map((node) => { + let title = ""; + switch (node.type) { + case "VIDEO": + case "IMAGE": + case "FILE": + case "TEXT": + title = node.data?.title ?? ""; + break; + case "QUIZ": + title = node.data?.question ?? ""; + break; + default: + title = ""; + } + return { + id: node.id, + type: + node.type === "FILE" + ? "doc" + : (node.type.toLowerCase() as + | "video" + | "image" + | "quiz" + | "doc" + | "file" + | "text"), + title, + }; + }) + : [] + } + onTitleClick={() => { + console.log("group id", group.id); + if (clubSlug && courseSlug && group.id) { + navigate( + `/club/${clubSlug}/course/${courseSlug}/nodegroup/${group.id}` + ); + } + }} + onNodeClick={() => { + if (clubSlug && courseSlug && group.id) { + navigate( + `/club/${clubSlug}/course/${courseSlug}/nodegroup/${group.id}` + ); + } + }} + /> + + ))} + + ))} diff --git a/src/main/front/src/pages/NodeGroupPage.tsx b/src/main/front/src/pages/NodeGroupPage.tsx index 8c55b9a8..4bc5fd93 100644 --- a/src/main/front/src/pages/NodeGroupPage.tsx +++ b/src/main/front/src/pages/NodeGroupPage.tsx @@ -25,7 +25,7 @@ import ClubRunningProgramBanner from "../components/ClubPage/ClubRunningProgramB const nodeHeightMap: Record = { video: 600, file: 100, - image: 500, + image: "auto", quiz: "auto", text: "auto", }; @@ -58,12 +58,29 @@ function NodeGroupPage() { data: nodeGroupData, isLoading, error, + refetch, } = useQuery({ queryKey: ["node-group", nodeGroupUUID], queryFn: () => fetchBe(`/v1/node-group/${nodeGroupUUID}`), enabled: !!nodeGroupUUID, // UUID 있을 때만 실행 }); + // 트랜스코딩 중인 비디오 노드가 있으면 3초마다 polling + useEffect(() => { + if (!nodeGroupData?.nodes) return; + const hasTranscoding = nodeGroupData.nodes.some( + (node) => + node.type === "VIDEO" && + node.data?.file?.status && + ["UPLOADED", "TRANSCODING"].includes(node.data.file.status) + ); + if (!hasTranscoding) return; + const interval = setInterval(() => { + refetch && refetch(); + }, 3000); + return () => clearInterval(interval); + }, [nodeGroupData, refetch]); + useEffect(() => { if (!nodeGroupData?.id) return; fetchBe("/v1/progress/start", { @@ -102,212 +119,328 @@ function NodeGroupPage() { {/* 노드 목록 */} - {nodeGroupData.nodes.map((node, index) => { - const emojiCountMap: Record = {}; + {nodeGroupData.nodes + .sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)) + .map((node, index) => { + const emojiCountMap: Record = {}; - const emojiSummary = Object.entries(emojiCountMap) - .map(([emoji]) => `${emoji}`) - .join(" "); + const emojiSummary = Object.entries(emojiCountMap) + .map(([emoji]) => `${emoji}`) + .join(" "); - const isOpen = openNodeId === node.id; + const isOpen = openNodeId === node.id; - return ( - // 노드의 완전 겉부분, 댓글+노드내용 - - {/* 노드 번호, 제목, 댓글 부분 */} + return ( + // 노드의 완전 겉부분, 댓글+노드내용 + + {/* 노드 번호, 제목, 댓글 부분 */} - - - - {node.data.title} - - - {!(node.type === "TEXT") && node.data.description} - - + + + + {node.data.title} + + + {!(node.type === "TEXT") && node.data.description} + + - - {/* 댓글 버튼 */} toggleComments(node.id)} + position="relative" // 댓글"창"의 위치 기준 > - toggleComments(node.id)} > - {emojiSummary} - - {node.comments.length === 0 ? ( - 댓글을 추가해보세요! - {" "} - - ) : ( - - {node.comments.length}{" "} + {emojiSummary} + {node.comments.length === 0 ? ( + + 댓글을 추가해보세요! + {" "} + + ) : ( + + {node.comments.length}{" "} + + )} + + + {isOpen && ( + + alert("submit")} + /> + )} - - {isOpen && ( - - alert("submit")} - /> - - )} - - - {/* 노드 안쪽내용 (댓글 아래) */} - - {/* 콘텐츠 영역 */} + {/* 노드 안쪽내용 (댓글 아래) */} + {/* 콘텐츠 영역 */} + - {/* 콘텐츠 조건 분기 */} - {node.type === "VIDEO" && node.data?.file?.playlist ? ( - - ) : node.type === "IMAGE" && - node.data?.file?.presignedUrl ? ( - - ) : node.type === "FILE" && - node.data?.file?.presignedUrl ? ( - - ) : node.type === "QUIZ" && - node.data?.question && - Array.isArray(node.data.options) && - typeof node.data.answer === "string" ? ( - node.data.answer.includes("&") ? ( - + {/* 콘텐츠 조건 분기 */} + {node.type === "VIDEO" ? ( + (() => { + const file = node.data?.file; + const status = file?.status; + const progress = file?.progress; + if (!file) { + return ( + + 비디오 파일 정보 없음 + + ); + } + if (status === "TRANSCODING") { + return ( + + + 비디오 트랜스코딩 중... + + + + + + + + + {progress != null + ? `${progress}%` + : "진행률 계산 중..."} + + + + 트랜스코딩이 완료되면 영상이 표시됩니다. + + + ); + } + if (status === "UPLOADED") { + return ( + + 비디오 업로드가 완료되었습니다. 트랜스코딩을 + 기다리는 중입니다. + + ); + } + if (status === "PENDING") { + return ( + + 비디오 업로드 대기 중입니다. + + ); + } + if (status === "UPLOADING") { + return ( + + 비디오 업로드 중입니다. + + ); + } + if (status === "TRANSCODE_FAILED") { + return ( + + 비디오 트랜스코딩에 실패했습니다. 다시 업로드해 + 주세요. + + ); + } + if ( + status === "TRANSCODE_COMPLETED" && + file.playlist + ) { + return ( + + ); + } + return ( + + 비디오 파일이 준비되지 않았습니다. + + ); + })() + ) : node.type === "IMAGE" && + node.data?.file?.presignedUrl ? ( + - ) : ( - - ) - ) : node.type === "TEXT" && node.data?.description ? ( - - ) : ( - // 👉 콘텐츠가 없을 때 표시되는 fallback 메시지 - - + ) : ( + + ) + ) : node.type === "TEXT" && node.data?.description ? ( + + ) : ( + // 👉 콘텐츠가 없을 때 표시되는 fallback 메시지 + - 아직 콘텐츠가 없습니다. - - - - )} + + 아직 콘텐츠가 없습니다. + + + + )} + - - ); - })} + ); + })} diff --git a/src/main/front/src/types/nodeGroupData.types.ts b/src/main/front/src/types/nodeGroupData.types.ts index c8cdd2e2..9c8fa511 100644 --- a/src/main/front/src/types/nodeGroupData.types.ts +++ b/src/main/front/src/types/nodeGroupData.types.ts @@ -15,6 +15,7 @@ export interface NodeFile { presignedUrl?: string; playlist?: string; status?: string; + progress?: number; } export interface NodeData {