Major UI overhaul and upload system improvements
- Removed hierarchy view and integrated functionality into index.html - Added book-based document grouping with dedicated book-documents.html page - Implemented comprehensive multi-file upload system with drag-and-drop reordering - Added HTML-PDF matching functionality with download capability - Enhanced upload workflow with 3-step process (File Selection, Book Settings, Order & Match) - Added book conflict resolution (existing book vs new edition) - Improved document order adjustment with one-click sort options - Added modular header component system - Updated API connectivity for Docker environment - Enhanced viewer.html with PDF download functionality - Fixed browser caching issues with version management - Improved mobile responsiveness and modern UI design
This commit is contained in:
589
frontend/static/js/upload.js
Normal file
589
frontend/static/js/upload.js
Normal file
@@ -0,0 +1,589 @@
|
||||
// 업로드 애플리케이션 컴포넌트
|
||||
window.uploadApp = () => ({
|
||||
// 상태 관리
|
||||
currentStep: 1,
|
||||
selectedFiles: [],
|
||||
uploadedDocuments: [],
|
||||
pdfFiles: [],
|
||||
|
||||
// 업로드 상태
|
||||
uploading: false,
|
||||
finalizing: false,
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// 서적 관련
|
||||
bookSelectionMode: 'none',
|
||||
bookSearchQuery: '',
|
||||
searchedBooks: [],
|
||||
selectedBook: null,
|
||||
newBook: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: ''
|
||||
},
|
||||
|
||||
// Sortable 인스턴스
|
||||
sortableInstance: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Upload App 초기화 시작');
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
await window.headerLoader.loadHeader();
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 드래그 오버 처리
|
||||
handleDragOver(event) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
event.target.closest('.drag-area').classList.add('drag-over');
|
||||
},
|
||||
|
||||
// 드래그 리브 처리
|
||||
handleDragLeave(event) {
|
||||
event.target.closest('.drag-area').classList.remove('drag-over');
|
||||
},
|
||||
|
||||
// 드롭 처리
|
||||
handleDrop(event) {
|
||||
event.target.closest('.drag-area').classList.remove('drag-over');
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
this.processFiles(files);
|
||||
},
|
||||
|
||||
// 파일 선택 처리
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.processFiles(files);
|
||||
},
|
||||
|
||||
// 파일 처리
|
||||
processFiles(files) {
|
||||
const validFiles = files.filter(file => {
|
||||
const isValid = file.type === 'text/html' ||
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.html') ||
|
||||
file.name.toLowerCase().endsWith('.htm') ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
if (!isValid) {
|
||||
console.warn('지원하지 않는 파일 형식:', file.name);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// 기존 파일과 중복 체크
|
||||
validFiles.forEach(file => {
|
||||
const isDuplicate = this.selectedFiles.some(existing =>
|
||||
existing.name === file.name && existing.size === file.size
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
this.selectedFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📁 선택된 파일:', this.selectedFiles.length, '개');
|
||||
},
|
||||
|
||||
// 파일 제거
|
||||
removeFile(index) {
|
||||
this.selectedFiles.splice(index, 1);
|
||||
},
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
// 다음 단계
|
||||
nextStep() {
|
||||
if (this.selectedFiles.length === 0) {
|
||||
alert('업로드할 파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
this.currentStep = 2;
|
||||
},
|
||||
|
||||
// 이전 단계
|
||||
prevStep() {
|
||||
this.currentStep = Math.max(1, this.currentStep - 1);
|
||||
},
|
||||
|
||||
// 서적 검색
|
||||
async searchBooks() {
|
||||
if (!this.bookSearchQuery.trim()) {
|
||||
this.searchedBooks = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const books = await window.api.searchBooks(this.bookSearchQuery, 10);
|
||||
this.searchedBooks = books;
|
||||
} catch (error) {
|
||||
console.error('서적 검색 실패:', error);
|
||||
this.searchedBooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 선택
|
||||
selectBook(book) {
|
||||
this.selectedBook = book;
|
||||
this.bookSearchQuery = book.title;
|
||||
this.searchedBooks = [];
|
||||
},
|
||||
|
||||
// 파일 업로드
|
||||
async uploadFiles() {
|
||||
if (this.selectedFiles.length === 0) {
|
||||
alert('업로드할 파일이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서적 설정 검증
|
||||
if (this.bookSelectionMode === 'new' && !this.newBook.title.trim()) {
|
||||
alert('새 서적을 생성하려면 제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.bookSelectionMode === 'existing' && !this.selectedBook) {
|
||||
alert('기존 서적을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
try {
|
||||
let bookId = null;
|
||||
|
||||
// 서적 처리
|
||||
if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) {
|
||||
try {
|
||||
const newBook = await window.api.createBook({
|
||||
title: this.newBook.title,
|
||||
author: this.newBook.author,
|
||||
description: this.newBook.description
|
||||
});
|
||||
bookId = newBook.id;
|
||||
console.log('📚 새 서적 생성됨:', newBook.title);
|
||||
} catch (error) {
|
||||
if (error.message.includes('already exists')) {
|
||||
// 동일한 서적이 이미 존재하는 경우 - 선택 모달 표시
|
||||
const choice = await this.showBookConflictModal();
|
||||
|
||||
if (choice === 'existing') {
|
||||
// 기존 서적 검색해서 사용
|
||||
const existingBooks = await window.api.searchBooks(this.newBook.title, 10);
|
||||
const matchingBook = existingBooks.find(book =>
|
||||
book.title === this.newBook.title &&
|
||||
book.author === this.newBook.author
|
||||
);
|
||||
|
||||
if (matchingBook) {
|
||||
bookId = matchingBook.id;
|
||||
console.log('📚 기존 서적 사용:', matchingBook.title);
|
||||
} else {
|
||||
throw new Error('기존 서적을 찾을 수 없습니다.');
|
||||
}
|
||||
} else if (choice === 'edition') {
|
||||
// 에디션 정보 입력받아서 새 서적 생성
|
||||
const edition = await this.getEditionInfo();
|
||||
if (edition) {
|
||||
const newBookWithEdition = await window.api.createBook({
|
||||
title: `${this.newBook.title} (${edition})`,
|
||||
author: this.newBook.author,
|
||||
description: this.newBook.description
|
||||
});
|
||||
bookId = newBookWithEdition.id;
|
||||
console.log('📚 에디션 서적 생성됨:', newBookWithEdition.title);
|
||||
} else {
|
||||
throw new Error('에디션 정보가 입력되지 않았습니다.');
|
||||
}
|
||||
} else {
|
||||
throw new Error('사용자가 업로드를 취소했습니다.');
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (this.bookSelectionMode === 'existing' && this.selectedBook) {
|
||||
bookId = this.selectedBook.id;
|
||||
}
|
||||
|
||||
// HTML과 PDF 파일 분리
|
||||
const htmlFiles = this.selectedFiles.filter(file =>
|
||||
file.type === 'text/html' ||
|
||||
file.name.toLowerCase().endsWith('.html') ||
|
||||
file.name.toLowerCase().endsWith('.htm')
|
||||
);
|
||||
|
||||
const pdfFiles = this.selectedFiles.filter(file =>
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
|
||||
console.log('📄 HTML 파일:', htmlFiles.length, '개');
|
||||
console.log('📕 PDF 파일:', pdfFiles.length, '개');
|
||||
|
||||
// HTML 파일 업로드 (백엔드 API에 맞게)
|
||||
const uploadPromises = htmlFiles.map(async (file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('html_file', file); // 백엔드가 요구하는 필드명
|
||||
formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거
|
||||
formData.append('description', `업로드된 파일: ${file.name}`);
|
||||
formData.append('language', 'ko');
|
||||
formData.append('is_public', 'false');
|
||||
|
||||
if (bookId) {
|
||||
formData.append('book_id', bookId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.api.uploadDocument(formData);
|
||||
console.log('✅ HTML 파일 업로드 완료:', file.name);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ HTML 파일 업로드 실패:', file.name, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// PDF 파일은 별도로 처리 (나중에 HTML과 매칭)
|
||||
const pdfUploadPromises = pdfFiles.map(async (file, index) => {
|
||||
// PDF 전용 업로드 로직 (임시로 HTML 파일로 처리하지 않음)
|
||||
console.log('📕 PDF 파일 대기 중:', file.name);
|
||||
return {
|
||||
id: `pdf-${Date.now()}-${index}`,
|
||||
title: file.name.replace(/\.[^/.]+$/, ""),
|
||||
original_filename: file.name,
|
||||
file_type: 'pdf',
|
||||
file: file // 실제 파일 객체 보관
|
||||
};
|
||||
});
|
||||
|
||||
// HTML 파일 업로드 완료 대기
|
||||
const uploadedHtmlDocs = await Promise.all(uploadPromises);
|
||||
|
||||
// PDF 파일 처리 (실제 업로드는 하지 않고 매칭용으로만 보관)
|
||||
const pdfDocs = await Promise.all(pdfUploadPromises);
|
||||
|
||||
// 업로드된 HTML 문서 정리
|
||||
this.uploadedDocuments = uploadedHtmlDocs.map((doc, index) => ({
|
||||
...doc,
|
||||
display_order: index + 1,
|
||||
matched_pdf_id: null,
|
||||
file_type: 'html'
|
||||
}));
|
||||
|
||||
// PDF 파일 목록 (매칭용)
|
||||
this.pdfFiles = pdfDocs;
|
||||
|
||||
console.log('🎉 모든 파일 업로드 완료!');
|
||||
console.log('📄 HTML 문서:', this.uploadedDocuments.filter(doc => doc.file_type === 'html').length, '개');
|
||||
console.log('📕 PDF 문서:', this.pdfFiles.length, '개');
|
||||
|
||||
// 3단계로 이동
|
||||
this.currentStep = 3;
|
||||
|
||||
// 다음 틱에서 Sortable 초기화
|
||||
this.$nextTick(() => {
|
||||
this.initSortable();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
alert('업로드 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Sortable 초기화
|
||||
initSortable() {
|
||||
const sortableList = document.getElementById('sortable-list');
|
||||
if (sortableList && !this.sortableInstance) {
|
||||
this.sortableInstance = Sortable.create(sortableList, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
handle: '.cursor-move',
|
||||
onEnd: (evt) => {
|
||||
// 배열 순서 업데이트
|
||||
const item = this.uploadedDocuments.splice(evt.oldIndex, 1)[0];
|
||||
this.uploadedDocuments.splice(evt.newIndex, 0, item);
|
||||
|
||||
// display_order 업데이트
|
||||
this.updateDisplayOrder();
|
||||
|
||||
console.log('📋 드래그로 순서 변경됨');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// display_order 업데이트
|
||||
updateDisplayOrder() {
|
||||
this.uploadedDocuments.forEach((doc, index) => {
|
||||
doc.display_order = index + 1;
|
||||
});
|
||||
},
|
||||
|
||||
// 위로 이동
|
||||
moveUp(index) {
|
||||
if (index > 0) {
|
||||
const item = this.uploadedDocuments.splice(index, 1)[0];
|
||||
this.uploadedDocuments.splice(index - 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 위로 이동:', item.title);
|
||||
}
|
||||
},
|
||||
|
||||
// 아래로 이동
|
||||
moveDown(index) {
|
||||
if (index < this.uploadedDocuments.length - 1) {
|
||||
const item = this.uploadedDocuments.splice(index, 1)[0];
|
||||
this.uploadedDocuments.splice(index + 1, 0, item);
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 아래로 이동:', item.title);
|
||||
}
|
||||
},
|
||||
|
||||
// 이름순 정렬
|
||||
autoSortByName() {
|
||||
this.uploadedDocuments.sort((a, b) => {
|
||||
return a.title.localeCompare(b.title, 'ko', { numeric: true });
|
||||
});
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 이름순 정렬 완료');
|
||||
},
|
||||
|
||||
// 순서 뒤집기
|
||||
reverseOrder() {
|
||||
this.uploadedDocuments.reverse();
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 순서 뒤집기 완료');
|
||||
},
|
||||
|
||||
// 섞기
|
||||
shuffleDocuments() {
|
||||
for (let i = this.uploadedDocuments.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.uploadedDocuments[i], this.uploadedDocuments[j]] = [this.uploadedDocuments[j], this.uploadedDocuments[i]];
|
||||
}
|
||||
this.updateDisplayOrder();
|
||||
console.log('📋 문서 섞기 완료');
|
||||
},
|
||||
|
||||
// 최종 완료 처리
|
||||
async finalizeUpload() {
|
||||
this.finalizing = true;
|
||||
|
||||
try {
|
||||
// 문서 순서 및 PDF 매칭 정보 업데이트
|
||||
const updatePromises = this.uploadedDocuments.map(async (doc) => {
|
||||
const updateData = {
|
||||
display_order: doc.display_order
|
||||
};
|
||||
|
||||
// PDF 매칭 정보 추가 (필요시 백엔드 API 확장)
|
||||
if (doc.matched_pdf_id) {
|
||||
updateData.matched_pdf_id = doc.matched_pdf_id;
|
||||
}
|
||||
|
||||
return await window.api.updateDocument(doc.id, updateData);
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
console.log('🎉 업로드 완료 처리됨!');
|
||||
alert('업로드가 완료되었습니다!');
|
||||
|
||||
// 메인 페이지로 이동
|
||||
window.location.href = 'index.html';
|
||||
|
||||
} catch (error) {
|
||||
console.error('완료 처리 실패:', error);
|
||||
alert('완료 처리 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.finalizing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 재시작
|
||||
resetUpload() {
|
||||
if (confirm('업로드를 다시 시작하시겠습니까? 현재 진행 상황이 초기화됩니다.')) {
|
||||
this.currentStep = 1;
|
||||
this.selectedFiles = [];
|
||||
this.uploadedDocuments = [];
|
||||
this.pdfFiles = [];
|
||||
this.bookSelectionMode = 'none';
|
||||
this.selectedBook = null;
|
||||
this.newBook = { title: '', author: '', description: '' };
|
||||
|
||||
if (this.sortableInstance) {
|
||||
this.sortableInstance.destroy();
|
||||
this.sortableInstance = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
if (this.currentStep > 1) {
|
||||
if (confirm('진행 중인 업로드를 취소하고 돌아가시겠습니까?')) {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 중복 시 선택 모달
|
||||
showBookConflictModal() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">📚 서적 중복 발견</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
"<strong>${this.newBook.title}</strong>"${this.newBook.author ? ` (${this.newBook.author})` : ''} 서적이 이미 존재합니다.
|
||||
<br><br>어떻게 처리하시겠습니까?
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<button id="use-existing" class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-left">
|
||||
<div class="font-medium">🔄 기존 서적에 추가</div>
|
||||
<div class="text-sm text-blue-100">기존 서적에 새 문서들을 추가합니다</div>
|
||||
</button>
|
||||
<button id="add-edition" class="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-left">
|
||||
<div class="font-medium">📖 에디션으로 구분</div>
|
||||
<div class="text-sm text-green-100">에디션 정보를 입력해서 별도 서적으로 생성합니다</div>
|
||||
</button>
|
||||
<button id="cancel-upload" class="w-full px-4 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-center">
|
||||
❌ 업로드 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 이벤트 리스너
|
||||
modal.querySelector('#use-existing').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('existing');
|
||||
};
|
||||
|
||||
modal.querySelector('#add-edition').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('edition');
|
||||
};
|
||||
|
||||
modal.querySelector('#cancel-upload').onclick = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve('cancel');
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 에디션 정보 입력
|
||||
getEditionInfo() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">📖 에디션 정보 입력</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
서적을 구분할 에디션 정보를 입력해주세요.
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">에디션 정보</label>
|
||||
<input type="text" id="edition-input"
|
||||
placeholder="예: 2nd Edition, 2024년판, Ver 2.0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
💡 예시: "2nd Edition", "2024년판", "개정판", "Ver 2.0"
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button id="confirm-edition" class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
확인
|
||||
</button>
|
||||
<button id="cancel-edition" class="flex-1 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const input = modal.querySelector('#edition-input');
|
||||
input.focus();
|
||||
|
||||
// 이벤트 리스너
|
||||
const confirm = () => {
|
||||
const edition = input.value.trim();
|
||||
document.body.removeChild(modal);
|
||||
resolve(edition || null);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
modal.querySelector('#confirm-edition').onclick = confirm;
|
||||
modal.querySelector('#cancel-edition').onclick = cancel;
|
||||
|
||||
// Enter 키 처리
|
||||
input.onkeypress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirm();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Upload 페이지 로드됨');
|
||||
});
|
||||
Reference in New Issue
Block a user