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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user