feat: 계층구조 뷰 및 완전한 하이라이트/메모 시스템 구현

주요 기능:
- 📚 Book 및 BookCategory 모델 추가 (서적 그룹화)
- 🏗️ 계층구조 뷰 (Book > Category > Document) 구현
- 🎨 완전한 하이라이트 시스템 (생성, 표시, 삭제)
- 📝 통합 메모 관리 (추가, 수정, 삭제)
- 🔄 그리드 뷰와 계층구조 뷰 간 완전 동기화
- 🛡️ 관리자 전용 문서 삭제 기능
- 🔧 모든 CORS 및 500 오류 해결

기술적 개선:
- API 베이스 URL을 Nginx 프록시로 변경 (/api)
- 외래키 제약 조건 해결 (삭제 순서 최적화)
- SQLAlchemy 관계 로딩 최적화 (selectinload)
- 프론트엔드 캐시 무효화 시스템
- Alpine.js 컴포넌트 구조 개선

UI/UX:
- 계층구조 네비게이션 (사이드바 + 트리 구조)
- 하이라이트 모드 토글 스위치
- 완전한 툴팁 기반 메모 관리 인터페이스
- 반응형 하이라이트 메뉴 (색상 선택)
- 스마트 툴팁 위치 조정 (화면 경계 고려)
This commit is contained in:
Hyungi Ahn
2025-08-23 14:31:30 +09:00
parent 1e2e66d8fe
commit 46546da55f
18 changed files with 3206 additions and 384 deletions

677
frontend/hierarchy.html Normal file
View File

