feat: 완전한 문서 관리 시스템 구현

 주요 기능:
- 문서 사라짐 문제 해결: API limit 제한으로 인한 문서 누락 해결
- 서적별 문서 관리: HTML과 PDF 통합 관리 시스템
- PDF 뷰어 개선: 인증, 네비게이션, 에러 처리 강화
- 서적 편집/삭제: 완전한 서적 관리 기능

🔧 기술적 개선:
- /api/documents/all 엔드포인트 추가 (모든 문서 조회)
- HTML/PDF 문서 타입별 아이콘 및 필터링
- 서적별 뷰에서 편집/삭제 버튼 추가
- PDF Manager와 서적 편집 페이지 연동

🎨 UI/UX 개선:
- Devonthink 스타일 서적 그룹화
- HTML 문서 순서 관리와 PDF 관리 섹션 분리
- 문서 타입별 시각적 구분 (HTML: 파란색, PDF: 빨간색)
- 2단계 확인을 통한 안전한 서적 삭제

�� 버그 수정:
- PDF 삭제 시 undefined ID 전달 문제 해결
- 서적 편집 페이지 422 오류 해결 (URL 파라미터 문제)
- PDF.js 워커 설정 및 인증 토큰 처리 개선
This commit is contained in:
hyungi
2025-09-05 11:00:17 +09:00
parent cfb9485d4f
commit 6a537008db
85 changed files with 375 additions and 28 deletions

View File

@@ -95,16 +95,29 @@
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
<!-- 서적 관리 버튼들 -->
<div class="flex justify-between pt-4 border-t border-gray-200">
<button @click="deleteBook()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-trash mr-2"></i>서적 삭제
</button>
<button @click="saveBookInfo()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-save mr-2"></i>서적 정보 저장
</button>
</div>
</div>
</div>
<!-- 문서 순서 및 PDF 매칭 편집 -->
<!-- HTML 문서 순서 및 PDF 매칭 편집 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-list-ol mr-2 text-green-600"></i>
문서 순서 및 PDF 매칭
<i class="fas fa-list-ol mr-2 text-blue-600"></i>
HTML 문서 순서 및 PDF 매칭
</h2>
<div class="flex space-x-2">
<button @click="autoSortByName()"
@@ -138,15 +151,22 @@
<!-- 문서 정보 -->
<div class="flex-1">
<h3 class="font-medium text-gray-900" x-text="doc.title"></h3>
<div class="flex items-center space-x-2">
<!-- 문서 타입 아이콘 -->
<i x-show="doc.html_path && !doc.pdf_path" class="fas fa-file-alt text-blue-500" title="HTML 문서"></i>
<i x-show="doc.pdf_path && !doc.html_path" class="fas fa-file-pdf text-red-500" title="PDF 문서"></i>
<i x-show="doc.html_path && doc.pdf_path" class="fas fa-file-archive text-purple-500" title="HTML + PDF"></i>
<h3 class="font-medium text-gray-900" x-text="doc.title"></h3>
</div>
<p class="text-sm text-gray-500" x-text="doc.description || '설명 없음'"></p>
</div>
</div>
<!-- PDF 매칭 및 컨트롤 -->
<div class="flex items-center space-x-3">
<!-- PDF 매칭 드롭다운 -->
<div class="min-w-48 relative">
<!-- PDF 매칭 드롭다운 (HTML 문서만) -->
<div x-show="doc.html_path" class="min-w-48 relative">
<select x-model="doc.matched_pdf_id"
:class="doc.matched_pdf_id ? 'border-green-300 bg-green-50' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
@@ -187,6 +207,67 @@
</div>
</div>
</div>
<!-- PDF 전용 문서 관리 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-file-pdf mr-2 text-red-600"></i>
등록된 PDF 문서 관리
<span class="ml-2 px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full" x-text="pdfDocuments.length + '개'"></span>
</h2>
</div>
<div class="p-6">
<!-- PDF 문서 목록 -->
<div class="space-y-3">
<template x-for="(pdf, index) in pdfDocuments" :key="pdf.id">
<div class="bg-red-50 border border-red-200 rounded-lg p-4 hover:bg-red-100 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center flex-1">
<!-- PDF 아이콘 -->
<div class="w-10 h-10 bg-red-600 text-white rounded-full flex items-center justify-center text-sm font-medium mr-4">
<i class="fas fa-file-pdf"></i>
</div>
<!-- PDF 정보 -->
<div class="flex-1">
<div class="flex items-center space-x-2">
<h3 class="font-medium text-gray-900" x-text="pdf.title"></h3>
<span class="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full">PDF 전용</span>
</div>
<p class="text-sm text-gray-500" x-text="pdf.description || '설명 없음'"></p>
<div class="flex items-center space-x-4 text-xs text-gray-400 mt-1">
<span x-text="pdf.original_filename"></span>
<span x-text="new Date(pdf.created_at).toLocaleDateString()"></span>
<span x-show="pdf.file_size" x-text="Math.round(pdf.file_size / 1024) + 'KB'"></span>
</div>
</div>
</div>
<!-- PDF 관리 버튼들 -->
<div class="flex items-center space-x-2">
<button @click="previewPDF(pdf)"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors text-sm">
<i class="fas fa-eye mr-1"></i>미리보기
</button>
<button @click="deletePDF(pdf)"
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-md hover:bg-red-200 transition-colors text-sm">
<i class="fas fa-trash mr-1"></i>삭제
</button>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="pdfDocuments.length === 0" class="text-center py-8">
<i class="fas fa-file-pdf text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-500">등록된 PDF 문서가 없습니다</p>
</div>
</div>
</div>
</div>
</main>

