From 09ff2288e87f0d4c2f461e1c2784a906accdf0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 15 Feb 2026 16:58:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=97=90=20=EC=A7=84=ED=96=89=EB=A5=A0=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XHR upload.onprogress로 파일 업로드 실시간 진행률 표시 (0~50%) - AI 분석 단계 진행률 시뮬레이션 표시 (50~100%) - 버튼 내부에 반투명 progress fill 애니메이션 - 단계별 텍스트 변경: "업로드 중... 30%" → "AI 분석 중... 75%" - 업로드+분석을 ScreenshotUpload 내부에서 일괄 처리 Co-Authored-By: Claude Opus 4.6 --- .../views/video/tutorial/index.blade.php | 121 ++++++++++++++---- 1 file changed, 93 insertions(+), 28 deletions(-) diff --git a/resources/views/video/tutorial/index.blade.php b/resources/views/video/tutorial/index.blade.php index f23a1039..555f2c85 100644 --- a/resources/views/video/tutorial/index.blade.php +++ b/resources/views/video/tutorial/index.blade.php @@ -60,6 +60,16 @@ cursor: pointer; transition: all 0.2s; color: #9ca3af; flex-shrink: 0; } .screenshot-add-btn:hover { border-color: #818cf8; color: #6366f1; background: #f5f3ff; } + .progress-btn { + position: relative; overflow: hidden; min-width: 260px; + } + .progress-btn .progress-fill { + position: absolute; left: 0; top: 0; bottom: 0; + background: rgba(255,255,255,0.15); transition: width 0.3s ease-out; + } + .progress-btn .btn-content { + position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 0.5rem; + } .analysis-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,.05); @@ -130,12 +140,14 @@ // ============================================================ // Step 1: Screenshot Upload // ============================================================ -const ScreenshotUpload = ({ onUploadComplete, loading, setLoading }) => { +const ScreenshotUpload = ({ onUploadComplete, onAnalysisComplete, loading, setLoading }) => { const [files, setFiles] = useState([]); const [previews, setPreviews] = useState([]); const [pasteFlash, setPasteFlash] = useState(false); const [dragIdx, setDragIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); // 0~100 + const [uploadPhase, setUploadPhase] = useState(''); // '', 'uploading', 'analyzing' const fileInputRef = useRef(null); const filesRef = useRef(files); const previewsRef = useRef(previews); @@ -230,22 +242,74 @@ setDragOverIdx(null); }; + // XHR 업로드 (진행률 지원) + AI 분석까지 한번에 const handleUpload = async () => { if (files.length === 0) return; setLoading(true); + setUploadPhase('uploading'); + setUploadProgress(0); + try { + // Phase 1: 파일 업로드 (0~50%) const formData = new FormData(); files.forEach(f => formData.append('screenshots[]', f)); - const data = await api('/video/tutorial/upload', { - method: 'POST', - body: formData, - headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' }, + + const uploadResult = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/video/tutorial/upload'); + xhr.setRequestHeader('X-CSRF-TOKEN', CSRF); + xhr.setRequestHeader('Accept', 'application/json'); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 50); + setUploadProgress(pct); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)); + } else { + try { + const err = JSON.parse(xhr.responseText); + reject(new Error(err.message || `HTTP ${xhr.status}`)); + } catch { + reject(new Error(`HTTP ${xhr.status}`)); + } + } + }; + xhr.onerror = () => reject(new Error('네트워크 오류')); + xhr.send(formData); }); - onUploadComplete(data.paths); + + // Phase 2: AI 분석 (50~100%) + setUploadPhase('analyzing'); + setUploadProgress(55); + + // 분석은 시간이 걸리므로 진행률 시뮬레이션 + const analyzeTimer = setInterval(() => { + setUploadProgress(prev => prev < 90 ? prev + 3 : prev); + }, 800); + + const analyzeResult = await api('/video/tutorial/analyze', { + method: 'POST', + body: JSON.stringify({ paths: uploadResult.paths }), + }); + + clearInterval(analyzeTimer); + setUploadProgress(100); + + // 완료 후 잠시 대기 (100% 표시) + await new Promise(r => setTimeout(r, 400)); + + onAnalysisComplete(uploadResult.paths, analyzeResult.analysis); } catch (err) { alert(err.message); } finally { setLoading(false); + setUploadPhase(''); + setUploadProgress(0); } }; @@ -331,14 +395,26 @@ className={`screenshot-thumb ${dragIdx === i ? 'dragging' : ''} ${dragOverIdx == )} @@ -708,21 +784,10 @@ className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 const [tutorialId, setTutorialId] = useState(null); const [refreshKey, setRefreshKey] = useState(0); - const handleUploadComplete = async (uploadedPaths) => { + const handleAnalysisComplete = (uploadedPaths, analysisData) => { setPaths(uploadedPaths); - setLoading(true); - try { - const data = await api('/video/tutorial/analyze', { - method: 'POST', - body: JSON.stringify({ paths: uploadedPaths }), - }); - setAnalysis(data.analysis); - setStep(2); - } catch (err) { - alert('AI 분석 실패: ' + err.message); - } finally { - setLoading(false); - } + setAnalysis(analysisData); + setStep(2); }; const handleGenerate = async (editedAnalysis, title) => { @@ -761,7 +826,7 @@ className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700
{step === 1 && (