@@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문서 관리 시스템 - 계층구조</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
/* 드래그 앤 드롭 스타일 */
.draggable {
cursor: move;
transition: all 0.2s ease;
}
.draggable:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.drag-over {
border: 2px dashed #3b82f6;
background-color: #eff6ff;
}
.tree-item {
position: relative;
}
.tree-item::before {
content: '';
position: absolute;
left: -12px;
top: 0;
bottom: 0;
width: 1px;
background: #e5e7eb;
}
.tree-item:last-child::before {
height: 20px;
}
.tree-item::after {
content: '';
position: absolute;
left: -12px;
top: 20px;
width: 12px;
height: 1px;
background: #e5e7eb;
}
.sidebar-scroll {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.sidebar-scroll::-webkit-scrollbar {
width: 6px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: #f1f5f9;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* 하이라이트 스타일 */
.highlight {
border-radius: 2px;
padding: 1px 2px;
margin: 0 1px;
transition: all 0.2s ease;
}
.highlight:hover {
opacity: 0.8;
transform: scale(1.02);
}
/* 텍스트 선택 시 하이라이트 메뉴 */
.highlight-menu {
animation: fadeInUp 0.2s ease-out;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3) !important;
border: 3px solid #3b82f6 !important;
background: white !important;
z-index: 99999 !important;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 문서 콘텐츠 영역 */
.prose {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
</style>
</head>
<body class="bg-gray-50" x-data="hierarchyApp()" x-init="init()">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">📚 문서 관리 시스템</h1>
<div class="flex space-x-2">
<a href="index.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">그리드 뷰</a>
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">계층구조 뷰</span>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- 검색 -->
<div class="relative" x-show="currentUser">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input
type="text"
x-model="searchQuery"
@input="searchDocuments"
placeholder="문서, 메모 검색..."
class="w-80 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 사용자 메뉴 -->
<div x-show="currentUser" class="flex items-center space-x-3">
<span class="text-sm text-gray-600" x-text="currentUser?.full_name || currentUser?.email"></span>
<button @click="logout()" class="text-sm text-red-600 hover:text-red-800">로그아웃</button>
</div>
<!-- 로그인 버튼 -->
<button x-show="!currentUser" @click="openLoginModal()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
로그인
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨테이너 -->
<div class="flex h-screen pt-16">
<!-- 사이드바 -->
<aside class="w-80 bg-white shadow-lg border-r flex flex-col">
<!-- 사이드바 헤더 -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900">📖 문서 구조</h2>
<div class="flex space-x-2">
<button
@click="showUploadModal = true"
x-show="currentUser"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"
title="문서 업로드"
>
<i class="fas fa-plus"></i>
</button>
<button
@click="refreshHierarchy()"
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg"
title="새로고침"
>
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
</div>
<!-- 트리 구조 -->
<div class="flex-1 overflow-y-auto sidebar-scroll p-4">
<div x-show="loading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
<p class="text-gray-500 mt-2">로딩 중...</p>
</div>
<div x-show="!loading">
<!-- 서적들 -->
<template x-for="(book, bookId) in hierarchy.books" :key="bookId">
<div class="mb-4">
<!-- 서적 헤더 -->
<div
class="flex items-center p-3 bg-blue-50 border border-blue-200 rounded-lg cursor-pointer hover:bg-blue-100"
@click="toggleBook(bookId)"
>
<i class="fas fa-book text-blue-600 mr-3"></i>
<div class="flex-1">
<h3 class="font-medium text-blue-900" x-text="book.title"></h3>
<p class="text-sm text-blue-700" x-text="book.author || '저자 미상'"></p>
</div>
<i
class="fas fa-chevron-down transition-transform duration-200"
:class="expandedBooks.includes(bookId) ? 'rotate-180' : ''"
></i>
</div>
<!-- 서적 내용 -->
<div x-show="expandedBooks.includes(bookId)" class="mt-2 ml-4 space-y-2">
<!-- 소분류들 -->
<template x-for="(category, categoryId) in book.categories" :key="categoryId">
<div class="tree-item">
<!-- 소분류 헤더 -->
<div
class="flex items-center p-2 bg-green-50 border border-green-200 rounded cursor-pointer hover:bg-green-100"
@click="toggleCategory(categoryId)"
>
<i class="fas fa-folder text-green-600 mr-2"></i>
<span class="flex-1 font-medium text-green-900" x-text="category.name"></span>
<span class="text-xs text-green-700" x-text="`${category.documents.length}개`"></span>
<i
class="fas fa-chevron-down ml-2 transition-transform duration-200"
:class="expandedCategories.includes(categoryId) ? 'rotate-180' : ''"
></i>
</div>
<!-- 소분류 문서들 -->
<div x-show="expandedCategories.includes(categoryId)" class="mt-1 ml-4 space-y-1">
<template x-for="doc in category.documents" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
@dragover.prevent
@drop="handleDrop($event, categoryId)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- 서적 내 미분류 문서들 -->
<div x-show="book.uncategorized_documents.length > 0" class="tree-item">
<div
class="flex items-center p-2 bg-yellow-50 border border-yellow-200 rounded cursor-pointer hover:bg-yellow-100"
@click="toggleUncategorizedInBook(bookId)"
>
<i class="fas fa-folder-open text-yellow-600 mr-2"></i>
<span class="flex-1 font-medium text-yellow-900">미분류</span>
<span class="text-xs text-yellow-700" x-text="`${book.uncategorized_documents.length}개`"></span>
<i
class="fas fa-chevron-down ml-2 transition-transform duration-200"
:class="expandedUncategorizedInBooks.includes(bookId) ? 'rotate-180' : ''"
></i>
</div>
<div x-show="expandedUncategorizedInBooks.includes(bookId)" class="mt-1 ml-4 space-y-1">
<template x-for="doc in book.uncategorized_documents" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<!-- 전체 미분류 문서들 -->
<div x-show="hierarchy.uncategorized.length > 0" class="mb-4">
<div
class="flex items-center p-3 bg-gray-100 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-200"
@click="toggleUncategorized()"
>
<i class="fas fa-folder-open text-gray-600 mr-3"></i>
<div class="flex-1">
<h3 class="font-medium text-gray-900">📄 미분류 문서</h3>
<p class="text-sm text-gray-600" x-text="`${hierarchy.uncategorized.length}개 문서`"></p>
</div>
<i
class="fas fa-chevron-down transition-transform duration-200"
:class="showUncategorized ? 'rotate-180' : ''"
></i>
</div>
<div x-show="showUncategorized" class="mt-2 ml-4 space-y-1">
<template x-for="doc in hierarchy.uncategorized" :key="doc.id">
<div
class="flex items-center p-2 bg-white border rounded cursor-pointer hover:bg-gray-50 draggable"
@click="openDocument(doc.id)"
draggable="true"
@dragstart="handleDragStart($event, doc)"
>
<i class="fas fa-file-alt text-gray-500 mr-2"></i>
<span class="flex-1 text-sm" x-text="doc.title"></span>
<div class="flex space-x-1">
<button
@click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600"
title="편집"
>
<i class="fas fa-edit text-xs"></i>
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600"
title="삭제"
>
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<!-- 빈 상태 -->
<div x-show="Object.keys(hierarchy.books).length === 0 && hierarchy.uncategorized.length === 0" class="text-center py-8">
<i class="fas fa-folder-open text-gray-300 text-4xl mb-4"></i>
<p class="text-gray-500">아직 문서가 없습니다.</p>
<button
x-show="currentUser"
@click="showUploadModal = true"
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
첫 문서 업로드하기
</button>
</div>
</div>
</div>
</aside>
<!-- 메인 콘텐츠 영역 -->
<main class="flex-1 flex flex-col">
<!-- 콘텐츠 헤더 -->
<div class="bg-white border-b p-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900" x-text="selectedDocument ? selectedDocument.title : '문서를 선택하세요'"></h2>
<p class="text-sm text-gray-500" x-text="selectedDocument ? `${selectedDocument.book_title || '미분류'} ${selectedDocument.category_name ? '> ' + selectedDocument.category_name : ''}` : '사이드바에서 문서를 클릭하여 내용을 확인하세요'"></p>
</div>
<div x-show="selectedDocument" class="flex space-x-2">
<button
@click="toggleHighlightMode()"
class="px-3 py-2 rounded transition-colors"
:class="highlightMode ? 'bg-yellow-500 text-white' : 'text-yellow-600 border border-yellow-600 hover:bg-yellow-50'"
>
<i class="fas fa-highlighter mr-1"></i>
<span x-text="highlightMode ? '하이라이트 ON' : '하이라이트 OFF'"></span>
</button>
<button
@click="editDocument(selectedDocument)"
class="px-3 py-2 text-blue-600 border border-blue-600 rounded hover:bg-blue-50"
>
<i class="fas fa-edit mr-1"></i> 편집
</button>
<button
x-show="currentUser && currentUser.is_admin"
@click="deleteDocument(selectedDocument.id)"
class="px-3 py-2 text-red-600 border border-red-600 rounded hover:bg-red-50"
>
<i class="fas fa-trash mr-1"></i> 삭제
</button>
</div>
</div>
</div>
<!-- 문서 뷰어 -->
<div class="flex-1 overflow-auto">
<div x-show="!selectedDocument" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-file-alt text-gray-300 text-6xl mb-4"></i>
<h3 class="text-xl font-medium text-gray-600 mb-2">문서를 선택하세요</h3>
<p class="text-gray-500">왼쪽 사이드바에서 문서를 클릭하면 내용이 표시됩니다.</p>
</div>
</div>
<div x-show="selectedDocument && documentContent" class="p-6">
<div x-html="documentContent" class="prose max-w-none"></div>
</div>
<div x-show="selectedDocument && !documentContent && loadingDocument" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-500">문서를 불러오는 중...</p>
</div>
</div>
</div>
<!-- 하이라이트 메뉴 (직접 DOM 조작) -->
<div
id="highlight-menu"
class="fixed top-20 left-1/2 transform -translate-x-1/2 z-50 bg-white border border-gray-300 rounded-lg shadow-lg p-3"
style="display: none;"
>
<div class="text-center mb-2">
<h4 class="font-semibold text-sm">하이라이트 색상</h4>
</div>
<div class="flex space-x-2">
<button onclick="window.hierarchyInstance.createHighlight('yellow')" class="w-8 h-8 bg-yellow-300 rounded hover:bg-yellow-400" title="노랑"></button>
<button onclick="window.hierarchyInstance.createHighlight('blue')" class="w-8 h-8 bg-blue-300 rounded hover:bg-blue-400" title="파랑"></button>
<button onclick="window.hierarchyInstance.createHighlight('green')" class="w-8 h-8 bg-green-300 rounded hover:bg-green-400" title="초록"></button>
<button onclick="window.hierarchyInstance.createHighlight('red')" class="w-8 h-8 bg-red-300 rounded hover:bg-red-400" title="빨강"></button>
</div>
</div>
</main>
</div>
<!-- 로그인 모달 -->
<div x-data="authModal()" x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<h2 class="text-xl font-bold mb-4">로그인</h2>
<form @submit.prevent="login()">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input
type="email"
x-model="loginForm.email"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input
type="password"
x-model="loginForm.password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
@click="showLoginModal = false"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
:disabled="loggingIn"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<span x-show="!loggingIn">로그인</span>
<span x-show="loggingIn">로그인 중...</span>
</button>
</div>
</form>
</div>
</div>
<!-- 업로드 모달 -->
<div x-data="uploadModal()" x-show="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-bold mb-4">문서 업로드</h2>
<form @submit.prevent="upload()">
<!-- 파일 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 파일 *</label>
<input
type="file"
accept=".html,.htm"
@change="handleFileSelect($event, 'html_file')"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">PDF 파일 (선택사항)</label>
<input
type="file"
accept=".pdf"
@change="handleFileSelect($event, 'pdf_file')"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 서적 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">서적 분류</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="existing" class="mr-2 text-blue-600" name="bookSelectionMode">
기존 서적 선택
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="new" class="mr-2 text-blue-600" name="bookSelectionMode">
새 서적 생성
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="none" class="mr-2 text-blue-600" name="bookSelectionMode">
서적 없이 업로드
</label>
</div>
</div>
<!-- 기존 서적 선택 -->
<div x-show="bookSelectionMode === 'existing'" class="mb-4 space-y-3">
<input
type="text"
x-model="bookSearchQuery"
@input="searchBooks"
placeholder="서적 제목으로 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
<template x-for="book in searchedBooks" :key="book.id">
<div
@click="selectBook(book)"
class="p-3 hover:bg-gray-50 cursor-pointer border-b last:border-b-0"
>
<div class="font-medium" x-text="book.title"></div>
<div class="text-sm text-gray-600" x-text="book.author || '저자 미상'"></div>
</div>
</template>
</div>
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
</div>
</div>
<!-- 새 서적 생성 -->
<div x-show="bookSelectionMode === 'new'" class="mb-4 space-y-3">
<input
type="text"
x-model="newBook.title"
@input="getSuggestions"
placeholder="서적 제목 *"
:required="bookSelectionMode === 'new'"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<input
type="text"
x-model="newBook.author"
placeholder="저자"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p class="text-sm text-yellow-800 mb-2">비슷한 서적이 있습니다:</p>
<template x-for="suggestion in suggestions" :key="suggestion.id">
<div
@click="selectExistingFromSuggestion(suggestion)"
class="p-2 hover:bg-yellow-100 cursor-pointer rounded text-sm"
>
<span class="font-medium" x-text="suggestion.title"></span>
<span class="text-yellow-700" x-text="suggestion.author ? ` - ${suggestion.author}` : ''"></span>
</div>
</template>
</div>
</div>
<!-- 문서 정보 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">문서 제목 *</label>
<input
type="text"
x-model="uploadForm.title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서 제목"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">문서 날짜</label>
<input
type="date"
x-model="uploadForm.document_date"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea
x-model="uploadForm.description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서에 대한 간단한 설명"
></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input
type="text"
x-model="uploadForm.tags"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="태그를 쉼표로 구분하여 입력 (예: 중요, 회의록, 2024)"
>
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="uploadForm.is_public" class="mr-2">
<span class="text-sm text-gray-700">공개 문서로 설정</span>
</label>
</div>
<div x-show="uploadError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="uploadError"></span>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
@click="closeUploadModal()"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
:disabled="uploading || !uploadForm.html_file || !uploadForm.title"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!uploading">업로드</span>
<span x-show="uploading">
<i class="fas fa-spinner fa-spin mr-2"></i>
업로드 중...
</span>
</button>
</div>
</form>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/api.js?v=2025012225"></script>
<script src="static/js/auth.js?v=2025012225"></script>
<script src="static/js/hierarchy.js?v=2025012225"></script>
</body>
</html>

View File

@@ -12,9 +12,9 @@
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 메인 앱 -->
<div x-data="documentApp" x-init="init()">
<div x-data="documentApp()" x-init="init()">
<!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
@@ -53,11 +53,15 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 -->
<div class="flex items-center">
<div class="flex items-center space-x-4">
<h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-file-alt mr-2"></i>
Document Server
</h1>
<div class="flex space-x-2">
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">그리드 뷰</span>
<a href="hierarchy.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">계층구조 뷰</a>
</div>
</div>
<!-- 검색바 -->
@@ -181,7 +185,16 @@
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
</div>
<p class="text-gray-600 text-sm mb-4 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
<p class="text-gray-600 text-sm mb-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
<!-- 서적 정보 -->
<div x-show="doc.book_title" class="mb-3 p-2 bg-green-50 border border-green-200 rounded-md">
<div class="flex items-center text-sm">
<i class="fas fa-book text-green-600 mr-2"></i>
<span class="font-medium text-green-800" x-text="doc.book_title"></span>
<span x-show="doc.book_author" class="text-green-600 ml-1" x-text="' by ' + doc.book_author"></span>
</div>
</div>
<div class="flex flex-wrap gap-1 mb-4">
<template x-for="tag in doc.tags" :key="tag">
@@ -191,7 +204,24 @@
<div class="flex justify-between items-center text-sm text-gray-500">
<span x-text="formatDate(doc.created_at)"></span>
<span x-text="doc.uploader_name"></span>
<div class="flex items-center space-x-2">
<span x-text="doc.uploader_name"></span>
<!-- 액션 버튼들 -->
<div class="flex space-x-1 ml-2">
<button @click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="문서 수정">
<i class="fas fa-edit text-sm"></i>
</button>
<!-- 관리자만 삭제 버튼 표시 -->
<button x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
title="문서 삭제 (관리자 전용)">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -225,7 +255,7 @@
</button>
</div>
<div x-data="uploadModal">
<div x-data="uploadModal()">
<form @submit.prevent="upload">
<!-- 파일 업로드 영역 -->
<div class="mb-6">
@@ -272,6 +302,98 @@
</div>
</div>
<!-- 서적 선택/생성 -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<label class="block text-sm font-medium text-gray-700 mb-3">📚 서적 설정</label>
<!-- 서적 선택 방식 -->
<div class="flex space-x-4 mb-4">
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="existing"
class="mr-2 text-blue-600">
<span class="text-sm">기존 서적에 추가</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="new"
class="mr-2 text-blue-600">
<span class="text-sm">새 서적 생성</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="none"
class="mr-2 text-blue-600">
<span class="text-sm">서적 없이 업로드</span>
</label>
</div>
<!-- 기존 서적 선택 -->
<div x-show="bookSelectionMode === 'existing'" class="space-y-3">
<div>
<input type="text" x-model="bookSearchQuery" @input="searchBooks"
placeholder="서적 제목으로 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 검색 결과 -->
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
<template x-for="book in searchedBooks" :key="book.id">
<div @click="selectBook(book)"
:class="selectedBook?.id === book.id ? 'bg-blue-100 border-blue-500' : 'hover:bg-gray-50'"
class="p-3 border-b border-gray-200 cursor-pointer">
<div class="font-medium text-sm" x-text="book.title"></div>
<div class="text-xs text-gray-500">
<span x-text="book.author || '저자 미상'"></span> ·
<span x-text="book.document_count + '개 문서'"></span>
</div>
</div>
</template>
</div>
<!-- 선택된 서적 표시 -->
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
</div>
<button @click="selectedBook = null" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 새 서적 생성 -->
<div x-show="bookSelectionMode === 'new'" class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<input type="text" x-model="newBook.title" @input="getSuggestions"
placeholder="서적 제목 *" :required="bookSelectionMode === 'new'"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<input type="text" x-model="newBook.author"
placeholder="저자"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 유사 서적 추천 -->
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="text-sm font-medium text-yellow-800 mb-2">💡 유사한 서적이 있습니다:</div>
<template x-for="suggestion in suggestions" :key="suggestion.id">
<div @click="selectExistingFromSuggestion(suggestion)"
class="p-2 bg-white border border-yellow-300 rounded cursor-pointer hover:bg-yellow-50 mb-1">
<div class="text-sm font-medium" x-text="suggestion.title"></div>
<div class="text-xs text-gray-600">
<span x-text="suggestion.author || '저자 미상'"></span> ·
<span x-text="Math.round(suggestion.similarity_score * 100) + '% 유사'"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 문서 정보 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
@@ -356,5 +478,10 @@
overflow: hidden;
}
</style>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012222"></script>
<script src="/static/js/auth.js?v=2025012222"></script>
<script src="/static/js/main.js?v=2025012222"></script>
</body>
</html>

View File

@@ -1,9 +1,9 @@
/**
* API 통신 유틸리티
*/
class API {
class DocumentServerAPI {
constructor() {
this.baseURL = 'http://localhost:24102/api';
this.baseURL = '/api'; // Nginx 프록시를 통해 접근
this.token = localStorage.getItem('access_token');
}
@@ -143,10 +143,18 @@ class API {
return await this.get('/documents/', params);
}
async getDocumentsHierarchy() {
return await this.get('/documents/hierarchy/structured');
}
async getDocument(documentId) {
return await this.get(`/documents/${documentId}`);
}
async getDocumentContent(documentId) {
return await this.get(`/documents/${documentId}/content`);
}
async uploadDocument(formData) {
return await this.uploadFile('/documents/', formData);
}
@@ -326,7 +334,87 @@ class API {
if (documentId) params.append('document_id', documentId);
return await this.get(`/search/notes?${params}`);
}
// === 서적 관련 API ===
async getBooks(skip = 0, limit = 50, search = null) {
const params = new URLSearchParams({ skip, limit });
if (search) params.append('search', search);
return await this.get(`/books?${params}`);
}
async createBook(bookData) {
return await this.post('/books', bookData);
}
async getBook(bookId) {
return await this.get(`/books/${bookId}`);
}
async searchBooks(query, limit = 10) {
const params = new URLSearchParams({ q: query, limit });
return await this.get(`/books/search/?${params}`);
}
async getBookSuggestions(title, limit = 5) {
const params = new URLSearchParams({ title, limit });
return await this.get(`/books/suggestions/?${params}`);
}
// === 서적 소분류 관련 API ===
async createBookCategory(categoryData) {
return await this.post('/book-categories/', categoryData);
}
async getBookCategories(bookId) {
return await this.get(`/book-categories/book/${bookId}`);
}
async updateBookCategory(categoryId, categoryData) {
return await this.put(`/book-categories/${categoryId}`, categoryData);
}
async deleteBookCategory(categoryId) {
return await this.delete(`/book-categories/${categoryId}`);
}
async updateDocumentOrder(orderData) {
return await this.put('/book-categories/documents/reorder', orderData);
}
// === 하이라이트 관련 API ===
async getDocumentHighlights(documentId) {
return await this.get(`/highlights/document/${documentId}`);
}
async createHighlight(highlightData) {
return await this.post('/highlights/', highlightData);
}
async updateHighlight(highlightId, highlightData) {
return await this.put(`/highlights/${highlightId}`, highlightData);
}
async deleteHighlight(highlightId) {
return await this.delete(`/highlights/${highlightId}`);
}
// === 메모 관련 API ===
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
async updateNote(noteId, noteData) {
return await this.put(`/notes/${noteId}`, noteData);
}
async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`);
}
}
// 전역 API 인스턴스
window.api = new API();
window.api = new DocumentServerAPI();

View File

@@ -18,14 +18,14 @@ window.authModal = () => ({
try {
// 실제 API 호출
const response = await api.login(this.loginForm.email, this.loginForm.password);
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장
api.setToken(response.access_token);
window.api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 가져오기
const userResponse = await api.getCurrentUser();
const userResponse = await window.api.getCurrentUser();
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
@@ -45,7 +45,7 @@ window.authModal = () => ({
async logout() {
try {
await api.logout();
await window.api.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
@@ -79,7 +79,7 @@ async function refreshTokenIfNeeded() {
} catch (error) {
console.error('Token refresh failed:', error);
// 갱신 실패시 로그아웃
api.setToken(null);
window.api.setToken(null);
localStorage.removeItem('refresh_token');
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null }

File diff suppressed because it is too large Load Diff

View File

@@ -1,201 +1,239 @@
/**
* 메인 애플리케이션 Alpine.js 컴포넌트
*/
// 메인 문서 앱 컴포넌트
// 메인 애플리케이션 컴포넌트
window.documentApp = () => ({
// 상태
isAuthenticated: false,
user: null,
loading: false,
// 문서 관련
// 상태 관리
documents: [],
tags: [],
selectedTag: '',
viewMode: 'grid', // 'grid' 또는 'list'
filteredDocuments: [],
loading: false,
error: '',
// 검색
// 인증 상태
isAuthenticated: false,
currentUser: null,
showLoginModal: false,
// 필터링 및 검색
searchQuery: '',
searchResults: [],
selectedTag: '',
availableTags: [],
// UI 상태
viewMode: 'grid', // 'grid' 또는 'list'
user: null, // currentUser의 별칭
tags: [], // availableTags의 별칭
// 모달 상태
showLoginModal: false,
showUploadModal: false,
showProfile: false,
showMyNotes: false,
showBookmarks: false,
showAdmin: false,
// 초기화
async init() {
// 인증 상태 확인
await this.checkAuth();
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-changed', (event) => {
this.isAuthenticated = event.detail.isAuthenticated;
this.user = event.detail.user;
if (this.isAuthenticated) {
this.loadInitialData();
} else {
this.resetData();
}
});
// 로그인 모달 닫기 이벤트 리스너
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);
}
});
// 초기 데이터 로드
await this.checkAuthStatus();
if (this.isAuthenticated) {
await this.loadInitialData();
await this.loadDocuments();
}
this.setupEventListeners();
},
// 인증 상태 확인
async checkAuth() {
if (!api.token) {
this.isAuthenticated = false;
return;
}
async checkAuthStatus() {
try {
this.user = await api.getCurrentUser();
this.isAuthenticated = true;
} catch (error) {
console.error('Auth check failed:', error);
this.isAuthenticated = false;
api.setToken(null);
}
},
// 초기 데이터 로드
async loadInitialData() {
this.loading = true;
try {
await Promise.all([
this.loadDocuments(),
this.loadTags()
]);
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
this.loading = false;
}
},
// 데이터 리셋
resetData() {
this.documents = [];
this.tags = [];
this.selectedTag = '';
this.searchQuery = '';
this.searchResults = [];
},
// 문서 목록 로드
async loadDocuments() {
try {
const response = await api.getDocuments();
let allDocuments = response || [];
// 태그 필터링
let filteredDocs = allDocuments;
if (this.selectedTag) {
filteredDocs = allDocuments.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
const token = localStorage.getItem('access_token');
if (token) {
window.api.setToken(token);
const user = await window.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
this.syncUIState(); // UI 상태 동기화
}
this.documents = filteredDocs;
} catch (error) {
console.error('Failed to load documents:', error);
this.documents = [];
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
console.log('Not authenticated or token expired');
this.isAuthenticated = false;
this.currentUser = null;
localStorage.removeItem('access_token');
this.syncUIState(); // UI 상태 동기화
}
},
// 태그 목록 로드
async loadTags() {
try {
const response = await api.getTags();
this.tags = response || [];
} catch (error) {
console.error('Failed to load tags:', error);
this.tags = [];
}
},
// 문서 검색
async searchDocuments() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
return;
}
try {
const results = await api.search({
q: this.searchQuery,
limit: 20
});
this.searchResults = results.results;
} catch (error) {
console.error('Search failed:', error);
}
},
// 문서 열기
openDocument(document) {
// 문서 뷰어 페이지로 이동
window.location.href = `/viewer.html?id=${document.id}`;
// 로그인 모달 열기
openLoginModal() {
this.showLoginModal = true;
},
// 로그아웃
async logout() {
try {
await api.logout();
this.isAuthenticated = false;
this.user = null;
this.resetData();
await window.api.logout();
} catch (error) {
console.error('Logout failed:', error);
console.error('Logout error:', error);
} finally {
this.isAuthenticated = false;
this.currentUser = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this.documents = [];
this.filteredDocuments = [];
}
},
// 이벤트 리스너 설정
setupEventListeners() {
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
this.loadDocuments();
});
// 알림 이벤트 리스너
window.addEventListener('show-notification', (event) => {
this.showNotification(event.detail.message, event.detail.type);
});
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-changed', (event) => {
this.isAuthenticated = event.detail.isAuthenticated;
this.currentUser = event.detail.user;
this.showLoginModal = false;
this.syncUIState(); // UI 상태 동기화
if (this.isAuthenticated) {
this.loadDocuments();
}
});
// 로그인 모달 닫기 이벤트 리스너
window.addEventListener('close-login-modal', () => {
this.showLoginModal = false;
});
},
// 문서 목록 로드
async loadDocuments() {
this.loading = true;
this.error = '';
try {
this.documents = await window.api.getDocuments();
this.updateAvailableTags();
this.filterDocuments();
this.syncUIState(); // UI 상태 동기화
} catch (error) {
console.error('Failed to load documents:', error);
this.error = 'Failed to load documents: ' + error.message;
this.documents = [];
} finally {
this.loading = false;
}
},
// 문서 뷰어 열기
// 사용 가능한 태그 업데이트
updateAvailableTags() {
const tagSet = new Set();
this.documents.forEach(doc => {
if (doc.tags) {
doc.tags.forEach(tag => tagSet.add(tag));
}
});
this.availableTags = Array.from(tagSet).sort();
this.tags = this.availableTags; // 별칭 동기화
},
// UI 상태 동기화
syncUIState() {
this.user = this.currentUser;
this.tags = this.availableTags;
},
// 문서 필터링
filterDocuments() {
let filtered = this.documents;
// 검색어 필터링
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
filtered = filtered.filter(doc =>
doc.title.toLowerCase().includes(query) ||
(doc.description && doc.description.toLowerCase().includes(query)) ||
(doc.tags && doc.tags.some(tag => tag.toLowerCase().includes(query)))
);
}
// 태그 필터링
if (this.selectedTag) {
filtered = filtered.filter(doc =>
doc.tags && doc.tags.includes(this.selectedTag)
);
}
this.filteredDocuments = filtered;
},
// 검색어 변경 시
onSearchChange() {
this.filterDocuments();
},
// 문서 검색 (HTML에서 사용)
searchDocuments() {
this.filterDocuments();
},
// 태그 선택 시
onTagSelect(tag) {
this.selectedTag = this.selectedTag === tag ? '' : tag;
this.filterDocuments();
},
// 태그 필터 초기화
clearTagFilter() {
this.selectedTag = '';
this.filterDocuments();
},
// 문서 삭제
async deleteDocument(documentId) {
if (!confirm('정말로 이 문서를 삭제하시겠습니까?')) {
return;
}
try {
await window.api.deleteDocument(documentId);
await this.loadDocuments();
this.showNotification('문서가 삭제되었습니다', 'success');
} catch (error) {
console.error('Failed to delete document:', error);
this.showNotification('문서 삭제에 실패했습니다: ' + error.message, 'error');
}
},
// 문서 보기
viewDocument(documentId) {
window.open(`/viewer.html?id=${documentId}`, '_blank');
},
// 문서 열기 (HTML에서 사용)
openDocument(documentId) {
window.location.href = `/viewer.html?id=${documentId}`;
this.viewDocument(documentId);
},
// 문서 수정 (HTML에서 사용)
editDocument(document) {
// TODO: 문서 수정 모달 구현
console.log('문서 수정:', document);
alert('문서 수정 기능은 곧 구현됩니다!');
},
// 업로드 모달 열기
openUploadModal() {
this.showUploadModal = true;
},
// 업로드 모달 닫기
closeUploadModal() {
this.showUploadModal = false;
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
return new Date(dateString).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
month: 'long',
day: 'numeric'
});
},
@@ -221,7 +259,6 @@ window.documentApp = () => ({
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
show: false,
uploading: false,
uploadForm: {
title: '',
@@ -233,6 +270,19 @@ window.uploadModal = () => ({
pdf_file: null
},
uploadError: '',
// 서적 관련 상태
bookSelectionMode: 'none', // 'existing', 'new', 'none'
bookSearchQuery: '',
searchedBooks: [],
selectedBook: null,
newBook: {
title: '',
author: '',
description: ''
},
suggestions: [],
searchTimeout: null,
// 파일 선택
onFileSelect(event, fileType) {
@@ -247,15 +297,44 @@ window.uploadModal = () => ({
}
},
// 드래그 앤 드롭 처리
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 = '제목을 입력해주세요';
this.uploadError = '문서 제목을 입력해주세요';
return;
}
@@ -263,25 +342,52 @@ window.uploadModal = () => ({
this.uploadError = '';
try {
let bookId = null;
// 서적 처리
if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) {
const newBook = await window.api.createBook({
title: this.newBook.title,
author: this.newBook.author || null,
description: this.newBook.description || null,
language: this.uploadForm.language || 'ko',
is_public: this.uploadForm.is_public
});
bookId = newBook.id;
} else if (this.bookSelectionMode === 'existing' && this.selectedBook) {
bookId = this.selectedBook.id;
}
// FormData 생성
const formData = new FormData();
formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description);
formData.append('tags', this.uploadForm.tags);
formData.append('is_public', this.uploadForm.is_public);
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);
}
// 서적 ID 추가
if (bookId) {
formData.append('book_id', bookId);
}
formData.append('language', this.uploadForm.language || 'ko');
formData.append('is_public', this.uploadForm.is_public);
if (this.uploadForm.tags) {
formData.append('tags', this.uploadForm.tags);
}
if (this.uploadForm.document_date) {
formData.append('document_date', this.uploadForm.document_date);
}
await api.uploadDocument(formData);
// 업로드 실행
await window.api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
this.show = false;
// 성공 처리
this.resetForm();
// 문서 목록 새로고침
@@ -315,138 +421,66 @@ window.uploadModal = () => ({
// 파일 입력 필드 리셋
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => input.value = '');
}
});
// 파일 업로드 컴포넌트
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 = '');
// 서적 관련 상태 리셋
this.bookSelectionMode = 'none';
this.bookSearchQuery = '';
this.searchedBooks = [];
this.selectedBook = null;
this.newBook = { title: '', author: '', description: '' };
this.suggestions = [];
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
},
// 서적 검색
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 getSuggestions() {
if (!this.newBook.title.trim()) {
this.suggestions = [];
return;
}
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(async () => {
try {
const suggestions = await window.api.getBookSuggestions(this.newBook.title, 3);
this.suggestions = suggestions.filter(s => s.similarity_score > 0.5); // 50% 이상 유사한 것만
} catch (error) {
console.error('추천 가져오기 실패:', error);
this.suggestions = [];
}
}, 300);
},
// 추천에서 기존 서적 선택
selectExistingFromSuggestion(suggestion) {
this.bookSelectionMode = 'existing';
this.selectedBook = suggestion;
this.bookSearchQuery = suggestion.title;
this.suggestions = [];
this.newBook = { title: '', author: '', description: '' };
}
});
});

24
frontend/test.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 페이지</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body class="bg-gray-100 p-8">
<div x-data="{ message: '페이지가 정상 작동합니다!' }">
<h1 class="text-3xl font-bold text-blue-600 mb-4" x-text="message"></h1>
<p class="text-gray-700">이 페이지가 보이면 기본 설정은 정상입니다.</p>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">링크</h2>
<div class="space-y-2">
<a href="index.html" class="block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">그리드 뷰</a>
<a href="hierarchy.html" class="block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">계층구조 뷰</a>
</div>
</div>
</div>
</body>
</html>