View File

@@ -150,7 +150,10 @@
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3>
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
<!-- 문서 타입별 아이콘 -->
<i x-show="doc.html_path && !doc.pdf_path" class="fas fa-file-alt text-blue-500 text-xl" title="HTML 문서"></i>
<i x-show="doc.pdf_path && !doc.html_path" class="fas fa-file-pdf text-red-500 text-xl" title="PDF 문서"></i>
<i x-show="doc.html_path && doc.pdf_path" class="fas fa-file-archive text-purple-500 text-xl" title="HTML + PDF"></i>
</div>
<p class="text-gray-600 text-sm mb-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
@@ -222,11 +225,22 @@
</div>
</div>
<!-- 확장/축소 아이콘 -->
<!-- 서적 관리 버튼들 -->
<div class="flex items-center space-x-2">
<button @click.stop="openBookDocuments(bookGroup.book)"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">
편집
<!-- 서적 편집 버튼 -->
<button x-show="bookGroup.book?.id"
@click.stop="window.location.href = `/book-editor.html?id=${bookGroup.book.id}`"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
title="서적 편집">
<i class="fas fa-edit mr-1"></i>편집
</button>
<!-- 서적 삭제 버튼 -->
<button x-show="bookGroup.book?.id"
@click.stop="deleteBook(bookGroup.book)"
class="px-3 py-1 text-xs bg-red-100 text-red-700 rounded-md hover:bg-red-200 transition-colors"
title="서적 삭제">
<i class="fas fa-trash mr-1"></i>삭제
</button>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
:class="{'rotate-180': bookGroup.expanded}"></i>
@@ -243,8 +257,17 @@
<div class="flex items-center space-x-3 flex-1">
<!-- 문서 타입 아이콘 -->
<div class="w-8 h-8 rounded-md flex items-center justify-center"
:class="doc.pdf_path ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'">
<i :class="doc.pdf_path ? 'fas fa-file-pdf' : 'fas fa-file-alt'" class="text-xs"></i>
:class="{
'bg-blue-100 text-blue-600': doc.html_path && !doc.pdf_path,
'bg-red-100 text-red-600': doc.pdf_path && !doc.html_path,
'bg-purple-100 text-purple-600': doc.html_path && doc.pdf_path
}">
<i class="text-xs"
:class="{
'fas fa-file-alt': doc.html_path && !doc.pdf_path,
'fas fa-file-pdf': doc.pdf_path && !doc.html_path,
'fas fa-file-archive': doc.html_path && doc.pdf_path
}"></i>
</div>
<!-- 문서 정보 -->

View File

@@ -279,8 +279,16 @@
</div>
</div>
<!-- 확장/축소 아이콘 -->
<!-- 서적 관리 버튼들 -->
<div class="flex items-center space-x-2">
<!-- 서적 편집 버튼 -->
<button x-show="bookGroup.book?.id"
@click.stop="window.location.href = `/book-editor.html?id=${bookGroup.book.id}`"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
title="서적 편집">
<i class="fas fa-edit mr-1"></i>편집
</button>
<span class="text-xs text-gray-500" x-text="bookGroup.pdfs.length + '개'"></span>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
:class="{'rotate-180': bookGroup.expanded}"></i>
@@ -319,7 +327,7 @@
title="다운로드">
<i class="fas fa-download text-xs"></i>
</button>
<button @click.stop="deletePDF(pdf.id)"
<button @click.stop="deletePDF(pdf)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors rounded-md hover:bg-red-50"
title="삭제">
<i class="fas fa-trash text-xs"></i>

