feat:업로드 버튼에 진행률 표시 UI 개선

- XHR upload.onprogress로 파일 업로드 실시간 진행률 표시 (0~50%)
- AI 분석 단계 진행률 시뮬레이션 표시 (50~100%)
- 버튼 내부에 반투명 progress fill 애니메이션
- 단계별 텍스트 변경: "업로드 중... 30%" → "AI 분석 중... 75%"
- 업로드+분석을 ScreenshotUpload 내부에서 일괄 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 16:58:02 +09:00
parent b47c27614b
commit 09ff2288e8

View File

@@ -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 ==
<button
onClick={handleUpload}
disabled={loading}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className={`progress-btn px-6 py-3 rounded-lg font-medium transition-colors ${
loading
? 'bg-indigo-700 text-white cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
업로드 ...
</span>
) : `${files.length}장 업로드 & AI 분석 시작`}
{loading && <div className="progress-fill" style={{ width: `${uploadProgress}%` }} />}
<span className="btn-content">
{loading ? (
<>
<svg className="animate-spin h-4 w-4 flex-shrink-0" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
<span>
{uploadPhase === 'uploading' && `업로드 중... ${uploadProgress}%`}
{uploadPhase === 'analyzing' && `AI 분석 중... ${uploadProgress}%`}
</span>
</>
) : (
`${files.length}장 업로드 & AI 분석 시작`
)}
</span>
</button>
</div>
)}
@@ -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
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-8">
{step === 1 && (
<ScreenshotUpload
onUploadComplete={handleUploadComplete}
onAnalysisComplete={handleAnalysisComplete}
loading={loading}
setLoading={setLoading}
/>