-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: #BBB-130 기술 서적 스터디 해설 영상 업로드 버튼 구현 #39
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
'use client'; | ||
|
||
import { useEffect, useState } from 'react'; | ||
import { Button } from '@/components/ui/button/button'; | ||
import { Upload } from 'lucide-react'; | ||
import { | ||
completeMultipartUpload, | ||
generatePresignedUrl, | ||
generateUploadId, | ||
uploadVideo | ||
} from '@/lib/api/video/video-upload'; | ||
import checkVideoUploadStatus from '@/lib/api/study/check-video-upload-status'; | ||
import { toast } from 'react-toastify'; | ||
import { useRecoilState } from 'recoil'; | ||
import { userState } from '@/recoil/userAtom'; | ||
import { BookRound } from '@/types/study/study-detail'; | ||
import { VideoUploadButtonProps } from '@/types/study/video-upload-button'; | ||
|
||
export default function VideoUploadButton({ | ||
studyType, | ||
studyId, | ||
round | ||
}: VideoUploadButtonProps) { | ||
const [myData, setMyData] = useRecoilState(userState); | ||
const [myAssignmentId, setMyAssignmentId] = useState<number>(); | ||
const [file, setFile] = useState<File | null>(null); | ||
|
||
useEffect(() => { | ||
if (studyType === 'BOOK' && myData) { | ||
const BookRound = round as BookRound; | ||
setMyAssignmentId(BookRound.users[myData.id].assignmentId); | ||
} | ||
}, [studyType, myData, round]); | ||
|
||
if (myAssignmentId == null) return null; | ||
|
||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||
if (event.target.files && event.target.files[0]) { | ||
setFile(event.target.files[0]); | ||
} | ||
}; | ||
|
||
const handleUpload = async () => { | ||
if (file == null) return; | ||
|
||
const completedParts = []; | ||
const respose = await generateUploadId(studyId, myAssignmentId); | ||
const uploadId = respose.data.uploadId; | ||
|
||
const partSize = 5 * 1024 * 1024; | ||
const totalParts = Math.ceil(file.size / partSize); | ||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) { | ||
const start = (partNumber - 1) * partSize; | ||
const end = Math.min(start + partSize, file.size + 1); | ||
const chunk = file.slice(start, end); | ||
const response = await generatePresignedUrl( | ||
uploadId, | ||
partNumber, | ||
chunk.size, | ||
studyId, | ||
myAssignmentId | ||
); | ||
const presignedUrl = response.data.presignedUrl; | ||
const result = await uploadVideo(presignedUrl, chunk); | ||
const eTag = result.headers.etag; | ||
completedParts.push({ partNumber, eTag }); | ||
} | ||
|
||
const completeResponse = await completeMultipartUpload( | ||
uploadId, | ||
completedParts, | ||
studyId, | ||
myAssignmentId | ||
); | ||
|
||
if (completeResponse.status === 200) { | ||
console.log(myAssignmentId); | ||
checkVideoUploadStatus(studyId, myAssignmentId).then((response) => { | ||
if (response.status === 200) { | ||
toast.success('업로드에 성공했습니다.'); | ||
} else { | ||
toast.error('업로드에 실패했습니다.'); | ||
} | ||
}); | ||
} else { | ||
toast.error('업로드에 실패했습니다.'); | ||
} | ||
setFile(null); | ||
}; | ||
|
||
return ( | ||
<div className="flex justify-end space-x-2 flex-shrink-0"> | ||
<input | ||
type="file" | ||
id="assignment-upload" | ||
className="sr-only" | ||
onChange={handleFileChange} | ||
accept=".mp4,.mov,.avi,.mkv,.wmv,.flv,.webm" | ||
/> | ||
<label htmlFor="assignment-upload" className="cursor-pointer"> | ||
<Button size="sm" variant="outline" asChild> | ||
<span> | ||
<Upload className="w-4 h-4 mr-2" /> | ||
{file ? file.name : '과제 업로드'} | ||
</span> | ||
</Button> | ||
</label> | ||
{file && ( | ||
<Button size="sm" onClick={handleUpload}> | ||
업로드 | ||
</Button> | ||
)} | ||
</div> | ||
); | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,6 +27,7 @@ import { useParams } from 'next/navigation'; | |
import { useRecoilState } from 'recoil'; | ||
import FeedbackDialog from '../feedback-dialog'; | ||
import { BookRow } from './book-row'; | ||
import VideoUploadButton from '@/components/study/button/video-upload-button'; | ||
|
||
export default function StudyDashBoard({ | ||
details, | ||
|
@@ -43,7 +44,12 @@ export default function StudyDashBoard({ | |
if (studyType === StudyType.ALGORITHM) { | ||
return ( | ||
<div className="mt-5 bg-background rounded-lg border p-6 w-full max-w-4xl h-full"> | ||
<DashBoardHeader round={round} setRound={setRound} details={details} /> | ||
<DashBoardHeader | ||
studyId={studyId} | ||
round={round} | ||
setRound={setRound} | ||
details={details} | ||
/> | ||
<AlgorithmDashBoardBody | ||
round={round as AlgorithmRound} | ||
studyId={studyId} | ||
|
@@ -53,7 +59,12 @@ export default function StudyDashBoard({ | |
} else if (studyType === StudyType.BOOK) { | ||
return ( | ||
<div className="mt-5 bg-background rounded-lg border p-6 w-full max-w-4xl h-full"> | ||
<DashBoardHeader round={round} setRound={setRound} details={details} /> | ||
<DashBoardHeader | ||
studyId={studyId} | ||
round={round as BookRound} | ||
setRound={setRound} | ||
details={details} | ||
/> | ||
<BookDashBoardBody round={round as BookRound} studyId={studyId} /> | ||
</div> | ||
); | ||
|
@@ -147,18 +158,29 @@ function BookDashBoardBody({ | |
} | ||
|
||
function DashBoardHeader({ | ||
studyId, | ||
round, | ||
setRound, | ||
details | ||
}: { | ||
studyId: number; | ||
details: StudyDetails; | ||
round: Round; | ||
setRound: (round: Round) => void; | ||
}) { | ||
return ( | ||
<div className="flex items-center justify-between mb-6"> | ||
<h2 className="text-2xl font-bold">{details.name}</h2> | ||
<SelectRound round={round} setRound={setRound} /> | ||
<div className="flex space-x-5"> | ||
{details.studyType === 'BOOK' ? ( | ||
<VideoUploadButton | ||
studyType={details.studyType} | ||
studyId={studyId} | ||
round={round} | ||
/> | ||
) : null} | ||
<SelectRound round={round} setRound={setRound} /> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
@@ -187,7 +209,7 @@ function SelectRound({ | |
}; | ||
|
||
return ( | ||
<div className="flex items-center w-1/5 justify-end"> | ||
<div className="flex items-center justify-end"> | ||
<Select | ||
value={(round.idx + 1).toString()} | ||
onValueChange={handleRoundChange} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 리뷰를 진행하겠습니다.
이러한 점을 반영하여 코드 품질을 향상시킬 수 있을 것입니다. 추가 질문이 있으시면 언제든지 말씀해 주세요! |
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import axios from 'axios'; | ||
import { getAuthenticationConfig } from '@/lib/utils'; | ||
|
||
/** | ||
* 해설 영상 업로드 완료 요청 API | ||
* | ||
* @param studyId | ||
* @param assignmentId | ||
*/ | ||
export default function checkVideoUploadStatus( | ||
studyId: number, | ||
assignmentId: number | ||
) { | ||
return axios.post( | ||
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/v1/studies/${studyId}/upload-status`, | ||
{ assignmentId: assignmentId }, | ||
getAuthenticationConfig() | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 패치를 검토해본 결과, 몇 가지 개선점과 잠재적인 버그 리스크가 보입니다.
이러한 사항들을 반영하면 코드의 안정성과 가독성이 높아질 것으로 보입니다. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import axios from 'axios'; | ||
import { getAuthenticationConfig } from '@/lib/utils'; | ||
|
||
/** | ||
* AWS S3 multipart 업로드 id 생성 API | ||
* | ||
* @param studyId | ||
* @param assignmentId | ||
*/ | ||
export async function generateUploadId(studyId: number, assignmentId: number) { | ||
return axios.post( | ||
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/v1/videos/initiate-upload`, | ||
{ studyId, assignmentId }, | ||
getAuthenticationConfig() | ||
); | ||
} | ||
|
||
/** | ||
* AWS S3 presigned url 생성 API | ||
* | ||
* @param uploadId | ||
* @param partNumber | ||
* @param partSize | ||
* @param studyId | ||
* @param assignmentId | ||
*/ | ||
export async function generatePresignedUrl( | ||
uploadId: string, | ||
partNumber: number, | ||
partSize: number, | ||
studyId: number, | ||
assignmentId: number | ||
) { | ||
return axios.post( | ||
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/v1/videos/presigned-url`, | ||
{ uploadId, partNumber, partSize, studyId, assignmentId }, | ||
getAuthenticationConfig() | ||
); | ||
} | ||
|
||
/** | ||
* AWS S3에 비디오 업로드 API | ||
* | ||
* @param presignedUrl | ||
* @param file | ||
*/ | ||
export async function uploadVideo(presignedUrl: string, file: File | Blob) { | ||
return axios.put(presignedUrl, file); | ||
} | ||
|
||
/** | ||
* AWS S3 multipart 업로드 완료 요청 API | ||
* | ||
* @param uploadId | ||
* @param parts | ||
* @param studyId | ||
* @param assignmentId | ||
*/ | ||
export async function completeMultipartUpload( | ||
uploadId: string, | ||
parts: { partNumber: number; eTag: string }[], | ||
studyId: number, | ||
assignmentId: number | ||
) { | ||
return axios.post( | ||
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/v1/videos/complete-upload`, | ||
{ uploadId, parts, studyId, assignmentId }, | ||
getAuthenticationConfig() | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 리뷰에 대해 의견을 말씀드리겠습니다.
이 점들을 고려하여 개선하면, 코드의 안정성 및 가독성이 높아질 것입니다. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { Round } from '@/types/study/study-detail'; | ||
|
||
export interface VideoUploadButtonProps { | ||
studyType: string; | ||
studyId: number; | ||
round: Round; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 패치에 대한 간단한 리뷰를 제공하겠습니다.
이외에도 현재 코드에는 큰 버그 리스크는 없어 보입니다. 추가적인 검토 사항이나 기능적 요구사항에 따라 더 깊이 있는 분석이 필요할 수 있습니다. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
코드 리뷰를 진행하겠습니다.
문제점 및 개선 사항
에러 처리 부족:
generateUploadId
,generatePresignedUrl
,uploadVideo
,completeMultipartUpload
,checkVideoUploadStatus
)에서 에러가 발생할 경우 적절한 에러 처리가 필요합니다. 현재 catch 블록이 없어서, 문제가 발생했을 때 사용자가 인지할 수 없습니다.중복 코드:
checkVideoUploadStatus
의 응답 처리 부분이completeMultipartUpload
의 응답 처리와 유사합니다. 오류 메시지가 중복되어 있으므로, 이를 함수로 뺄 수 있습니다.미사용 변수:
const completedParts = [];
는 비어있는 배열로 초기화되지만, 업로드가 실패할 경우 호출되지 않을 수 있습니다. 업로드가 실패해도 최종적으로 올바른 상태를 유지할 수 있도록 확인이 필요합니다.상태 초기화:
setFile(null)
을 호출하고 있지만, 파일 선택기에서 여전히 이전 파일의 이름이 표시됩니다. 나중에 사용자 경험을 고려하여 상태를 명확하게 관리하는 것이 좋습니다.파일 타입 확인:
스크롤 및 UI 상태 유지:
애플리케이션 상태 관리:
myAssignmentId
,file
상태가 변경될 때마다 조건부 렌더링을 사용하는데, 이는 효율성을 떨어뜨릴 수 있습니다. React의useMemo
훅을 활용하여 최적화할 수 있습니다.위의 개선 사항들을 고려하면 코드의 안정성과 사용자 경험을 크게 향상시킬 수 있습니다.