View File

@@ -213,6 +213,11 @@ class DocumentServerAPI {
return await this.get('/documents/', params);
}
async getAllDocuments() {
// 모든 문서를 가져오는 전용 엔드포인트
return await this.get('/documents/all');
}
async getDocumentsHierarchy() {
return await this.get('/documents/hierarchy/structured');
}
@@ -429,6 +434,14 @@ class DocumentServerAPI {
return await this.post('/books', bookData);
}
async updateBook(bookId, bookData) {
return await this.put(`/books/${bookId}`, bookData);
}
async deleteBook(bookId) {
return await this.delete(`/books/${bookId}`);
}
async getBook(bookId) {
return await this.get(`/books/${bookId}`);
}

View File

@@ -75,7 +75,7 @@ window.bookDocumentsApp = () => ({
try {
// 모든 문서 가져오기
const allDocuments = await window.api.getDocuments();
const allDocuments = await window.api.getAllDocuments();
if (this.bookId === 'none') {
// 서적 미분류 HTML 문서들만 (폴더로 구분)

View File

@@ -1,7 +1,8 @@
// 서적 편집 애플리케이션 컴포넌트
window.bookEditorApp = () => ({
// 상태 관리
documents: [],
documents: [], // HTML 문서들 (순서 관리용)
pdfDocuments: [], // PDF 전용 문서들 (별도 관리용)
bookInfo: {},
availablePDFs: [],
loading: false,
@@ -40,8 +41,9 @@ window.bookEditorApp = () => ({
// URL 파라미터 파싱
parseUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
this.bookId = urlParams.get('bookId');
this.bookId = urlParams.get('id') || urlParams.get('bookId'); // 'id' 또는 'bookId' 파라미터 지원
console.log('📖 편집할 서적 ID:', this.bookId);
console.log('📖 URL 파라미터들:', Object.fromEntries(urlParams));
},
// 인증 상태 확인
@@ -74,21 +76,35 @@ window.bookEditorApp = () => ({
this.error = '';
try {
console.log('🔍 서적 ID 확인:', this.bookId);
// 서적 정보 로드
this.bookInfo = await window.api.getBook(this.bookId);
console.log('📚 서적 정보 로드:', this.bookInfo);
// 모든 문서 가져와서 이 서적에 속한 HTML 문서들 필터링 (폴더로 구분)
const allDocuments = await window.api.getDocuments();
// 모든 문서 가져와서 이 서적에 속한 문서들 필터링
const allDocuments = await window.api.getAllDocuments();
// HTML 문서만 (순서 관리용)
this.documents = allDocuments
.filter(doc =>
doc.book_id === this.bookId &&
doc.html_path &&
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
doc.html_path &&
doc.html_path.includes('/documents/')
)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬
console.log('📄 서적 문서들:', this.documents.length, '개');
// PDF 전용 문서들 (별도 관리용)
this.pdfDocuments = allDocuments
.filter(doc =>
doc.book_id === this.bookId &&
doc.pdf_path &&
!doc.html_path // PDF만 있는 문서
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // 최신순
console.log('📄 HTML 문서들:', this.documents.length, '개 (순서 관리)');
console.log('📕 PDF 전용 문서들:', this.pdfDocuments.length, '개 (별도 관리)');
// 각 문서의 PDF 매칭 상태 확인
this.documents.forEach((doc, index) => {
@@ -295,6 +311,93 @@ window.bookEditorApp = () => ({
}
},
// 서적 정보 저장
async saveBookInfo() {
try {
await window.api.updateBook(this.bookId, {
title: this.bookInfo.title,
author: this.bookInfo.author,
description: this.bookInfo.description
});
this.showNotification('서적 정보가 저장되었습니다', 'success');
} catch (error) {
console.error('서적 정보 저장 실패:', error);
this.showNotification('서적 정보 저장에 실패했습니다: ' + error.message, 'error');
}
},
// 서적 삭제 (모든 문서 포함)
async deleteBook() {
if (!this.bookInfo.title) {
alert('서적 정보가 로드되지 않았습니다.');
return;
}
const confirmMessage = `"${this.bookInfo.title}" 서적을 완전히 삭제하시겠습니까?\n\n⚠️ 경고: 이 작업은 되돌릴 수 없습니다!\n\n삭제될 항목:\n- 서적 정보\n- HTML 문서 ${this.documents.length}\n- PDF 전용 문서 ${this.pdfDocuments.length}\n- 관련된 모든 하이라이트, 노트, 링크`;
if (!confirm(confirmMessage)) {
return;
}
// 한 번 더 확인
const finalConfirm = prompt(`정말로 삭제하시려면 서적 제목을 입력하세요:\n"${this.bookInfo.title}"`);
if (finalConfirm !== this.bookInfo.title) {
alert('서적 제목이 일치하지 않습니다. 삭제가 취소되었습니다.');
return;
}
try {
// 서적에 속한 모든 HTML 문서 삭제
for (const doc of this.documents) {
console.log(`🗑️ HTML 문서 삭제 중: ${doc.title}`);
await window.api.deleteDocument(doc.id);
}
// 서적에 속한 모든 PDF 전용 문서 삭제
for (const doc of this.pdfDocuments) {
console.log(`🗑️ PDF 문서 삭제 중: ${doc.title}`);
await window.api.deleteDocument(doc.id);
}
// 서적 삭제
await window.api.deleteBook(this.bookId);
alert('서적이 완전히 삭제되었습니다.');
// 메인 페이지로 이동
window.location.href = '/index.html';
} catch (error) {
console.error('서적 삭제 실패:', error);
alert('서적 삭제에 실패했습니다: ' + error.message);
}
},
// PDF 미리보기
previewPDF(pdf) {
// PDF 뷰어 페이지로 이동
window.open(`/viewer.html?id=${pdf.id}`, '_blank');
},
// PDF 삭제
async deletePDF(pdf) {
if (!confirm(`"${pdf.title}" PDF 문서를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
return;
}
try {
await window.api.deleteDocument(pdf.id);
// PDF 목록에서 제거
this.pdfDocuments = this.pdfDocuments.filter(p => p.id !== pdf.id);
this.showNotification('PDF 문서가 삭제되었습니다', 'success');
} catch (error) {
console.error('PDF 삭제 실패:', error);
this.showNotification('PDF 삭제에 실패했습니다: ' + error.message, 'error');
}
},
// 뒤로가기
goBack() {
window.location.href = `book-documents.html?bookId=${this.bookId}`;

View File

@@ -134,7 +134,10 @@ window.documentApp = () => ({
this.error = '';
try {
const allDocuments = await window.api.getDocuments();
const allDocuments = await window.api.getAllDocuments();
// 디버깅: API 응답 원본 확인
console.log('🔍 API 응답 원본 (첫 3개):', JSON.stringify(allDocuments.slice(0, 3), null, 2));
// HTML 문서만 필터링 (PDF 파일 제외)
this.documents = allDocuments.filter(doc =>
@@ -146,6 +149,19 @@ window.documentApp = () => ({
console.log('📄 HTML 문서:', this.documents.length, '개');
console.log('📄 PDF 파일:', allDocuments.length - this.documents.length, '개 (제외됨)');
// 디버깅: 사라진 문서 찾기
console.log('🔍 HTML 경로가 있는 문서들:');
allDocuments.forEach(doc => {
if (doc.html_path) {
console.log(` - ${doc.title}: ${doc.html_path}`);
}
});
console.log('🔍 필터링된 문서들:');
this.documents.forEach(doc => {
console.log(` - ${doc.title}: ${doc.html_path}`);
});
this.updateAvailableTags();
this.filterDocuments();
this.syncUIState(); // UI 상태 동기화
@@ -206,6 +222,32 @@ window.documentApp = () => ({
this.filterDocuments();
},
// 서적 삭제
async deleteBook(book) {
if (!book || !book.id) {
alert('서적 정보가 올바르지 않습니다.');
return;
}
const confirmMessage = `"${book.title}" 서적을 삭제하시겠습니까?\n\n⚠️ 주의: 이 서적에 속한 모든 문서들이 '서적 미분류'로 이동됩니다.`;
if (!confirm(confirmMessage)) {
return;
}
try {
await window.api.deleteBook(book.id);
// 문서 목록 다시 로드
await this.loadDocuments();
alert('서적이 삭제되었습니다.');
} catch (error) {
console.error('서적 삭제 실패:', error);
alert('서적 삭제에 실패했습니다: ' + error.message);
}
},
// 필터 초기화
clearFilters() {
this.searchQuery = '';

View File

@@ -67,7 +67,7 @@ window.pdfManagerApp = () => ({
try {
// 모든 문서 가져오기
this.allDocuments = await window.api.getDocuments();
this.allDocuments = await window.api.getAllDocuments();
// PDF 파일들만 필터링
this.pdfDocuments = this.allDocuments.filter(doc =>