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:
133
frontend/book-documents.html
Normal file
133
frontend/book-documents.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>서적 문서 목록 - Document Server</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="bookDocumentsApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 뒤로가기 및 서적 정보 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>목차로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-6">
|
||||
<i class="fas fa-book text-white text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900" x-text="bookInfo.title || '서적 미분류'"></h1>
|
||||
<p class="text-gray-600 mt-1" x-show="bookInfo.author" x-text="bookInfo.author"></p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
<span x-text="documents.length"></span>개 문서
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600" x-show="bookInfo.description" x-text="bookInfo.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
<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">문서 목록</h2>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="p-8 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-500">문서를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
<div x-show="!loading && documents.length > 0" class="divide-y divide-gray-200">
|
||||
<template x-for="doc in documents" :key="doc.id">
|
||||
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
@click="openDocument(doc.id)">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2" x-text="doc.title"></h3>
|
||||
<p class="text-gray-600 text-sm mb-3 line-clamp-2" x-text="doc.description || '설명이 없습니다'"></p>
|
||||
|
||||
<!-- 태그들 -->
|
||||
<div class="flex flex-wrap gap-1 mb-3" x-show="doc.tags && doc.tags.length > 0">
|
||||
<template x-for="tag in (doc.tags || [])" :key="tag">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span x-text="formatDate(doc.created_at)"></span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<span x-text="doc.uploader_name"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click.stop="editDocument(doc)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="문서 수정">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button x-show="currentUser && currentUser.is_admin"
|
||||
@click.stop="deleteDocument(doc.id)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="문서 삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && documents.length === 0" class="p-8 text-center">
|
||||
<i class="fas fa-file-alt text-gray-400 text-4xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">문서가 없습니다</h3>
|
||||
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012380"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/header-loader.js?v=2025012351"></script>
|
||||
<script src="/static/js/book-documents.js?v=2025012371"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/cache-buster.html
Normal file
38
frontend/cache-buster.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>캐시 무효화 - Document Server</title>
|
||||
<script>
|
||||
// 강제 캐시 무효화
|
||||
const timestamp = new Date().getTime();
|
||||
console.log('🔧 캐시 무효화 타임스탬프:', timestamp);
|
||||
|
||||
// localStorage 캐시 정리
|
||||
localStorage.removeItem('api_cache');
|
||||
sessionStorage.clear();
|
||||
|
||||
// 3초 후 업로드 페이지로 리다이렉트
|
||||
setTimeout(() => {
|
||||
window.location.href = `upload.html?t=${timestamp}`;
|
||||
}, 3000);
|
||||
</script>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center;">
|
||||
<h1>🔧 캐시 무효화 중...</h1>
|
||||
<p>잠시만 기다려주세요. 3초 후 업로드 페이지로 이동합니다.</p>
|
||||
<div style="margin-top: 20px;">
|
||||
<div style="width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
211
frontend/components/header.html
Normal file
211
frontend/components/header.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!-- 공통 헤더 컴포넌트 -->
|
||||
<header class="header-modern fade-in">
|
||||
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 - 2가지 기능만 -->
|
||||
<nav class="flex space-x-8">
|
||||
<!-- 문서 관리 시스템 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="index.html" class="nav-link" id="doc-nav-link">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>문서 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
|
||||
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모장 시스템 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="memo-tree.html" class="nav-link" id="memo-nav-link">
|
||||
<i class="fas fa-sitemap"></i>
|
||||
<span>메모장</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
|
||||
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
|
||||
</a>
|
||||
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
|
||||
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 로그인/로그아웃 -->
|
||||
<div class="flex items-center space-x-3" id="user-menu">
|
||||
<!-- 로그인된 사용자 -->
|
||||
<div class="hidden" id="logged-in-menu">
|
||||
<span class="text-sm text-gray-600" id="user-name">User</span>
|
||||
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
|
||||
<i class="fas fa-sign-out-alt"></i> 로그아웃
|
||||
</button>
|
||||
</div>
|
||||
<!-- 로그인 버튼 -->
|
||||
<div class="hidden" id="login-button">
|
||||
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
|
||||
<i class="fas fa-sign-in-alt"></i> 로그인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 헤더 관련 스타일 -->
|
||||
<style>
|
||||
/* 네비게이션 링크 스타일 */
|
||||
.nav-link {
|
||||
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 스타일 */
|
||||
.nav-dropdown {
|
||||
@apply absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-2 min-w-44 z-50;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.nav-dropdown-item.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
}
|
||||
|
||||
/* 헤더 모던 스타일 */
|
||||
.header-modern {
|
||||
@apply bg-white border-b border-gray-200 shadow-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 헤더 관련 JavaScript 함수들 -->
|
||||
<script>
|
||||
// 헤더 관련 유틸리티 함수들
|
||||
window.headerUtils = {
|
||||
getCurrentPage() {
|
||||
const path = window.location.pathname;
|
||||
const filename = path.split('/').pop().replace('.html', '');
|
||||
return filename || 'index';
|
||||
},
|
||||
|
||||
isDocumentPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['index', 'hierarchy'].includes(page);
|
||||
},
|
||||
|
||||
isMemoPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['memo-tree', 'story-view'].includes(page);
|
||||
}
|
||||
};
|
||||
|
||||
// Alpine.js 전역 함수로 등록 - 즉시 실행
|
||||
if (typeof Alpine !== 'undefined') {
|
||||
// Alpine이 이미 로드된 경우
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isMemoPage: () => headerUtils.isMemoPage(),
|
||||
});
|
||||
} else {
|
||||
// Alpine 로드 대기
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isMemoPage: () => headerUtils.isMemoPage(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수로도 등록 (Alpine 외부에서도 사용 가능)
|
||||
window.getCurrentPage = () => headerUtils.getCurrentPage();
|
||||
window.isDocumentPage = () => headerUtils.isDocumentPage();
|
||||
window.isMemoPage = () => headerUtils.isMemoPage();
|
||||
|
||||
// 로그인 관련 함수들
|
||||
window.handleLogin = () => {
|
||||
console.log('🔐 handleLogin 호출됨');
|
||||
|
||||
// Alpine.js 컨텍스트에서 함수 찾기
|
||||
const bodyElement = document.querySelector('body');
|
||||
if (bodyElement && bodyElement._x_dataStack) {
|
||||
const alpineData = bodyElement._x_dataStack[0];
|
||||
if (alpineData && typeof alpineData.openLoginModal === 'function') {
|
||||
console.log('✅ Alpine 컨텍스트에서 openLoginModal 호출');
|
||||
alpineData.openLoginModal();
|
||||
return;
|
||||
}
|
||||
if (alpineData && alpineData.showLoginModal !== undefined) {
|
||||
console.log('✅ Alpine 컨텍스트에서 showLoginModal 설정');
|
||||
alpineData.showLoginModal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 시도
|
||||
if (typeof window.openLoginModal === 'function') {
|
||||
console.log('✅ 전역 openLoginModal 호출');
|
||||
window.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 직접 이벤트 발생
|
||||
console.log('🔄 커스텀 이벤트로 로그인 모달 열기');
|
||||
document.dispatchEvent(new CustomEvent('open-login-modal'));
|
||||
};
|
||||
|
||||
window.handleLogout = () => {
|
||||
// 각 페이지의 로그아웃 함수 호출
|
||||
if (typeof logout === 'function') {
|
||||
logout();
|
||||
} else {
|
||||
console.log('로그아웃 함수를 찾을 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 상태 업데이트 함수
|
||||
window.updateUserMenu = (user) => {
|
||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const userName = document.getElementById('user-name');
|
||||
|
||||
if (user) {
|
||||
// 로그인된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.remove('hidden');
|
||||
if (loginButton) loginButton.classList.add('hidden');
|
||||
if (userName) userName.textContent = user.username || user.full_name || user.email || 'User';
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 언어 토글 함수 (전역)
|
||||
window.toggleLanguage = () => {
|
||||
console.log('🌐 언어 토글 기능 (미구현)');
|
||||
// 향후 다국어 지원 시 구현
|
||||
};
|
||||
</script>
|
||||
@@ -1,844 +0,0 @@
|
||||
<!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>
|
||||
<a href="memo-tree.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">🌳 트리 메모장</a>
|
||||
</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="console.log('🪟 메모창 토글:', showFloatingMemo, '→', !showFloatingMemo); showFloatingMemo = !showFloatingMemo"
|
||||
class="px-3 py-2 rounded transition-colors"
|
||||
:class="showFloatingMemo ? 'bg-blue-500 text-white' : 'text-blue-600 border border-blue-600 hover:bg-blue-50'"
|
||||
title="메모 & 하이라이트 창"
|
||||
>
|
||||
<i class="fas fa-window-restore mr-1"></i>
|
||||
메모창 (<span x-text="notes.length + highlights.length"></span>)
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 플로팅 메모창 -->
|
||||
<div
|
||||
x-show="showFloatingMemo && selectedDocument"
|
||||
x-init="console.log('🪟 플로팅 메모창 초기화:', { showFloatingMemo, selectedDocument: !!selectedDocument })"
|
||||
class="fixed bg-white rounded-lg shadow-2xl border z-40 flex flex-col"
|
||||
:style="`left: ${floatingMemoPosition.x}px; top: ${floatingMemoPosition.y}px; width: ${floatingMemoSize.width}px; height: ${floatingMemoSize.height}px;`"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
>
|
||||
<!-- 플로팅 창 헤더 (드래그 가능) -->
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-t-lg cursor-move"
|
||||
@mousedown="startDragging($event)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-window-restore"></i>
|
||||
<h3 class="font-semibold">📝 메모 & 하이라이트</h3>
|
||||
<div class="flex space-x-1">
|
||||
<span class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded-full" x-text="`${notes.length}개 메모`"></span>
|
||||
<span class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded-full" x-text="`${highlights.length}개 하이라이트`"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 크기 조절 버튼들 -->
|
||||
<button
|
||||
@click="toggleFloatingMemoSize()"
|
||||
class="text-white hover:bg-white hover:bg-opacity-20 p-1 rounded"
|
||||
title="크기 조절"
|
||||
>
|
||||
<i class="fas fa-expand-arrows-alt text-sm"></i>
|
||||
</button>
|
||||
<!-- 닫기 버튼 -->
|
||||
<button
|
||||
@click="showFloatingMemo = false"
|
||||
class="text-white hover:bg-white hover:bg-opacity-20 p-1 rounded"
|
||||
title="닫기"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 검색 -->
|
||||
<div class="p-3 border-b bg-gray-50">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="memoSearchQuery"
|
||||
@input="filterMemos()"
|
||||
placeholder="메모 검색..."
|
||||
class="w-full pl-8 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<i class="fas fa-search absolute left-2.5 top-2.5 text-gray-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 목록 -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div x-show="(filteredNotes.length === 0 && highlights.length === 0)" class="text-center text-gray-500 py-8">
|
||||
<i class="fas fa-sticky-note text-3xl text-gray-300 mb-2"></i>
|
||||
<p class="text-sm">아직 메모나 하이라이트가 없습니다.</p>
|
||||
<p class="text-xs text-gray-400 mt-1">문서에서 텍스트를 선택하여 하이라이트를 만들어보세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 하이라이트 섹션 -->
|
||||
<div x-show="highlights.length > 0" class="mb-4">
|
||||
<h4 class="font-medium text-gray-700 mb-3 flex items-center">
|
||||
<i class="fas fa-highlighter text-yellow-500 mr-2"></i>
|
||||
하이라이트 (<span x-text="highlights.length"></span>)
|
||||
</h4>
|
||||
<template x-for="highlight in highlights" :key="highlight.id">
|
||||
<div class="mb-3 p-3 bg-gray-50 rounded-lg border-l-4" :class="{
|
||||
'border-yellow-400': highlight.highlight_color === 'yellow',
|
||||
'border-blue-400': highlight.highlight_color === 'blue',
|
||||
'border-green-400': highlight.highlight_color === 'green',
|
||||
'border-red-400': highlight.highlight_color === 'red'
|
||||
}">
|
||||
<div class="text-sm text-gray-600 mb-1" x-text="highlight.selected_text"></div>
|
||||
<div x-show="highlight.notes && highlight.notes.content" class="text-xs text-gray-500 mt-2 p-2 bg-white rounded border">
|
||||
<i class="fas fa-comment mr-1"></i>
|
||||
<span x-text="highlight.notes?.content || ''"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-400" x-text="new Date(highlight.created_at).toLocaleDateString()"></span>
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
@click="scrollToHighlight(highlight.id)"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 p-1"
|
||||
title="하이라이트로 이동"
|
||||
>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteHighlight(highlight.id)"
|
||||
class="text-xs text-red-600 hover:text-red-800 p-1"
|
||||
title="삭제"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 독립 메모 섹션 -->
|
||||
<div x-show="filteredNotes.length > 0">
|
||||
<h4 class="font-medium text-gray-700 mb-3 flex items-center">
|
||||
<i class="fas fa-sticky-note text-blue-500 mr-2"></i>
|
||||
독립 메모 (<span x-text="filteredNotes.length"></span>)
|
||||
</h4>
|
||||
<template x-for="note in filteredNotes" :key="note.id">
|
||||
<div class="mb-3 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-400">
|
||||
<div class="text-sm text-gray-700" x-text="note.content"></div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-400" x-text="new Date(note.created_at).toLocaleDateString()"></span>
|
||||
<button
|
||||
@click="deleteNote(note.id)"
|
||||
class="text-xs text-red-600 hover:text-red-800 p-1"
|
||||
title="삭제"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 크기 조절 핸들 -->
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-gray-300 hover:bg-gray-400"
|
||||
@mousedown="startResizing($event)"
|
||||
title="크기 조절"
|
||||
>
|
||||
<i class="fas fa-grip-lines text-xs text-gray-600 absolute bottom-0.5 right-0.5"></i>
|
||||
</div>
|
||||
</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=2025012227"></script>
|
||||
<script src="static/js/auth.js?v=2025012227"></script>
|
||||
<script src="static/js/hierarchy.js?v=2025012227"></script>
|
||||
|
||||
<!-- 디버깅 스크립트 -->
|
||||
<script>
|
||||
console.log('🔍 디버깅: hierarchyApp 함수 존재 여부:', typeof window.hierarchyApp);
|
||||
if (typeof window.hierarchyApp === 'function') {
|
||||
const testInstance = window.hierarchyApp();
|
||||
console.log('🔍 디버깅: showFloatingMemo 변수 존재 여부:', 'showFloatingMemo' in testInstance);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,6 +9,9 @@
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 메인 앱 -->
|
||||
@@ -48,87 +51,8 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<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 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>
|
||||
<a href="memo-tree.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">🌳 트리 메모장</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색바 -->
|
||||
<div class="flex-1 max-w-lg mx-8" x-show="isAuthenticated">
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchQuery" @input="searchDocuments"
|
||||
placeholder="문서, 메모 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<template x-if="!isAuthenticated">
|
||||
<button @click="showLoginModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
로그인
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template x-if="isAuthenticated">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 업로드 버튼 -->
|
||||
<button @click="showUploadModal = true"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
업로드
|
||||
</button>
|
||||
|
||||
<!-- 사용자 드롭다운 -->
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button @click="open = !open" class="flex items-center text-gray-700 hover:text-gray-900">
|
||||
<i class="fas fa-user-circle text-2xl"></i>
|
||||
<span x-text="user?.full_name || user?.email" class="ml-2"></span>
|
||||
<i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
|
||||
<div x-show="open" @click.away="open = false" x-cloak
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
<a href="#" @click="showProfile = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-user mr-2"></i>프로필
|
||||
</a>
|
||||
<a href="#" @click="showMyNotes = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sticky-note mr-2"></i>내 메모
|
||||
</a>
|
||||
<a href="#" @click="showBookmarks = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-bookmark mr-2"></i>책갈피
|
||||
</a>
|
||||
<template x-if="user?.is_admin">
|
||||
<a href="#" @click="showAdmin = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-cog mr-2"></i>관리자
|
||||
</a>
|
||||
</template>
|
||||
<hr class="my-1">
|
||||
<a href="#" @click="logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 공통 헤더 컨테이너 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@@ -149,35 +73,69 @@
|
||||
<template x-if="isAuthenticated">
|
||||
<div>
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="mb-6">
|
||||
<!-- 첫 번째 줄: 제목과 뷰 모드 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
|
||||
<select x-model="selectedTag" @change="loadDocuments"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="viewMode = 'grid'"
|
||||
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-th-large mr-2"></i>그리드
|
||||
</button>
|
||||
<button @click="viewMode = 'books'"
|
||||
:class="viewMode === 'books' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-list mr-2"></i>목차
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 두 번째 줄: 검색과 필터 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 검색 입력창 -->
|
||||
<div class="flex-1 relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterDocuments"
|
||||
placeholder="문서 제목, 내용, 태그로 검색..."
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm transition-all duration-200">
|
||||
<i class="fas fa-search absolute left-3 top-3.5 text-gray-400"></i>
|
||||
<!-- 검색 결과 개수 표시 -->
|
||||
<span x-show="searchQuery && filteredDocuments.length !== documents.length"
|
||||
class="absolute right-3 top-3 text-sm text-gray-500">
|
||||
<span x-text="filteredDocuments.length"></span>개 결과
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 태그 필터 -->
|
||||
<select x-model="selectedTag" @change="filterDocuments"
|
||||
class="px-4 py-2.5 border border-gray-200 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[140px]">
|
||||
<option value="">모든 태그</option>
|
||||
<template x-for="tag in tags" :key="tag.id">
|
||||
<option :value="tag.name" x-text="tag.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="viewMode = 'grid'"
|
||||
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-th-large"></i>
|
||||
|
||||
<!-- 업로드 버튼 -->
|
||||
<button @click="openUploadPage()"
|
||||
class="px-6 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center space-x-2">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span>업로드</span>
|
||||
</button>
|
||||
<button @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-list"></i>
|
||||
|
||||
<!-- 검색 초기화 버튼 -->
|
||||
<button x-show="searchQuery || selectedTag"
|
||||
@click="clearFilters"
|
||||
class="px-4 py-2.5 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-all duration-200">
|
||||
<i class="fas fa-times mr-2"></i>초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 그리드/리스트 -->
|
||||
<!-- 기존 그리드 뷰 (모든 문서 평면적으로) -->
|
||||
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template x-for="doc in documents" :key="doc.id">
|
||||
<template x-for="doc in filteredDocuments" :key="doc.id">
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openDocument(doc.id)">
|
||||
<div class="p-6">
|
||||
@@ -229,17 +187,66 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 서적별 그룹화 뷰 (목차) -->
|
||||
<div x-show="viewMode === 'books'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- 서적 카드들 -->
|
||||
<template x-for="bookGroup in groupedDocuments" :key="bookGroup.book?.id || 'no-book'">
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200"
|
||||
@click="openBookDocuments(bookGroup.book)">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-book text-white text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 line-clamp-2" x-text="bookGroup.book?.title || '서적 미분류'"></h3>
|
||||
<p class="text-sm text-gray-600 mt-1" x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-3">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
<span x-text="bookGroup.documents.length"></span>개 문서
|
||||
</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm line-clamp-2" x-text="bookGroup.book?.description || '서적 설명이 없습니다'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<template x-if="documents.length === 0 && !loading">
|
||||
<template x-if="filteredDocuments.length === 0 && !loading">
|
||||
<div class="text-center py-16">
|
||||
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
|
||||
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
|
||||
<button @click="showUploadModal = true"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
문서 업로드
|
||||
</button>
|
||||
<template x-if="searchQuery || selectedTag">
|
||||
<!-- 검색 결과 없음 -->
|
||||
<div>
|
||||
<i class="fas fa-search text-6xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">검색 결과가 없습니다</h3>
|
||||
<p class="text-gray-600 mb-6">다른 검색어나 필터를 시도해보세요</p>
|
||||
<button @click="clearFilters"
|
||||
class="bg-gray-600 text-white px-6 py-3 rounded-lg hover:bg-gray-700">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!searchQuery && !selectedTag">
|
||||
<!-- 문서 없음 -->
|
||||
<div>
|
||||
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
|
||||
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
|
||||
<button @click="showUploadModal = true"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
문서 업로드
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -478,8 +485,8 @@
|
||||
</style>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012225"></script>
|
||||
<script src="/static/js/auth.js?v=2025012225"></script>
|
||||
<script src="/static/js/main.js?v=2025012225"></script>
|
||||
<script src="/static/js/api.js?v=2025012380"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/main.js?v=2025012374"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,12 @@
|
||||
*/
|
||||
class DocumentServerAPI {
|
||||
constructor() {
|
||||
this.baseURL = '/api'; // Nginx 프록시를 통해 접근
|
||||
// 도커 백엔드 API (24102 포트)
|
||||
this.baseURL = 'http://localhost:24102/api';
|
||||
this.token = localStorage.getItem('access_token');
|
||||
|
||||
console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL);
|
||||
console.log('🔧 도커 환경 설정 완료 - 버전 2025012380');
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
@@ -184,6 +188,10 @@ class DocumentServerAPI {
|
||||
return await this.uploadFile('/documents/', formData);
|
||||
}
|
||||
|
||||
async updateDocument(documentId, updateData) {
|
||||
return await this.put(`/documents/${documentId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteDocument(documentId) {
|
||||
return await this.delete(`/documents/${documentId}`);
|
||||
}
|
||||
|
||||
@@ -17,26 +17,40 @@ window.authModal = () => ({
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
console.log('🔐 로그인 시도:', this.loginForm.email);
|
||||
|
||||
// 토큰 저장
|
||||
window.api.setToken(response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
// API 클래스의 login 메서드 사용 (이미 토큰 설정과 사용자 정보 가져오기 포함)
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const userResponse = await window.api.getCurrentUser();
|
||||
console.log('✅ 로그인 결과:', result);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: true, user: userResponse }
|
||||
}));
|
||||
|
||||
// 모달 닫기 (부모 컴포넌트의 상태 변경)
|
||||
window.dispatchEvent(new CustomEvent('close-login-modal'));
|
||||
this.loginForm = { email: '', password: '' };
|
||||
if (result.success) {
|
||||
// refresh_token 저장 (access_token은 API 클래스에서 이미 처리됨)
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.loginForm)
|
||||
});
|
||||
const tokenData = await loginResponse.json();
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token);
|
||||
|
||||
console.log('💾 토큰 저장 완료');
|
||||
|
||||
// 전역 상태 업데이트
|
||||
window.dispatchEvent(new CustomEvent('auth-changed', {
|
||||
detail: { isAuthenticated: true, user: result.user }
|
||||
}));
|
||||
|
||||
// 모달 닫기
|
||||
window.dispatchEvent(new CustomEvent('close-login-modal'));
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
} else {
|
||||
this.loginError = result.message || '로그인에 실패했습니다';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 오류:', error);
|
||||
this.loginError = error.message || '로그인에 실패했습니다';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
|
||||
180
frontend/static/js/book-documents.js
Normal file
180
frontend/static/js/book-documents.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// 서적 문서 목록 애플리케이션 컴포넌트
|
||||
window.bookDocumentsApp = () => ({
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
bookInfo: {},
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// URL 파라미터
|
||||
bookId: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Book Documents App 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadBookDocuments();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.bookId = urlParams.get('bookId');
|
||||
console.log('📖 서적 ID:', this.bookId);
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// 서적 문서 목록 로드
|
||||
async loadBookDocuments() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// 모든 문서 가져오기
|
||||
const allDocuments = await window.api.getDocuments();
|
||||
|
||||
if (this.bookId === 'none') {
|
||||
// 서적 미분류 문서들
|
||||
this.documents = allDocuments.filter(doc => !doc.book_id);
|
||||
this.bookInfo = {
|
||||
title: '서적 미분류',
|
||||
description: '서적에 속하지 않은 문서들입니다.'
|
||||
};
|
||||
} else {
|
||||
// 특정 서적의 문서들
|
||||
this.documents = allDocuments.filter(doc => doc.book_id === this.bookId);
|
||||
|
||||
if (this.documents.length > 0) {
|
||||
// 첫 번째 문서에서 서적 정보 추출
|
||||
const firstDoc = this.documents[0];
|
||||
this.bookInfo = {
|
||||
id: firstDoc.book_id,
|
||||
title: firstDoc.book_title,
|
||||
author: firstDoc.book_author,
|
||||
description: firstDoc.book_description || '서적 설명이 없습니다.'
|
||||
};
|
||||
} else {
|
||||
// 서적 정보만 가져오기 (문서가 없는 경우)
|
||||
try {
|
||||
this.bookInfo = await window.api.getBook(this.bookId);
|
||||
} catch (error) {
|
||||
console.error('서적 정보 로드 실패:', error);
|
||||
this.bookInfo = {
|
||||
title: '알 수 없는 서적',
|
||||
description: '서적 정보를 불러올 수 없습니다.'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📚 서적 문서 로드 완료:', this.documents.length, '개');
|
||||
} catch (error) {
|
||||
console.error('서적 문서 로드 실패:', error);
|
||||
this.error = '문서를 불러오는데 실패했습니다: ' + error.message;
|
||||
this.documents = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 열기
|
||||
openDocument(documentId) {
|
||||
// 현재 페이지 정보를 세션 스토리지에 저장
|
||||
sessionStorage.setItem('previousPage', 'book-documents.html');
|
||||
|
||||
// 뷰어로 이동
|
||||
window.open(`/viewer.html?id=${documentId}&from=book`, '_blank');
|
||||
},
|
||||
|
||||
// 문서 수정
|
||||
editDocument(doc) {
|
||||
// TODO: 문서 수정 모달 또는 페이지로 이동
|
||||
console.log('문서 수정:', doc.title);
|
||||
alert('문서 수정 기능은 준비 중입니다.');
|
||||
},
|
||||
|
||||
// 문서 삭제
|
||||
async deleteDocument(documentId) {
|
||||
if (!confirm('이 문서를 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.deleteDocument(documentId);
|
||||
await this.loadBookDocuments(); // 목록 새로고침
|
||||
this.showNotification('문서가 삭제되었습니다', 'success');
|
||||
} catch (error) {
|
||||
console.error('문서 삭제 실패:', error);
|
||||
this.showNotification('문서 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
window.location.href = 'index.html';
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
// TODO: 알림 시스템 구현
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
if (type === 'error') {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Book Documents 페이지 로드됨');
|
||||
});
|
||||
159
frontend/static/js/header-loader.js
Normal file
159
frontend/static/js/header-loader.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 공통 헤더 로더
|
||||
* 모든 페이지에서 동일한 헤더를 로드하기 위한 유틸리티
|
||||
*/
|
||||
|
||||
class HeaderLoader {
|
||||
constructor() {
|
||||
this.headerLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 HTML을 로드하고 삽입
|
||||
*/
|
||||
async loadHeader(targetSelector = '#header-container') {
|
||||
if (this.headerLoaded) {
|
||||
console.log('✅ 헤더가 이미 로드됨');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 헤더 로딩 중...');
|
||||
|
||||
const response = await fetch('components/header.html?v=2025012352');
|
||||
if (!response.ok) {
|
||||
throw new Error(`헤더 로드 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const headerHtml = await response.text();
|
||||
|
||||
// 헤더 컨테이너 찾기
|
||||
const container = document.querySelector(targetSelector);
|
||||
if (!container) {
|
||||
throw new Error(`헤더 컨테이너를 찾을 수 없음: ${targetSelector}`);
|
||||
}
|
||||
|
||||
// 헤더 HTML 삽입
|
||||
container.innerHTML = headerHtml;
|
||||
|
||||
this.headerLoaded = true;
|
||||
console.log('✅ 헤더 로드 완료');
|
||||
|
||||
// 헤더 로드 완료 이벤트 발생
|
||||
document.dispatchEvent(new CustomEvent('headerLoaded'));
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 헤더 로드 오류:', error);
|
||||
this.showFallbackHeader(targetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 로드 실패 시 폴백 헤더 표시
|
||||
*/
|
||||
showFallbackHeader(targetSelector) {
|
||||
const container = document.querySelector(targetSelector);
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
<nav class="flex space-x-6">
|
||||
<a href="index.html" class="text-gray-600 hover:text-blue-600">📁 문서 관리</a>
|
||||
<a href="memo-tree.html" class="text-gray-600 hover:text-blue-600">🌳 메모장</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 정보 가져오기
|
||||
*/
|
||||
getCurrentPageInfo() {
|
||||
const path = window.location.pathname;
|
||||
const filename = path.split('/').pop().replace('.html', '') || 'index';
|
||||
|
||||
const pageInfo = {
|
||||
filename,
|
||||
isDocumentPage: ['index', 'hierarchy'].includes(filename),
|
||||
isMemoPage: ['memo-tree', 'story-view'].includes(filename)
|
||||
};
|
||||
|
||||
return pageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 활성 상태 업데이트
|
||||
*/
|
||||
updateActiveStates() {
|
||||
const pageInfo = this.getCurrentPageInfo();
|
||||
|
||||
// 모든 활성 클래스 제거
|
||||
document.querySelectorAll('.nav-link, .nav-dropdown-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 페이지에 따라 활성 상태 설정
|
||||
console.log('현재 페이지:', pageInfo.filename);
|
||||
|
||||
if (pageInfo.isDocumentPage) {
|
||||
const docLink = document.getElementById('doc-nav-link');
|
||||
if (docLink) {
|
||||
docLink.classList.add('active');
|
||||
console.log('문서 관리 메뉴 활성화');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageInfo.isMemoPage) {
|
||||
const memoLink = document.getElementById('memo-nav-link');
|
||||
if (memoLink) {
|
||||
memoLink.classList.add('active');
|
||||
console.log('메모장 메뉴 활성화');
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 페이지 드롭다운 아이템 활성화
|
||||
const pageItemMap = {
|
||||
'index': 'index-nav-item',
|
||||
'hierarchy': 'hierarchy-nav-item',
|
||||
'memo-tree': 'memo-tree-nav-item',
|
||||
'story-view': 'story-view-nav-item'
|
||||
};
|
||||
|
||||
const itemId = pageItemMap[pageInfo.filename];
|
||||
if (itemId) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (item) {
|
||||
item.classList.add('active');
|
||||
console.log(`${pageInfo.filename} 페이지 아이템 활성화`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 인스턴스 생성
|
||||
window.headerLoader = new HeaderLoader();
|
||||
|
||||
// DOM 로드 완료 시 자동 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.headerLoader.loadHeader();
|
||||
});
|
||||
|
||||
// 헤더 로드 완료 후 활성 상태 업데이트
|
||||
document.addEventListener('headerLoaded', () => {
|
||||
setTimeout(() => {
|
||||
window.headerLoader.updateActiveStates();
|
||||
|
||||
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ window.documentApp = () => ({
|
||||
// 상태 관리
|
||||
documents: [],
|
||||
filteredDocuments: [],
|
||||
groupedDocuments: [],
|
||||
expandedBooks: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
@@ -17,20 +19,34 @@ window.documentApp = () => ({
|
||||
availableTags: [],
|
||||
|
||||
// UI 상태
|
||||
viewMode: 'grid', // 'grid' 또는 'list'
|
||||
viewMode: 'grid', // 'grid' 또는 'books'
|
||||
user: null, // currentUser의 별칭
|
||||
tags: [], // availableTags의 별칭
|
||||
|
||||
// 모달 상태
|
||||
showUploadModal: false,
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
await this.checkAuthStatus();
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadDocuments();
|
||||
} else {
|
||||
// 로그인하지 않은 경우에도 빈 배열로 초기화
|
||||
this.groupedDocuments = [];
|
||||
}
|
||||
this.setupEventListeners();
|
||||
|
||||
// 커스텀 이벤트 리스너 등록
|
||||
document.addEventListener('open-login-modal', () => {
|
||||
console.log('📨 open-login-modal 이벤트 수신 (index.html)');
|
||||
this.openLoginModal();
|
||||
});
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
@@ -162,6 +178,7 @@ window.documentApp = () => ({
|
||||
}
|
||||
|
||||
this.filteredDocuments = filtered;
|
||||
this.groupDocumentsByBook();
|
||||
},
|
||||
|
||||
// 검색어 변경 시
|
||||
@@ -169,6 +186,59 @@ window.documentApp = () => ({
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 필터 초기화
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.selectedTag = '';
|
||||
this.filterDocuments();
|
||||
},
|
||||
|
||||
// 서적별 그룹화
|
||||
groupDocumentsByBook() {
|
||||
if (!this.filteredDocuments || this.filteredDocuments.length === 0) {
|
||||
this.groupedDocuments = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
|
||||
this.filteredDocuments.forEach(doc => {
|
||||
const bookKey = doc.book_id || 'no-book';
|
||||
if (!grouped[bookKey]) {
|
||||
grouped[bookKey] = {
|
||||
book: doc.book_id ? {
|
||||
id: doc.book_id,
|
||||
title: doc.book_title,
|
||||
author: doc.book_author
|
||||
} : null,
|
||||
documents: []
|
||||
};
|
||||
}
|
||||
grouped[bookKey].documents.push(doc);
|
||||
});
|
||||
|
||||
// 배열로 변환하고 정렬 (서적 있는 것 먼저, 그 다음 서적명 순)
|
||||
this.groupedDocuments = Object.values(grouped).sort((a, b) => {
|
||||
if (!a.book && b.book) return 1;
|
||||
if (a.book && !b.book) return -1;
|
||||
if (!a.book && !b.book) return 0;
|
||||
return a.book.title.localeCompare(b.book.title);
|
||||
});
|
||||
|
||||
// 기본적으로 모든 서적을 펼친 상태로 설정
|
||||
this.expandedBooks = this.groupedDocuments.map(group => group.book?.id || 'no-book');
|
||||
},
|
||||
|
||||
// 서적 펼침/접힘 토글
|
||||
toggleBookExpansion(bookId) {
|
||||
const index = this.expandedBooks.indexOf(bookId);
|
||||
if (index > -1) {
|
||||
this.expandedBooks.splice(index, 1);
|
||||
} else {
|
||||
this.expandedBooks.push(bookId);
|
||||
}
|
||||
},
|
||||
|
||||
// 문서 검색 (HTML에서 사용)
|
||||
searchDocuments() {
|
||||
this.filterDocuments();
|
||||
@@ -204,7 +274,29 @@ window.documentApp = () => ({
|
||||
|
||||
// 문서 보기
|
||||
viewDocument(documentId) {
|
||||
window.open(`/viewer.html?id=${documentId}`, '_blank');
|
||||
// 현재 페이지 정보를 세션 스토리지에 저장
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
|
||||
sessionStorage.setItem('previousPage', currentPage);
|
||||
|
||||
// from 파라미터 추가
|
||||
const fromParam = currentPage === 'hierarchy.html' ? 'hierarchy' : 'index';
|
||||
window.open(`/viewer.html?id=${documentId}&from=${fromParam}`, '_blank');
|
||||
},
|
||||
|
||||
// 서적의 문서들 보기
|
||||
openBookDocuments(book) {
|
||||
if (book && book.id) {
|
||||
// 서적 ID를 URL 파라미터로 전달하여 해당 서적의 문서들만 표시
|
||||
window.location.href = `book-documents.html?bookId=${book.id}`;
|
||||
} else {
|
||||
// 서적 미분류 문서들 보기
|
||||
window.location.href = `book-documents.html?bookId=none`;
|
||||
}
|
||||
},
|
||||
|
||||
// 업로드 페이지 열기
|
||||
openUploadPage() {
|
||||
window.location.href = 'upload.html';
|
||||
},
|
||||
|
||||
// 문서 열기 (HTML에서 사용)
|
||||
|
||||
@@ -21,6 +21,7 @@ window.memoTreeApp = function() {
|
||||
showNewNodeModal: false,
|
||||
showTreeSettings: false,
|
||||
showLoginModal: false,
|
||||
showMobileEditModal: false,
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
@@ -59,10 +60,86 @@ window.memoTreeApp = function() {
|
||||
dragNode: null,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
},
|
||||
|
||||
async login() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
// 토큰 저장
|
||||
window.api.setToken(response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const userResponse = await window.api.getCurrentUser();
|
||||
this.currentUser = userResponse;
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(userResponse);
|
||||
}
|
||||
|
||||
// 사용자 트리 목록 로드
|
||||
await this.loadUserTrees();
|
||||
|
||||
// 모달 닫기
|
||||
this.showLoginModal = false;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
console.log('✅ 로그인 성공');
|
||||
|
||||
} catch (error) {
|
||||
this.loginError = error.message || '로그인에 실패했습니다';
|
||||
console.error('❌ 로그인 오류:', error);
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await window.api.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
// 로컬 스토리지 정리
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.api.setToken(null);
|
||||
|
||||
// 상태 초기화
|
||||
this.currentUser = null;
|
||||
this.userTrees = [];
|
||||
this.selectedTreeId = '';
|
||||
this.selectedTree = null;
|
||||
this.treeNodes = [];
|
||||
this.selectedNode = null;
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
|
||||
console.log('✅ 로그아웃 완료');
|
||||
}
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🌳 트리 메모장 초기화 중...');
|
||||
|
||||
// 커스텀 이벤트 리스너 등록
|
||||
document.addEventListener('open-login-modal', () => {
|
||||
console.log('📨 open-login-modal 이벤트 수신');
|
||||
this.openLoginModal();
|
||||
});
|
||||
|
||||
// API 객체가 로드될 때까지 대기 (더 긴 시간)
|
||||
let retries = 0;
|
||||
while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) {
|
||||
@@ -149,10 +226,14 @@ window.memoTreeApp = function() {
|
||||
this.selectNode(this.treeNodes[0]);
|
||||
}
|
||||
|
||||
// 트리 다이어그램 위치 계산
|
||||
// 트리 다이어그램 위치 계산 및 중앙 정렬
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 중앙 정렬
|
||||
setTimeout(() => {
|
||||
this.centerTree();
|
||||
}, 50);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
@@ -220,23 +301,74 @@ window.memoTreeApp = function() {
|
||||
|
||||
this.selectedNode = node;
|
||||
|
||||
// 에디터에 내용 로드
|
||||
if (monacoEditor && node.content) {
|
||||
// 에디터에 내용 로드 (빈 내용도 포함)
|
||||
if (monacoEditor) {
|
||||
monacoEditor.setValue(node.content || '');
|
||||
this.isEditorDirty = false;
|
||||
}
|
||||
|
||||
// 모바일에서만 편집 모달 열기 (기존 동작에 추가)
|
||||
if (window.innerWidth < 1024) { // lg 브레이크포인트
|
||||
this.showMobileEditModal = true;
|
||||
// 모바일 에디터 초기화 (약간의 지연 후)
|
||||
setTimeout(() => {
|
||||
this.initMobileEditor();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
console.log('📝 노드 선택:', node.title);
|
||||
},
|
||||
|
||||
// 모바일 에디터 초기화
|
||||
initMobileEditor() {
|
||||
if (!window.mobileMonacoEditor && this.selectedNode) {
|
||||
const container = document.getElementById('mobile-editor-container');
|
||||
if (container) {
|
||||
window.mobileMonacoEditor = monaco.editor.create(container, {
|
||||
value: this.selectedNode.content || '',
|
||||
language: 'markdown',
|
||||
theme: 'vs',
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: 'off',
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
glyphMargin: false
|
||||
});
|
||||
|
||||
// 모바일 에디터 변경 감지
|
||||
window.mobileMonacoEditor.onDidChangeModelContent(() => {
|
||||
if (this.selectedNode) {
|
||||
this.selectedNode.content = window.mobileMonacoEditor.getValue();
|
||||
this.isEditorDirty = true;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📱 모바일 에디터 초기화 완료');
|
||||
}
|
||||
} else if (window.mobileMonacoEditor && this.selectedNode) {
|
||||
// 기존 에디터가 있으면 내용만 업데이트
|
||||
window.mobileMonacoEditor.setValue(this.selectedNode.content || '');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 저장
|
||||
async saveNode() {
|
||||
if (!this.selectedNode) return;
|
||||
|
||||
try {
|
||||
// 에디터 내용 가져오기
|
||||
// 에디터 내용 가져오기 (데스크톱 또는 모바일)
|
||||
if (monacoEditor) {
|
||||
this.selectedNode.content = monacoEditor.getValue();
|
||||
} else if (window.mobileMonacoEditor) {
|
||||
this.selectedNode.content = window.mobileMonacoEditor.getValue();
|
||||
}
|
||||
|
||||
if (this.selectedNode.content !== undefined) {
|
||||
|
||||
// 단어 수 계산 (간단한 방식)
|
||||
const wordCount = this.selectedNode.content
|
||||
@@ -327,6 +459,37 @@ window.memoTreeApp = function() {
|
||||
};
|
||||
return icons[nodeType] || '📝';
|
||||
},
|
||||
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'memo': '메모',
|
||||
'folder': '폴더',
|
||||
'chapter': '챕터',
|
||||
'character': '캐릭터',
|
||||
'plot': '플롯'
|
||||
};
|
||||
return labels[nodeType] || '메모';
|
||||
},
|
||||
|
||||
getStatusIcon(status) {
|
||||
const icons = {
|
||||
'draft': '📝',
|
||||
'writing': '✍️',
|
||||
'review': '👀',
|
||||
'complete': '✅'
|
||||
};
|
||||
return icons[status] || '📝';
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '초안',
|
||||
'writing': '작성중',
|
||||
'review': '검토중',
|
||||
'complete': '완료'
|
||||
};
|
||||
return labels[status] || '초안';
|
||||
},
|
||||
|
||||
// 상태별 색상 클래스 가져오기
|
||||
getStatusColor(status) {
|
||||
@@ -486,6 +649,7 @@ window.memoTreeApp = function() {
|
||||
const nodeHeight = 80;
|
||||
const levelHeight = 150; // 레벨 간 간격
|
||||
const nodeSpacing = 50; // 노드 간 간격
|
||||
const margin = 100; // 여백
|
||||
|
||||
// 레벨별 노드 그룹화
|
||||
const levels = new Map();
|
||||
@@ -493,6 +657,8 @@ window.memoTreeApp = function() {
|
||||
// 루트 노드들 찾기
|
||||
const rootNodes = this.treeNodes.filter(node => !node.parent_id);
|
||||
|
||||
if (rootNodes.length === 0) return;
|
||||
|
||||
// BFS로 레벨별 노드 배치
|
||||
const queue = [];
|
||||
rootNodes.forEach(node => {
|
||||
@@ -514,11 +680,22 @@ window.memoTreeApp = function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 트리 전체 크기 계산
|
||||
const maxLevel = Math.max(...levels.keys());
|
||||
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
||||
|
||||
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
|
||||
const treeHeight = (maxLevel + 1) * levelHeight;
|
||||
|
||||
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
||||
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
||||
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
||||
|
||||
// 각 레벨의 노드들 위치 계산
|
||||
levels.forEach((nodes, level) => {
|
||||
const y = 100 + level * levelHeight; // 상단 여백 + 레벨 * 높이
|
||||
const totalWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = (canvasWidth - totalWidth) / 2;
|
||||
const y = offsetY + level * levelHeight;
|
||||
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = offsetX + (treeWidth - levelWidth) / 2;
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
||||
@@ -526,6 +703,14 @@ window.memoTreeApp = function() {
|
||||
});
|
||||
});
|
||||
|
||||
// 트리 크기 저장 (centerTree에서 사용)
|
||||
this.treeBounds = {
|
||||
width: treeWidth + 2 * margin,
|
||||
height: treeHeight + 2 * margin,
|
||||
offsetX: offsetX - margin,
|
||||
offsetY: offsetY - margin
|
||||
};
|
||||
|
||||
// 연결선 다시 그리기
|
||||
this.drawConnections();
|
||||
},
|
||||
@@ -598,9 +783,38 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 트리 중앙 정렬
|
||||
centerTree() {
|
||||
this.treePanX = 0;
|
||||
this.treePanY = 0;
|
||||
this.treeZoom = 1;
|
||||
const canvas = document.getElementById('tree-canvas');
|
||||
if (!canvas || !this.treeBounds) {
|
||||
this.treePanX = 0;
|
||||
this.treePanY = 0;
|
||||
this.treeZoom = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasWidth = canvas.clientWidth;
|
||||
const canvasHeight = canvas.clientHeight;
|
||||
|
||||
// 트리가 캔버스보다 큰 경우 적절한 줌 레벨 계산
|
||||
const scaleX = canvasWidth / this.treeBounds.width;
|
||||
const scaleY = canvasHeight / this.treeBounds.height;
|
||||
const optimalZoom = Math.min(scaleX, scaleY, 1) * 0.9; // 90%로 여유 공간 확보
|
||||
|
||||
// 줌 적용
|
||||
this.treeZoom = Math.max(0.1, Math.min(optimalZoom, 2));
|
||||
|
||||
// 중앙 정렬을 위한 팬 값 계산
|
||||
const scaledTreeWidth = this.treeBounds.width * this.treeZoom;
|
||||
const scaledTreeHeight = this.treeBounds.height * this.treeZoom;
|
||||
|
||||
this.treePanX = (canvasWidth - scaledTreeWidth) / 2 - this.treeBounds.offsetX * this.treeZoom;
|
||||
this.treePanY = (canvasHeight - scaledTreeHeight) / 2 - this.treeBounds.offsetY * this.treeZoom;
|
||||
|
||||
console.log('🎯 트리 중앙 정렬:', {
|
||||
zoom: this.treeZoom,
|
||||
panX: this.treePanX,
|
||||
panY: this.treePanY,
|
||||
treeBounds: this.treeBounds
|
||||
});
|
||||
},
|
||||
|
||||
// 확대
|
||||
@@ -613,16 +827,6 @@ window.memoTreeApp = function() {
|
||||
this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3);
|
||||
},
|
||||
|
||||
// 휠 이벤트 처리 (확대/축소)
|
||||
handleWheel(event) {
|
||||
event.preventDefault();
|
||||
if (event.deltaY < 0) {
|
||||
this.zoomIn();
|
||||
} else {
|
||||
this.zoomOut();
|
||||
}
|
||||
},
|
||||
|
||||
// 패닝 시작
|
||||
startPan(event) {
|
||||
if (event.target.closest('.tree-diagram-node')) return; // 노드 클릭 시 패닝 방지
|
||||
|
||||
333
frontend/static/js/story-reader.js
Normal file
333
frontend/static/js/story-reader.js
Normal file
@@ -0,0 +1,333 @@
|
||||
// 스토리 읽기 애플리케이션
|
||||
function storyReaderApp() {
|
||||
return {
|
||||
// 상태 변수들
|
||||
currentUser: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// 스토리 데이터
|
||||
selectedTree: null,
|
||||
canonicalNodes: [],
|
||||
currentChapter: null,
|
||||
currentChapterIndex: 0,
|
||||
|
||||
// URL 파라미터
|
||||
treeId: null,
|
||||
nodeId: null,
|
||||
chapterIndex: null,
|
||||
|
||||
// 편집 관련
|
||||
showEditModal: false,
|
||||
editingChapter: null,
|
||||
editEditor: null,
|
||||
saving: false,
|
||||
|
||||
// 로그인 관련
|
||||
showLoginModal: false,
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 스토리 리더 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 사용자 인증 확인
|
||||
await this.checkAuth();
|
||||
|
||||
if (this.currentUser) {
|
||||
await this.loadStoryData();
|
||||
}
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.treeId = urlParams.get('treeId');
|
||||
this.nodeId = urlParams.get('nodeId');
|
||||
this.chapterIndex = parseInt(urlParams.get('index')) || 0;
|
||||
|
||||
console.log('📖 URL 파라미터:', {
|
||||
treeId: this.treeId,
|
||||
nodeId: this.nodeId,
|
||||
chapterIndex: this.chapterIndex
|
||||
});
|
||||
},
|
||||
|
||||
// 인증 확인
|
||||
async checkAuth() {
|
||||
try {
|
||||
this.currentUser = await window.api.getCurrentUser();
|
||||
console.log('✅ 사용자 인증됨:', this.currentUser?.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증 실패:', error);
|
||||
this.currentUser = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 데이터 로드
|
||||
async loadStoryData() {
|
||||
if (!this.treeId) {
|
||||
this.error = '트리 ID가 필요합니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// 트리 정보 로드
|
||||
this.selectedTree = await window.api.getMemoTree(this.treeId);
|
||||
console.log('📚 트리 로드됨:', this.selectedTree.title);
|
||||
|
||||
// 트리의 모든 노드 로드
|
||||
const allNodes = await window.api.getMemoTreeNodes(this.treeId);
|
||||
|
||||
// 정사 노드만 필터링하고 순서대로 정렬
|
||||
this.canonicalNodes = allNodes
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
console.log('📝 정사 노드 수:', this.canonicalNodes.length);
|
||||
|
||||
if (this.canonicalNodes.length === 0) {
|
||||
this.error = '정사로 설정된 노드가 없습니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 챕터 설정
|
||||
this.setCurrentChapter();
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
this.error = '스토리를 불러오는데 실패했습니다';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 챕터 설정
|
||||
setCurrentChapter() {
|
||||
if (this.nodeId) {
|
||||
// 특정 노드 ID로 찾기
|
||||
const index = this.canonicalNodes.findIndex(node => node.id === this.nodeId);
|
||||
if (index !== -1) {
|
||||
this.currentChapterIndex = index;
|
||||
}
|
||||
} else if (this.chapterIndex >= 0 && this.chapterIndex < this.canonicalNodes.length) {
|
||||
// 인덱스로 설정
|
||||
this.currentChapterIndex = this.chapterIndex;
|
||||
} else {
|
||||
// 기본값: 첫 번째 챕터
|
||||
this.currentChapterIndex = 0;
|
||||
}
|
||||
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
console.log('📖 현재 챕터:', this.currentChapter?.title, `(${this.currentChapterIndex + 1}/${this.canonicalNodes.length})`);
|
||||
},
|
||||
|
||||
// 계산된 속성들
|
||||
get totalChapters() {
|
||||
return this.canonicalNodes.length;
|
||||
},
|
||||
|
||||
get hasPreviousChapter() {
|
||||
return this.currentChapterIndex > 0;
|
||||
},
|
||||
|
||||
get hasNextChapter() {
|
||||
return this.currentChapterIndex < this.canonicalNodes.length - 1;
|
||||
},
|
||||
|
||||
get previousChapter() {
|
||||
return this.hasPreviousChapter ? this.canonicalNodes[this.currentChapterIndex - 1] : null;
|
||||
},
|
||||
|
||||
get nextChapter() {
|
||||
return this.hasNextChapter ? this.canonicalNodes[this.currentChapterIndex + 1] : null;
|
||||
},
|
||||
|
||||
// 네비게이션 함수들
|
||||
goToPreviousChapter() {
|
||||
if (this.hasPreviousChapter) {
|
||||
this.currentChapterIndex--;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goToNextChapter() {
|
||||
if (this.hasNextChapter) {
|
||||
this.currentChapterIndex++;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goBackToStoryView() {
|
||||
window.location.href = `story-view.html?treeId=${this.treeId}`;
|
||||
},
|
||||
|
||||
editChapter() {
|
||||
if (this.currentChapter) {
|
||||
this.editingChapter = { ...this.currentChapter }; // 복사본 생성
|
||||
this.showEditModal = true;
|
||||
console.log('✅ 편집 모달 열림 (Textarea 방식)');
|
||||
}
|
||||
},
|
||||
|
||||
// 편집 취소
|
||||
cancelEdit() {
|
||||
this.showEditModal = false;
|
||||
this.editingChapter = null;
|
||||
console.log('✅ 편집 취소됨 (Textarea 방식)');
|
||||
},
|
||||
|
||||
// 편집 저장
|
||||
async saveEdit() {
|
||||
if (!this.editingChapter) {
|
||||
console.warn('⚠️ 편집 중인 챕터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.saving = true;
|
||||
|
||||
// 단어 수 계산
|
||||
const content = this.editingChapter.content || '';
|
||||
let wordCount = 0;
|
||||
if (content && content.trim()) {
|
||||
const words = content.trim().split(/\s+/);
|
||||
wordCount = words.filter(word => word.length > 0).length;
|
||||
}
|
||||
this.editingChapter.word_count = wordCount;
|
||||
|
||||
// API로 저장
|
||||
const updateData = {
|
||||
title: this.editingChapter.title,
|
||||
content: this.editingChapter.content,
|
||||
word_count: this.editingChapter.word_count
|
||||
};
|
||||
|
||||
await window.api.updateMemoNode(this.editingChapter.id, updateData);
|
||||
|
||||
// 현재 챕터 업데이트
|
||||
this.currentChapter.title = this.editingChapter.title;
|
||||
this.currentChapter.content = this.editingChapter.content;
|
||||
this.currentChapter.word_count = this.editingChapter.word_count;
|
||||
|
||||
// 정사 노드 목록에서도 업데이트
|
||||
const nodeIndex = this.canonicalNodes.findIndex(node => node.id === this.editingChapter.id);
|
||||
if (nodeIndex !== -1) {
|
||||
this.canonicalNodes[nodeIndex].title = this.editingChapter.title;
|
||||
this.canonicalNodes[nodeIndex].content = this.editingChapter.content;
|
||||
this.canonicalNodes[nodeIndex].word_count = this.editingChapter.word_count;
|
||||
}
|
||||
|
||||
console.log('✅ 챕터 저장 완료');
|
||||
this.cancelEdit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
updateUrl() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('nodeId', this.currentChapter.id);
|
||||
url.searchParams.set('index', this.currentChapterIndex.toString());
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 페이지 상단으로 스크롤
|
||||
scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// 인쇄
|
||||
printChapter() {
|
||||
window.print();
|
||||
},
|
||||
|
||||
// 콘텐츠 포맷팅
|
||||
formatContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// 마크다운 스타일 간단 변환
|
||||
return content
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
},
|
||||
|
||||
// 노드 타입 라벨
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'memo': '메모',
|
||||
'folder': '폴더',
|
||||
'chapter': '챕터',
|
||||
'character': '캐릭터',
|
||||
'plot': '플롯'
|
||||
};
|
||||
return labels[nodeType] || nodeType;
|
||||
},
|
||||
|
||||
// 상태 라벨
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '초안',
|
||||
'writing': '작성중',
|
||||
'review': '검토중',
|
||||
'complete': '완료'
|
||||
};
|
||||
return labels[status] || status;
|
||||
},
|
||||
|
||||
// 로그인 관련
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
try {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (result.access_token) {
|
||||
this.currentUser = result.user;
|
||||
this.showLoginModal = false;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
// 로그인 후 스토리 데이터 로드
|
||||
await this.loadStoryData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 실패:', error);
|
||||
this.loginError = '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,6 @@ window.storyViewApp = function() {
|
||||
canonicalNodes: [], // 정사 경로 노드들만
|
||||
|
||||
// UI 상태
|
||||
viewMode: 'toc', // 'toc' | 'full'
|
||||
showLoginModal: false,
|
||||
showEditModal: false,
|
||||
editingNode: null,
|
||||
@@ -55,6 +54,9 @@ window.storyViewApp = function() {
|
||||
// 인증된 경우 트리 목록 로드
|
||||
if (this.currentUser) {
|
||||
await this.loadUserTrees();
|
||||
|
||||
// URL 파라미터에서 treeId 확인하고 자동 선택
|
||||
this.checkUrlParams();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,6 +93,8 @@ window.storyViewApp = function() {
|
||||
if (!treeId) {
|
||||
this.selectedTree = null;
|
||||
this.canonicalNodes = [];
|
||||
// URL에서 treeId 파라미터 제거
|
||||
this.updateUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +112,9 @@ window.storyViewApp = function() {
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
// URL 업데이트
|
||||
this.updateUrl(treeId);
|
||||
|
||||
console.log(`✅ 스토리 로드 완료: ${this.canonicalNodes.length}개 정사 노드`);
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
@@ -115,28 +122,33 @@ window.storyViewApp = function() {
|
||||
}
|
||||
},
|
||||
|
||||
// 뷰 모드 토글
|
||||
toggleView() {
|
||||
this.viewMode = this.viewMode === 'toc' ? 'full' : 'toc';
|
||||
// URL 파라미터 확인 및 처리
|
||||
checkUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const treeId = urlParams.get('treeId');
|
||||
|
||||
if (treeId && this.userTrees.some(tree => tree.id === treeId)) {
|
||||
console.log('📖 URL에서 트리 ID 감지:', treeId);
|
||||
this.selectedTreeId = treeId;
|
||||
this.loadStory(treeId);
|
||||
}
|
||||
},
|
||||
|
||||
// 챕터로 스크롤
|
||||
scrollToChapter(nodeId) {
|
||||
if (this.viewMode === 'toc') {
|
||||
this.viewMode = 'full';
|
||||
// DOM 업데이트 대기 후 스크롤
|
||||
this.$nextTick(() => {
|
||||
const element = document.getElementById(`chapter-${nodeId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
// URL 업데이트
|
||||
updateUrl(treeId) {
|
||||
const url = new URL(window.location);
|
||||
if (treeId) {
|
||||
url.searchParams.set('treeId', treeId);
|
||||
} else {
|
||||
const element = document.getElementById(`chapter-${nodeId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
url.searchParams.delete('treeId');
|
||||
}
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 스토리 리더로 이동
|
||||
openStoryReader(nodeId, index) {
|
||||
const url = `story-reader.html?treeId=${this.selectedTreeId}&nodeId=${nodeId}&index=${index}`;
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
// 챕터 편집 (인라인 모달)
|
||||
|
||||
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 페이지 로드됨');
|
||||
});
|
||||
@@ -28,6 +28,9 @@ window.documentViewer = () => ({
|
||||
noteSearchQuery: '',
|
||||
filteredNotes: [],
|
||||
|
||||
// 언어 전환
|
||||
isKorean: false,
|
||||
|
||||
// 모달
|
||||
showNoteModal: false,
|
||||
showBookmarkModal: false,
|
||||
@@ -783,9 +786,31 @@ window.documentViewer = () => ({
|
||||
});
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
// 뒤로가기 - 문서 관리 페이지로 이동
|
||||
goBack() {
|
||||
window.history.back();
|
||||
// 1. URL 파라미터에서 from 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fromPage = urlParams.get('from');
|
||||
|
||||
// 2. 세션 스토리지에서 이전 페이지 확인
|
||||
const previousPage = sessionStorage.getItem('previousPage');
|
||||
|
||||
// 3. referrer 확인
|
||||
const referrer = document.referrer;
|
||||
|
||||
let targetPage = 'index.html'; // 기본값: 그리드 뷰
|
||||
|
||||
// 우선순위: URL 파라미터 > 세션 스토리지 > referrer
|
||||
if (fromPage === 'hierarchy') {
|
||||
targetPage = 'hierarchy.html';
|
||||
} else if (previousPage === 'hierarchy.html') {
|
||||
targetPage = 'hierarchy.html';
|
||||
} else if (referrer && referrer.includes('hierarchy.html')) {
|
||||
targetPage = 'hierarchy.html';
|
||||
}
|
||||
|
||||
console.log(`🔙 뒤로가기: ${targetPage}로 이동`);
|
||||
window.location.href = targetPage;
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
@@ -1025,5 +1050,79 @@ window.documentViewer = () => ({
|
||||
console.error('Failed to delete highlight:', error);
|
||||
alert('하이라이트 삭제에 실패했습니다');
|
||||
}
|
||||
},
|
||||
|
||||
// 언어 전환 함수
|
||||
toggleLanguage() {
|
||||
this.isKorean = !this.isKorean;
|
||||
|
||||
// 문서 내 언어별 요소 토글 (더 범용적으로)
|
||||
const primaryLangElements = document.querySelectorAll('[lang="ko"], .korean, .kr, .primary-lang');
|
||||
const secondaryLangElements = document.querySelectorAll('[lang="en"], .english, .en, [lang="ja"], .japanese, .jp, [lang="zh"], .chinese, .cn, .secondary-lang');
|
||||
|
||||
primaryLangElements.forEach(el => {
|
||||
el.style.display = this.isKorean ? 'block' : 'none';
|
||||
});
|
||||
|
||||
secondaryLangElements.forEach(el => {
|
||||
el.style.display = this.isKorean ? 'none' : 'block';
|
||||
});
|
||||
|
||||
console.log(`🌐 언어 전환됨 (Primary: ${this.isKorean ? '표시' : '숨김'})`);
|
||||
},
|
||||
|
||||
// 매칭된 PDF 다운로드
|
||||
async downloadMatchedPDF() {
|
||||
if (!this.document.matched_pdf_id) {
|
||||
console.warn('매칭된 PDF가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📕 PDF 다운로드 시작:', this.document.matched_pdf_id);
|
||||
|
||||
// PDF 문서 정보 가져오기
|
||||
const pdfDocument = await window.api.getDocument(this.document.matched_pdf_id);
|
||||
|
||||
if (!pdfDocument) {
|
||||
throw new Error('PDF 문서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// PDF 파일 다운로드 URL 생성
|
||||
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
|
||||
|
||||
// 인증 헤더 추가를 위해 fetch 사용
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('PDF 다운로드에 실패했습니다');
|
||||
}
|
||||
|
||||
// Blob으로 변환하여 다운로드
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// 다운로드 링크 생성 및 클릭
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// URL 정리
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ PDF 다운로드 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 다운로드 실패:', error);
|
||||
alert('PDF 다운로드에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
359
frontend/story-reader.html
Normal file
359
frontend/story-reader.html
Normal file
@@ -0,0 +1,359 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Monaco Editor -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs/loader.js"></script>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
<style>
|
||||
.story-content {
|
||||
line-height: 1.8;
|
||||
font-size: 16px;
|
||||
}
|
||||
.story-content p {
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.story-content h1, .story-content h2, .story-content h3 {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* 프린트 스타일 */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.story-content { font-size: 12pt; line-height: 1.6; }
|
||||
}
|
||||
|
||||
/* 네비게이션 버튼 스타일 */
|
||||
.nav-button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50" x-data="storyReaderApp()" x-init="init()">
|
||||
<!-- 공통 헤더 컨테이너 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen pt-4" x-show="currentUser">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<!-- 상단 네비게이션 바 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6 no-print">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 왼쪽: 돌아가기 버튼 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="goBackToStoryView()"
|
||||
class="nav-button flex items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
목차로 돌아가기
|
||||
</button>
|
||||
|
||||
<div class="text-sm text-gray-600" x-show="currentChapter">
|
||||
<span x-text="`${currentChapterIndex + 1}/${totalChapters}`"></span>
|
||||
<span class="mx-2">•</span>
|
||||
<span x-text="currentChapter?.title"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="editChapter()"
|
||||
class="nav-button px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i> 수정
|
||||
</button>
|
||||
<button
|
||||
@click="printChapter()"
|
||||
class="nav-button px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-print mr-1"></i> 인쇄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-600">스토리를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 에러 상태 -->
|
||||
<div x-show="error" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">오류가 발생했습니다</h3>
|
||||
<p class="text-gray-600 mb-4" x-text="error"></p>
|
||||
<button
|
||||
@click="goBackToStoryView()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
목차로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 스토리 컨텐츠 -->
|
||||
<div x-show="!loading && !error && currentChapter" class="space-y-6">
|
||||
|
||||
<!-- 챕터 헤더 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
<span x-text="`${currentChapterIndex + 1}장`"></span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4" x-text="currentChapter?.title"></h1>
|
||||
<div class="flex items-center justify-center space-x-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-tag mr-1"></i> <span x-text="getNodeTypeLabel(currentChapter?.node_type)"></span></span>
|
||||
<span x-show="currentChapter?.word_count > 0"><i class="fas fa-font mr-1"></i> <span x-text="`${currentChapter?.word_count}단어`"></span></span>
|
||||
<span><i class="fas fa-flag mr-1"></i> <span x-text="getStatusLabel(currentChapter?.status)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 챕터 내용 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="p-8">
|
||||
<div x-show="currentChapter?.content" class="story-content prose max-w-none" x-html="formatContent(currentChapter?.content)"></div>
|
||||
<div x-show="!currentChapter?.content" class="text-gray-400 italic text-center py-16">
|
||||
<i class="fas fa-file-alt text-4xl mb-4"></i>
|
||||
<p>이 챕터는 아직 내용이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단 네비게이션 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 no-print">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 이전 챕터 -->
|
||||
<div class="flex-1">
|
||||
<button
|
||||
x-show="hasPreviousChapter"
|
||||
@click="goToPreviousChapter()"
|
||||
class="nav-button flex items-center px-6 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all"
|
||||
>
|
||||
<i class="fas fa-chevron-left mr-2"></i>
|
||||
<div class="text-left">
|
||||
<div class="text-xs text-gray-500">이전 챕터</div>
|
||||
<div class="font-medium" x-text="previousChapter?.title"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 중앙: 목차로 돌아가기 -->
|
||||
<div class="flex-shrink-0 mx-4">
|
||||
<button
|
||||
@click="goBackToStoryView()"
|
||||
class="nav-button px-4 py-2 text-gray-600 hover:text-gray-800 text-sm"
|
||||
>
|
||||
<i class="fas fa-list mr-1"></i> 목차
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 다음 챕터 -->
|
||||
<div class="flex-1 flex justify-end">
|
||||
<button
|
||||
x-show="hasNextChapter"
|
||||
@click="goToNextChapter()"
|
||||
class="nav-button flex items-center px-6 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all"
|
||||
>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-500">다음 챕터</div>
|
||||
<div class="font-medium" x-text="nextChapter?.title"></div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right ml-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인이 필요한 경우 -->
|
||||
<div x-show="!currentUser" class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-lock text-6xl text-gray-300 mb-4"></i>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">로그인이 필요합니다</h2>
|
||||
<p class="text-gray-600 mb-6">스토리를 보려면 먼저 로그인해주세요</p>
|
||||
<button @click="openLoginModal()" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
로그인하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 편집 모달 -->
|
||||
<div x-show="showEditModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg w-full max-w-4xl mx-4 h-5/6 flex flex-col">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold">챕터 편집</h2>
|
||||
<button @click="cancelEdit()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6 overflow-hidden" x-show="editingChapter">
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<!-- 제목 편집 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="editingChapter.title"
|
||||
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>
|
||||
|
||||
<!-- 내용 편집 (Textarea 방식) -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">내용</label>
|
||||
<textarea
|
||||
x-model="editingChapter.content"
|
||||
class="flex-1 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm"
|
||||
placeholder="챕터 내용을 입력하세요..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="!editingChapter" class="flex-1 p-6 flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<p>편집 준비 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t bg-gray-50 flex justify-end space-x-3">
|
||||
<button
|
||||
@click="cancelEdit()"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
@click="saveEdit()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!saving"><i class="fas fa-save mr-2"></i> 저장</span>
|
||||
<span x-show="saving">저장 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 모달 -->
|
||||
<div 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="handleLogin()">
|
||||
<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-4">
|
||||
<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="loginLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!loginLoading">로그인</span>
|
||||
<span x-show="loginLoading">로그인 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 -->
|
||||
<script>
|
||||
// 순차적 스크립트 로딩
|
||||
async function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Alpine.js 초기화 이벤트 리스너
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js 초기화됨');
|
||||
});
|
||||
|
||||
// Monaco Editor 초기화
|
||||
function initMonaco() {
|
||||
return new Promise((resolve) => {
|
||||
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' } });
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
console.log('✅ Monaco Editor 로드 완료');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 스크립트 순차 로딩
|
||||
(async () => {
|
||||
try {
|
||||
// Monaco Editor 먼저 로드
|
||||
await initMonaco();
|
||||
|
||||
await loadScript('static/js/api.js?v=2025012380');
|
||||
console.log('✅ API 스크립트 로드 완료');
|
||||
await loadScript('static/js/story-reader.js?v=2025012369');
|
||||
console.log('✅ Story Reader 스크립트 로드 완료');
|
||||
|
||||
// 모든 스크립트 로드 완료 후 Alpine.js 로드
|
||||
console.log('🚀 Alpine.js 로딩...');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
|
||||
console.log('✅ Alpine.js 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 스크립트 로드 실패:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,9 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>스토리 뷰 - 정사 경로</title>
|
||||
<title>스토리 뷰</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
<style>
|
||||
/* 목차 스타일 */
|
||||
.toc-item {
|
||||
@@ -37,40 +40,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50" x-data="storyViewApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b no-print">
|
||||
<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 space-x-4">
|
||||
<a href="index.html" class="flex items-center space-x-2">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
<span class="text-xl font-bold text-gray-900">Document Server</span>
|
||||
</a>
|
||||
<span class="text-gray-400">|</span>
|
||||
<nav class="flex space-x-4">
|
||||
<a href="memo-tree.html" class="text-gray-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium">
|
||||
<i class="fas fa-sitemap mr-1"></i> 트리 에디터
|
||||
</a>
|
||||
<a href="story-view.html" class="bg-blue-100 text-blue-700 px-3 py-2 rounded-md text-sm font-medium">
|
||||
<i class="fas fa-book-open mr-1"></i> 스토리 뷰
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 사용자 정보 및 액션 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<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-gray-500 hover:text-gray-700">로그아웃</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 id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen pt-4" x-show="currentUser">
|
||||
@@ -101,13 +72,6 @@
|
||||
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="toggleView()"
|
||||
class="px-3 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
<span x-text="viewMode === 'toc' ? '전체보기' : '목차보기'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="exportStory()"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
@@ -128,13 +92,13 @@
|
||||
<div x-show="!selectedTree" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-book-open text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">스토리를 선택하세요</h3>
|
||||
<p class="text-gray-500">위에서 트리를 선택하면 정사 경로가 목차 형태로 표시됩니다</p>
|
||||
<p class="text-gray-500">위에서 트리를 선택하면 목차가 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 정사 노드 없음 상태 -->
|
||||
<div x-show="selectedTree && canonicalNodes.length === 0" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-route text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">정사 경로가 없습니다</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">스토리 노드가 없습니다</h3>
|
||||
<p class="text-gray-500 mb-4">트리 에디터에서 노드들을 정사로 설정해주세요</p>
|
||||
<a href="memo-tree.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
<i class="fas fa-sitemap mr-2"></i> 트리 에디터로 가기
|
||||
@@ -157,10 +121,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 목차 뷰 -->
|
||||
<div x-show="viewMode === 'toc'" class="bg-white rounded-lg shadow-sm">
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-list mr-2"></i> 목차 (정사 경로)
|
||||
<i class="fas fa-list mr-2"></i> 목차
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
@@ -168,7 +132,7 @@
|
||||
<template x-for="(node, index) in canonicalNodes" :key="node.id">
|
||||
<div
|
||||
class="toc-item flex items-center p-3 rounded-lg cursor-pointer"
|
||||
@click="scrollToChapter(node.id)"
|
||||
@click="openStoryReader(node.id, index)"
|
||||
>
|
||||
<span class="toc-number text-sm font-medium text-gray-500 mr-3" x-text="`${index + 1}.`"></span>
|
||||
<div class="flex-1">
|
||||
@@ -185,54 +149,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 스토리 뷰 -->
|
||||
<div x-show="viewMode === 'full'" class="bg-white rounded-lg shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-book-open mr-2"></i> 전체 스토리
|
||||
</h2>
|
||||
</div>
|
||||
<div class="story-content">
|
||||
<template x-for="(node, index) in canonicalNodes" :key="node.id">
|
||||
<div :id="`chapter-${node.id}`" class="chapter-section">
|
||||
<!-- 챕터 헤더 -->
|
||||
<div class="p-6 bg-gray-50 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span class="text-blue-600 mr-2" x-text="`${index + 1}.`"></span>
|
||||
<span x-text="node.title"></span>
|
||||
</h3>
|
||||
<div class="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
||||
<span x-text="getNodeTypeLabel(node.node_type)"></span>
|
||||
<span x-show="node.word_count > 0" x-text="`${node.word_count}단어`"></span>
|
||||
<span x-text="getStatusLabel(node.status)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="editChapter(node)"
|
||||
class="no-print px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i> 편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 챕터 내용 -->
|
||||
<div class="p-6">
|
||||
<div x-show="node.content" class="prose max-w-none" x-html="formatContent(node.content)"></div>
|
||||
<div x-show="!node.content" class="text-gray-400 italic text-center py-8">
|
||||
이 챕터는 아직 내용이 없습니다
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 챕터 구분선 -->
|
||||
<div x-show="index < canonicalNodes.length - 1" class="chapter-divider"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,9 +288,9 @@
|
||||
// 스크립트 순차 로딩
|
||||
(async () => {
|
||||
try {
|
||||
await loadScript('static/js/api.js?v=2025012290');
|
||||
await loadScript('static/js/api.js?v=2025012380');
|
||||
console.log('✅ API 스크립트 로드 완료');
|
||||
await loadScript('static/js/story-view.js?v=2025012295');
|
||||
await loadScript('static/js/story-view.js?v=2025012364');
|
||||
console.log('✅ Story View 스크립트 로드 완료');
|
||||
|
||||
// 모든 스크립트 로드 완료 후 Alpine.js 로드
|
||||
|
||||
340
frontend/upload.html
Normal file
340
frontend/upload.html
Normal file
@@ -0,0 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>문서 업로드 - Document Server</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
|
||||
<style>
|
||||
.drag-area {
|
||||
border: 2px dashed #cbd5e1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.drag-area.drag-over {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
.file-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.file-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.sortable-chosen {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="uploadApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>돌아가기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">문서 업로드</h1>
|
||||
<p class="text-gray-600">HTML 및 PDF 파일을 업로드하고 정리해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 단계 표시 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div :class="currentStep >= 1 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-gray-600'"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium">1</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="currentStep >= 1 ? 'text-blue-600' : 'text-gray-500'">파일 선택</span>
|
||||
</div>
|
||||
<div class="w-16 h-0.5" :class="currentStep >= 2 ? 'bg-blue-600' : 'bg-gray-300'"></div>
|
||||
<div class="flex items-center">
|
||||
<div :class="currentStep >= 2 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-gray-600'"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium">2</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="currentStep >= 2 ? 'text-blue-600' : 'text-gray-500'">서적 설정</span>
|
||||
</div>
|
||||
<div class="w-16 h-0.5" :class="currentStep >= 3 ? 'bg-blue-600' : 'bg-gray-300'"></div>
|
||||
<div class="flex items-center">
|
||||
<div :class="currentStep >= 3 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-gray-600'"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium">3</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="currentStep >= 3 ? 'text-blue-600' : 'text-gray-500'">순서 정리</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1단계: 파일 선택 -->
|
||||
<div x-show="currentStep === 1" class="space-y-6">
|
||||
<!-- 드래그 앤 드롭 영역 -->
|
||||
<div class="drag-area rounded-lg p-8 text-center"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-cloud-upload-alt text-6xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-2">파일을 드래그하여 업로드</h3>
|
||||
<p class="text-gray-500 mb-4">또는 클릭하여 파일을 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<input type="file"
|
||||
multiple
|
||||
accept=".html,.htm,.pdf"
|
||||
@change="handleFileSelect"
|
||||
class="hidden"
|
||||
x-ref="fileInput">
|
||||
|
||||
<button @click="$refs.fileInput.click()"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-folder-open mr-2"></i>파일 선택
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-gray-400 mt-4">HTML, PDF 파일만 업로드 가능합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 파일 목록 -->
|
||||
<div x-show="selectedFiles.length > 0" class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
선택된 파일 (<span x-text="selectedFiles.length"></span>개)
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 max-h-60 overflow-y-auto">
|
||||
<template x-for="(file, index) in selectedFiles" :key="index">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i :class="file.type.includes('pdf') ? 'fas fa-file-pdf text-red-500' : 'fas fa-file-code text-blue-500'"></i>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900" x-text="file.name"></p>
|
||||
<p class="text-sm text-gray-500" x-text="formatFileSize(file.size)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="removeFile(index)"
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button @click="nextStep()"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
다음 단계
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 서적 설정 -->
|
||||
<div x-show="currentStep === 2" class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">서적 설정</h3>
|
||||
|
||||
<!-- 서적 선택 방식 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">서적 설정 방식</label>
|
||||
<div class="flex space-x-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>
|
||||
|
||||
<!-- 기존 서적 선택 -->
|
||||
<div x-show="bookSelectionMode === 'existing'" class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">기존 서적 선택</label>
|
||||
<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="mt-2 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 border-gray-100 last:border-b-0">
|
||||
<p class="font-medium text-gray-900" x-text="book.title"></p>
|
||||
<p class="text-sm text-gray-500" x-text="book.author"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedBook" class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p class="font-medium text-blue-900">선택된 서적: <span x-text="selectedBook?.title"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 서적 생성 -->
|
||||
<div x-show="bookSelectionMode === 'new'" class="mb-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">서적 제목 *</label>
|
||||
<input type="text" x-model="newBook.title"
|
||||
placeholder="서적 제목을 입력하세요"
|
||||
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">
|
||||
💡 동일한 제목의 서적이 이미 존재하는 경우, 기존 서적에 추가할지 묻습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">저자</label>
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="newBook.description"
|
||||
placeholder="서적에 대한 설명을 입력하세요"
|
||||
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"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 버튼 -->
|
||||
<div class="flex justify-between">
|
||||
<button @click="prevStep()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>이전 단계
|
||||
</button>
|
||||
<button @click="uploadFiles()"
|
||||
:disabled="uploading"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50">
|
||||
<template x-if="!uploading">
|
||||
<span>업로드 시작 <i class="fas fa-upload ml-2"></i></span>
|
||||
</template>
|
||||
<template x-if="uploading">
|
||||
<span><i class="fas fa-spinner fa-spin mr-2"></i>업로드 중...</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3단계: 순서 정리 (업로드 완료 후) -->
|
||||
<div x-show="currentStep === 3" class="space-y-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">문서 순서 정리</h3>
|
||||
<p class="text-gray-600">드래그하거나 버튼으로 순서를 조정하고, PDF 파일을 HTML 문서와 매칭하세요</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="autoSortByName()"
|
||||
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors text-sm">
|
||||
<i class="fas fa-sort-alpha-down mr-1"></i>이름순 정렬
|
||||
</button>
|
||||
<button @click="shuffleDocuments()"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
|
||||
<i class="fas fa-random mr-1"></i>섞기
|
||||
</button>
|
||||
<button @click="reverseOrder()"
|
||||
class="px-3 py-1 bg-purple-100 text-purple-700 rounded-md hover:bg-purple-200 transition-colors text-sm">
|
||||
<i class="fas fa-sort mr-1"></i>순서 뒤집기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업로드된 파일 목록 (정렬 가능) -->
|
||||
<div id="sortable-list" class="space-y-4">
|
||||
<template x-for="(doc, index) in uploadedDocuments" :key="doc.id">
|
||||
<div class="file-item bg-gray-50 rounded-lg p-4 border border-gray-200" :data-id="doc.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 드래그 핸들 -->
|
||||
<div class="cursor-move text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</div>
|
||||
|
||||
<!-- 순서 번호 -->
|
||||
<div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
<span x-text="index + 1"></span>
|
||||
</div>
|
||||
|
||||
<!-- 파일 정보 -->
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-gray-900" x-text="doc.title"></h4>
|
||||
<p class="text-sm text-gray-500" x-text="doc.original_filename"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 순서 조정 버튼 -->
|
||||
<div class="flex flex-col space-y-1">
|
||||
<button @click="moveUp(index)"
|
||||
:disabled="index === 0"
|
||||
class="w-6 h-6 bg-gray-200 hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed rounded text-xs flex items-center justify-center transition-colors"
|
||||
title="위로 이동">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button @click="moveDown(index)"
|
||||
:disabled="index === uploadedDocuments.length - 1"
|
||||
class="w-6 h-6 bg-gray-200 hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed rounded text-xs flex items-center justify-center transition-colors"
|
||||
title="아래로 이동">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF 매칭 (HTML 파일인 경우만) -->
|
||||
<div x-show="doc.file_type === 'html'" class="flex items-center space-x-2">
|
||||
<label class="text-sm text-gray-700">PDF:</label>
|
||||
<select x-model="doc.matched_pdf_id"
|
||||
class="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">선택 안함</option>
|
||||
<template x-for="pdf in pdfFiles" :key="pdf.id">
|
||||
<option :value="pdf.id" x-text="pdf.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 완료 버튼 -->
|
||||
<div class="mt-8 flex justify-between">
|
||||
<button @click="resetUpload()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<i class="fas fa-redo mr-2"></i>다시 업로드
|
||||
</button>
|
||||
<button @click="finalizeUpload()"
|
||||
:disabled="finalizing"
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
<template x-if="!finalizing">
|
||||
<span>완료 <i class="fas fa-check ml-2"></i></span>
|
||||
</template>
|
||||
<template x-if="finalizing">
|
||||
<span><i class="fas fa-spinner fa-spin mr-2"></i>처리 중...</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/api.js?v=2025012380"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/header-loader.js?v=2025012351"></script>
|
||||
<script src="/static/js/upload.js?v=2025012384"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,66 +15,108 @@
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div x-data="documentViewer" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b sticky top-0 z-40">
|
||||
<header class="bg-white shadow-sm border-b sticky top-0 z-40 backdrop-blur-sm bg-white/95">
|
||||
<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 justify-between items-center h-12 border-b border-gray-100">
|
||||
<!-- 뒤로가기 및 문서 정보 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="goBack" class="text-gray-600 hover:text-gray-900">
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
<button @click="goBack" class="w-8 h-8 bg-gray-100 hover:bg-gray-200 rounded-lg flex items-center justify-center transition-all duration-200 text-gray-600 hover:text-gray-900">
|
||||
<i class="fas fa-arrow-left text-sm"></i>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900" x-text="document?.title || '로딩 중...'"></h1>
|
||||
<p class="text-sm text-gray-500" x-show="document" x-text="document?.uploader_name"></p>
|
||||
<h1 class="text-lg font-bold text-gray-900" x-text="document?.title || '로딩 중...'"></h1>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 업로더 정보 -->
|
||||
<div x-show="document">
|
||||
<p class="text-sm text-gray-500 font-medium" x-text="document?.uploader_name"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 도구 모음 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 두 번째 줄: 도구 모음 -->
|
||||
<div class="flex justify-between items-center h-14 py-2">
|
||||
<!-- 왼쪽: 도구들 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 하이라이트 색상 선택 -->
|
||||
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<div class="flex items-center space-x-1 bg-white rounded-xl shadow-sm border p-2">
|
||||
<span class="text-xs font-medium text-gray-600 mr-2">하이라이트</span>
|
||||
<button @click="createHighlightWithColor('#FFFF00')"
|
||||
:class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-blue-500' : ''"
|
||||
class="w-8 h-8 bg-yellow-300 rounded border-2 border-white"
|
||||
:class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-yellow-400 scale-110' : 'hover:scale-105'"
|
||||
class="w-6 h-6 bg-yellow-300 rounded-full border-2 border-white shadow-sm transition-all duration-200"
|
||||
title="노란색 하이라이트"></button>
|
||||
<button @click="createHighlightWithColor('#90EE90')"
|
||||
:class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-blue-500' : ''"
|
||||
class="w-8 h-8 bg-green-300 rounded border-2 border-white"
|
||||
:class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-green-400 scale-110' : 'hover:scale-105'"
|
||||
class="w-6 h-6 bg-green-300 rounded-full border-2 border-white shadow-sm transition-all duration-200"
|
||||
title="초록색 하이라이트"></button>
|
||||
<button @click="createHighlightWithColor('#FFB6C1')"
|
||||
:class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-blue-500' : ''"
|
||||
class="w-8 h-8 bg-pink-300 rounded border-2 border-white"
|
||||
:class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-pink-400 scale-110' : 'hover:scale-105'"
|
||||
class="w-6 h-6 bg-pink-300 rounded-full border-2 border-white shadow-sm transition-all duration-200"
|
||||
title="분홍색 하이라이트"></button>
|
||||
<button @click="createHighlightWithColor('#87CEEB')"
|
||||
:class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-500' : ''"
|
||||
class="w-8 h-8 bg-blue-300 rounded border-2 border-white"
|
||||
:class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-400 scale-110' : 'hover:scale-105'"
|
||||
class="w-6 h-6 bg-blue-300 rounded-full border-2 border-white shadow-sm transition-all duration-200"
|
||||
title="파란색 하이라이트"></button>
|
||||
</div>
|
||||
|
||||
<!-- 메모 패널 토글 -->
|
||||
<button @click="showNotesPanel = !showNotesPanel"
|
||||
:class="showNotesPanel ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-sticky-note mr-1"></i>
|
||||
메모 (<span x-text="notes.length"></span>)
|
||||
</button>
|
||||
<!-- 메모 & 책갈피 버튼 그룹 -->
|
||||
<div class="flex items-center bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<!-- 메모 버튼 -->
|
||||
<button @click="showNotesPanel = !showNotesPanel"
|
||||
:class="showNotesPanel ? 'bg-blue-50 text-blue-700 border-r border-blue-200' : 'text-gray-600 hover:bg-gray-50 border-r border-gray-200'"
|
||||
class="px-3 py-1.5 transition-all duration-200 flex flex-col items-center space-y-0.5 relative min-w-[50px]">
|
||||
<div class="relative">
|
||||
<i class="fas fa-sticky-note text-sm"></i>
|
||||
<span x-show="notes.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-3 h-3 flex items-center justify-center font-medium text-xs"
|
||||
x-text="notes.length"></span>
|
||||
</div>
|
||||
<span class="font-medium text-xs">메모</span>
|
||||
</button>
|
||||
|
||||
<!-- 책갈피 패널 토글 -->
|
||||
<button @click="showBookmarksPanel = !showBookmarksPanel"
|
||||
:class="showBookmarksPanel ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
|
||||
class="px-3 py-2 rounded-md hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-bookmark mr-1"></i>
|
||||
책갈피 (<span x-text="bookmarks.length"></span>)
|
||||
</button>
|
||||
<!-- 책갈피 버튼 -->
|
||||
<button @click="showBookmarksPanel = !showBookmarksPanel"
|
||||
:class="showBookmarksPanel ? 'bg-amber-50 text-amber-700' : 'text-gray-600 hover:bg-gray-50'"
|
||||
class="px-3 py-1.5 transition-all duration-200 flex flex-col items-center space-y-0.5 relative min-w-[50px]">
|
||||
<div class="relative">
|
||||
<i class="fas fa-bookmark text-sm"></i>
|
||||
<span x-show="bookmarks.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-amber-500 text-white text-xs rounded-full w-3 h-3 flex items-center justify-center font-medium text-xs"
|
||||
x-text="bookmarks.length"></span>
|
||||
</div>
|
||||
<span class="font-medium text-xs">책갈피</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<!-- 중앙: 검색 -->
|
||||
<div class="flex-1 flex justify-center">
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchQuery" @input="searchInDocument"
|
||||
placeholder="문서 내 검색..."
|
||||
class="w-64 pl-8 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<i class="fas fa-search absolute left-2 top-3 text-gray-400"></i>
|
||||
class="w-64 pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm transition-all duration-200">
|
||||
<i class="fas fa-search absolute left-3 top-2.5 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 한영 전환 -->
|
||||
<div class="flex items-center">
|
||||
<!-- PDF 다운로드 버튼 (매칭된 PDF가 있는 경우) -->
|
||||
<button x-show="document.matched_pdf_id"
|
||||
@click="downloadMatchedPDF()"
|
||||
class="bg-red-600 text-white px-4 py-2 rounded-xl hover:bg-red-700 transition-all duration-200 flex items-center space-x-2 shadow-sm"
|
||||
title="매칭된 PDF 다운로드">
|
||||
<i class="fas fa-file-pdf text-sm"></i>
|
||||
<span class="font-medium text-sm">PDF 원본</span>
|
||||
</button>
|
||||
|
||||
<button @click="toggleLanguage()"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-xl hover:bg-blue-700 transition-all duration-200 flex items-center space-x-2 shadow-sm"
|
||||
id="language-toggle-btn">
|
||||
<i class="fas fa-globe text-sm"></i>
|
||||
<span class="font-medium text-sm">언어전환</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -82,7 +124,8 @@
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<div class="flex max-w-7xl mx-auto">
|
||||
<!-- 문서 뷰어 -->
|
||||
<main class="flex-1 bg-white shadow-sm" :class="(showNotesPanel || showBookmarksPanel) ? 'mr-80' : ''">
|
||||
<main class="flex-1 bg-white shadow-sm" :class="(showNotesPanel || showBookmarksPanel) ? 'mr-70' : ''"
|
||||
:style="(showNotesPanel || showBookmarksPanel) ? 'margin-right: 280px;' : ''">
|
||||
<div class="p-8">
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="text-center py-16">
|
||||
@@ -112,24 +155,35 @@
|
||||
|
||||
<!-- 사이드 패널 -->
|
||||
<aside x-show="showNotesPanel || showBookmarksPanel"
|
||||
class="fixed right-0 top-16 w-80 h-screen bg-white shadow-lg border-l overflow-hidden flex flex-col">
|
||||
class="fixed right-0 bg-white shadow-lg border-l overflow-hidden flex flex-col"
|
||||
style="height: calc(100vh - 7rem); top: 7rem; width: 280px;">
|
||||
|
||||
<!-- 패널 탭 -->
|
||||
<div class="flex border-b">
|
||||
<div class="flex bg-gray-50 p-1 rounded-t-lg">
|
||||
<button @click="activePanel = 'notes'; showNotesPanel = true; showBookmarksPanel = false"
|
||||
:class="activePanel === 'notes' ? 'bg-blue-50 text-blue-600 border-b-2 border-blue-600' : 'text-gray-600'"
|
||||
class="flex-1 px-4 py-3 text-sm font-medium">
|
||||
<i class="fas fa-sticky-note mr-2"></i>
|
||||
메모 (<span x-text="notes.length"></span>)
|
||||
:class="activePanel === 'notes' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-600 hover:text-gray-800'"
|
||||
class="flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all duration-200 flex items-center justify-center space-x-2">
|
||||
<div class="relative">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span x-show="notes.length > 0"
|
||||
class="absolute -top-1.5 -right-1.5 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center font-medium"
|
||||
x-text="notes.length"></span>
|
||||
</div>
|
||||
<span>메모</span>
|
||||
</button>
|
||||
<button @click="activePanel = 'bookmarks'; showBookmarksPanel = true; showNotesPanel = false"
|
||||
:class="activePanel === 'bookmarks' ? 'bg-green-50 text-green-600 border-b-2 border-green-600' : 'text-gray-600'"
|
||||
class="flex-1 px-4 py-3 text-sm font-medium">
|
||||
<i class="fas fa-bookmark mr-2"></i>
|
||||
책갈피 (<span x-text="bookmarks.length"></span>)
|
||||
:class="activePanel === 'bookmarks' ? 'bg-white text-amber-600 shadow-sm' : 'text-gray-600 hover:text-gray-800'"
|
||||
class="flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all duration-200 flex items-center justify-center space-x-2">
|
||||
<div class="relative">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
<span x-show="bookmarks.length > 0"
|
||||
class="absolute -top-1.5 -right-1.5 bg-amber-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center font-medium"
|
||||
x-text="bookmarks.length"></span>
|
||||
</div>
|
||||
<span>책갈피</span>
|
||||
</button>
|
||||
<button @click="showNotesPanel = false; showBookmarksPanel = false"
|
||||
class="px-3 py-3 text-gray-400 hover:text-gray-600">
|
||||
class="px-3 py-2.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-all duration-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -144,38 +198,47 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<template x-if="filteredNotes.length === 0">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<i class="fas fa-sticky-note text-3xl mb-2"></i>
|
||||
<p>메모가 없습니다</p>
|
||||
<p class="text-sm">텍스트를 선택하고 메모를 추가해보세요</p>
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-sticky-note text-2xl text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-700 mb-2">메모가 없습니다</h3>
|
||||
<p class="text-sm text-gray-500">텍스트를 선택하고 메모를 추가해보세요</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 p-2">
|
||||
<template x-for="note in filteredNotes" :key="note.id">
|
||||
<div class="bg-gray-50 rounded-lg p-3 cursor-pointer hover:bg-gray-100"
|
||||
<div class="bg-white rounded-lg p-3 cursor-pointer hover:shadow-md border border-gray-100 transition-all duration-200 group"
|
||||
@click="scrollToHighlight(note.highlight.id)">
|
||||
<!-- 선택된 텍스트 -->
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-600 line-clamp-2" x-text="note.highlight.selected_text"></p>
|
||||
<div class="flex-1 bg-blue-50 rounded-md p-1.5 mr-2">
|
||||
<p class="text-xs text-blue-800 line-clamp-2 font-medium" x-text="note.highlight.selected_text"></p>
|
||||
</div>
|
||||
<div class="flex space-x-1 ml-2">
|
||||
<button @click.stop="editNote(note)" class="text-blue-500 hover:text-blue-700">
|
||||
<div class="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<button @click.stop="editNote(note)"
|
||||
class="w-6 h-6 bg-blue-100 text-blue-600 rounded-md hover:bg-blue-200 flex items-center justify-center transition-colors duration-200">
|
||||
<i class="fas fa-edit text-xs"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteNote(note.id)" class="text-red-500 hover:text-red-700">
|
||||
<button @click.stop="deleteNote(note.id)"
|
||||
class="w-6 h-6 bg-red-100 text-red-600 rounded-md hover:bg-red-200 flex items-center justify-center transition-colors duration-200">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-800" x-text="note.content"></p>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
<p class="text-sm text-gray-800 mb-2 leading-relaxed" x-text="note.content"></p>
|
||||
|
||||
<!-- 태그와 날짜 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="tag in note.tags || []" :key="tag">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full font-medium" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(note.created_at)"></span>
|
||||
<span class="text-xs text-gray-500 font-medium" x-text="formatDate(note.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -186,7 +249,7 @@
|
||||
<!-- 책갈피 패널 -->
|
||||
<div x-show="activePanel === 'bookmarks'" class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="p-4 border-b">
|
||||
<button @click="addBookmark" class="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700">
|
||||
<button @click="addBookmark" class="w-full bg-gradient-to-r from-amber-500 to-orange-500 text-white py-3 px-4 rounded-xl hover:from-amber-600 hover:to-orange-600 transition-all duration-200 shadow-sm font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
현재 위치에 책갈피 추가
|
||||
</button>
|
||||
@@ -194,29 +257,43 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<template x-if="bookmarks.length === 0">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<i class="fas fa-bookmark text-3xl mb-2"></i>
|
||||
<p>책갈피가 없습니다</p>
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<div class="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-bookmark text-2xl text-amber-500"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-700 mb-2">책갈피가 없습니다</h3>
|
||||
<p class="text-sm text-gray-500">위의 버튼을 눌러 책갈피를 추가해보세요</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 p-2">
|
||||
<template x-for="bookmark in bookmarks" :key="bookmark.id">
|
||||
<div class="bg-gray-50 rounded-lg p-3 cursor-pointer hover:bg-gray-100"
|
||||
<div class="bg-white rounded-lg p-3 cursor-pointer hover:shadow-md border border-gray-100 transition-all duration-200 group"
|
||||
@click="scrollToBookmark(bookmark)">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-medium text-gray-900" x-text="bookmark.title"></h4>
|
||||
<div class="flex space-x-1">
|
||||
<button @click.stop="editBookmark(bookmark)" class="text-blue-500 hover:text-blue-700">
|
||||
<div class="flex items-start space-x-2 flex-1">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-md flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-bookmark text-white text-xs"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-gray-900 text-sm truncate" x-text="bookmark.title"></h4>
|
||||
<p class="text-xs text-gray-600 line-clamp-2" x-text="bookmark.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-2">
|
||||
<button @click.stop="editBookmark(bookmark)"
|
||||
class="w-6 h-6 bg-blue-100 text-blue-600 rounded-md hover:bg-blue-200 flex items-center justify-center transition-colors duration-200">
|
||||
<i class="fas fa-edit text-xs"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteBookmark(bookmark.id)" class="text-red-500 hover:text-red-700">
|
||||
<button @click.stop="deleteBookmark(bookmark.id)"
|
||||
class="w-6 h-6 bg-red-100 text-red-600 rounded-md hover:bg-red-200 flex items-center justify-center transition-colors duration-200">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600" x-text="bookmark.description"></p>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(bookmark.created_at)"></span>
|
||||
<div class="flex justify-end">
|
||||
<span class="text-xs text-gray-500 font-medium" x-text="formatDate(bookmark.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -313,12 +390,20 @@
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/api.js?v=2025012225"></script>
|
||||
<script src="/static/js/api.js?v=2025012380"></script>
|
||||
<script src="/static/js/viewer.js?v=2025012225"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 기존 언어 전환 버튼 숨기기 */
|
||||
.language-toggle,
|
||||
button[onclick*="toggleLanguage"],
|
||||
*[class*="language"],
|
||||
*[class*="translate"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
Reference in New Issue
Block a user