feat: 완전한 문서 업로드 및 관리 시스템 구현

- 백엔드 API 완전 구현 (FastAPI + SQLAlchemy + PostgreSQL)
  - 사용자 인증 (JWT 토큰 기반)
  - 문서 CRUD (업로드, 조회, 목록, 삭제)
  - 하이라이트, 메모, 책갈피 관리
  - 태그 시스템 및 검색 기능
  - Pydantic v2 호환성 수정

- 프론트엔드 완전 구현 (Alpine.js + Tailwind CSS)
  - 로그인/로그아웃 기능
  - 문서 업로드 모달 (드래그앤드롭, 파일 검증)
  - 문서 목록 및 필터링
  - 뷰어 페이지 (하이라이트, 메모, 책갈피 UI)
  - 실시간 목록 새로고침

- 시스템 안정성 개선
  - Alpine.js 컴포넌트 간 안전한 통신 (이벤트 기반)
  - API 오류 처리 및 사용자 피드백
  - 파비콘 추가로 404 오류 해결

- 포트 구성: Frontend(24100), Backend(24102), DB(24101), Redis(24103)
This commit is contained in:
Hyungi Ahn
2025-08-22 06:42:26 +09:00
parent 3036b8f0fb
commit a42d193508
28 changed files with 1213 additions and 152 deletions

View File

@@ -20,6 +20,7 @@ window.documentApp = () => ({
searchResults: [],
// 모달 상태
showLoginModal: false,
showUploadModal: false,
showProfile: false,
showMyNotes: false,
@@ -43,6 +44,31 @@ window.documentApp = () => ({
}
});
// 로그인 모달 닫기 이벤트 리스너
window.addEventListener('close-login-modal', () => {
this.showLoginModal = false;
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
if (this.isAuthenticated) {
this.loadDocuments();
this.loadTags();
}
});
// 업로드 모달 닫기 이벤트 리스너
window.addEventListener('close-upload-modal', () => {
this.showUploadModal = false;
});
// 알림 표시 이벤트 리스너
window.addEventListener('show-notification', (event) => {
if (this.showNotification) {
this.showNotification(event.detail.message, event.detail.type);
}
});
// 초기 데이터 로드
if (this.isAuthenticated) {
await this.loadInitialData();
@@ -93,14 +119,21 @@ window.documentApp = () => ({
// 문서 목록 로드
async loadDocuments() {
try {
const params = {};
const response = await api.getDocuments();
let allDocuments = response || [];
// 태그 필터링
let filteredDocs = allDocuments;
if (this.selectedTag) {
params.tag = this.selectedTag;
filteredDocs = allDocuments.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
}
this.documents = await api.getDocuments(params);
this.documents = filteredDocs;
} catch (error) {
console.error('Failed to load documents:', error);
this.documents = [];
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
}
},
@@ -108,9 +141,11 @@ window.documentApp = () => ({
// 태그 목록 로드
async loadTags() {
try {
this.tags = await api.getTags();
const response = await api.getTags();
this.tags = response || [];
} catch (error) {
console.error('Failed to load tags:', error);
this.tags = [];
}
},
@@ -248,7 +283,9 @@ window.uploadModal = () => ({
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
document.querySelector('[x-data="documentApp"]').__x.$data.showNotification('문서가 성공적으로 업로드되었습니다', 'success');
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
}));
} catch (error) {
this.uploadError = error.message;
@@ -276,11 +313,135 @@ window.uploadModal = () => ({
}
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
const app = document.querySelector('[x-data="documentApp"]').__x.$data;
if (app && app.isAuthenticated) {
app.loadDocuments();
app.loadTags();
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
uploading: false,
uploadForm: {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
},
uploadError: '',
// 파일 선택
onFileSelect(event, fileType) {
const file = event.target.files[0];
if (file) {
this.uploadForm[fileType] = file;
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 드래그 앤 드롭 처리
handleFileDrop(event, fileType) {
event.target.classList.remove('dragover');
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
// 파일 타입 검증
if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) {
this.uploadError = 'HTML 파일만 업로드 가능합니다';
return;
}
if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) {
this.uploadError = 'PDF 파일만 업로드 가능합니다';
return;
}
this.uploadForm[fileType] = file;
this.uploadError = '';
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행 (목업)
async upload() {
if (!this.uploadForm.html_file) {
this.uploadError = 'HTML 파일을 선택해주세요';
return;
}
if (!this.uploadForm.title.trim()) {
this.uploadError = '제목을 입력해주세요';
return;
}
this.uploading = true;
this.uploadError = '';
try {
// FormData 생성
const formData = new FormData();
formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description || '');
formData.append('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file);
}
formData.append('language', this.uploadForm.language);
formData.append('is_public', this.uploadForm.is_public);
// 태그 추가
if (this.uploadForm.tags && this.uploadForm.tags.length > 0) {
this.uploadForm.tags.forEach(tag => {
formData.append('tags', tag);
});
}
// 실제 API 호출
await api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
window.dispatchEvent(new CustomEvent('close-upload-modal'));
this.resetForm();
// 문서 목록 새로고침
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
window.dispatchEvent(new CustomEvent('show-notification', {
detail: { message: '문서가 성공적으로 업로드되었습니다', type: 'success' }
}));
} catch (error) {
this.uploadError = error.message || '업로드에 실패했습니다';
} finally {
this.uploading = false;
}
},
// 폼 리셋
resetForm() {
this.uploadForm = {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
};
this.uploadError = '';
// 파일 입력 필드 리셋
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => input.value = '');
}
});