🐛 Fix Alpine.js SyntaxError and backlink visibility issues
- Fix SyntaxError in viewer.js line 2868 (.bind(this) issue in setTimeout) - Resolve Alpine.js 'Can't find variable' errors (documentViewer, goBack, etc.) - Fix backlink rendering and persistence during temporary highlights - Add backlink protection and restoration mechanism in highlightAndScrollToText - Implement Note Management System with hierarchical notebooks - Add note highlights and memos functionality - Update cache version to force browser refresh (v=2025012641) - Add comprehensive logging for debugging backlink issues
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 - 2가지 기능만 -->
|
||||
<nav class="flex space-x-8">
|
||||
<!-- 메인 네비게이션 - 3가지 기능 -->
|
||||
<nav class="flex space-x-6">
|
||||
<!-- 문서 관리 시스템 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="index.html" class="nav-link" id="doc-nav-link">
|
||||
@@ -27,11 +27,11 @@
|
||||
</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>
|
||||
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
|
||||
<i class="fas fa-feather-alt"></i>
|
||||
<span>소설 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
@@ -43,6 +43,26 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 관리 시스템 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="notes.html" class="nav-link" id="notes-nav-link">
|
||||
<i class="fas fa-sticky-note"></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="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
|
||||
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
|
||||
</a>
|
||||
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
|
||||
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
|
||||
</a>
|
||||
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
|
||||
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
@@ -114,12 +134,17 @@
|
||||
|
||||
isDocumentPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['index', 'hierarchy'].includes(page);
|
||||
return ['index', 'hierarchy', 'pdf-manager'].includes(page);
|
||||
},
|
||||
|
||||
isMemoPage() {
|
||||
isNovelPage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['memo-tree', 'story-view'].includes(page);
|
||||
return ['memo-tree', 'story-view', 'story-reader'].includes(page);
|
||||
},
|
||||
|
||||
isNotePage() {
|
||||
const page = this.getCurrentPage();
|
||||
return ['notes', 'note-editor'].includes(page);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,7 +154,8 @@
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isMemoPage: () => headerUtils.isMemoPage(),
|
||||
isNovelPage: () => headerUtils.isNovelPage(),
|
||||
isNotePage: () => headerUtils.isNotePage(),
|
||||
});
|
||||
} else {
|
||||
// Alpine 로드 대기
|
||||
@@ -137,7 +163,8 @@
|
||||
Alpine.store('header', {
|
||||
getCurrentPage: () => headerUtils.getCurrentPage(),
|
||||
isDocumentPage: () => headerUtils.isDocumentPage(),
|
||||
isMemoPage: () => headerUtils.isMemoPage(),
|
||||
isNovelPage: () => headerUtils.isNovelPage(),
|
||||
isNotePage: () => headerUtils.isNotePage(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
class="px-3 py-2 rounded-md">
|
||||
<i class="fas fa-list mr-2"></i>목차
|
||||
</button>
|
||||
|
||||
<button onclick="window.location.href='/notes.html'"
|
||||
class="px-3 py-2 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-sticky-note mr-2"></i>노트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -487,6 +492,6 @@
|
||||
<!-- JavaScript 파일들 -->
|
||||
<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>
|
||||
<script src="/static/js/main.js?v=2025012462"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
202
frontend/note-editor.html
Normal file
202
frontend/note-editor.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!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">
|
||||
|
||||
<!-- Quill.js (WYSIWYG HTML 에디터) -->
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
|
||||
<style>
|
||||
.ql-editor {
|
||||
min-height: 400px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ql-toolbar {
|
||||
border-top: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
.ql-container {
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="noteEditorApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-edit text-blue-600 mr-3"></i>
|
||||
<span x-text="isEditing ? '노트 편집' : '새 노트 작성'"></span>
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">HTML 에디터로 풍부한 노트를 작성하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button @click="goBack()"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
<span>돌아가기</span>
|
||||
</button>
|
||||
|
||||
<button @click="saveNote()"
|
||||
:disabled="saving || !noteData.title"
|
||||
:class="saving || !noteData.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="flex items-center px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-save mr-2" :class="{'fa-spin': saving}"></i>
|
||||
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 설정 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- 제목 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
|
||||
<input type="text"
|
||||
x-model="noteData.title"
|
||||
placeholder="노트 제목을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 선택 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
|
||||
<select x-model="noteData.notebook_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">미분류</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<!-- 노트 타입 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||
<select x-model="noteData.note_type"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="note">일반 노트</option>
|
||||
<option value="research">연구 노트</option>
|
||||
<option value="summary">요약</option>
|
||||
<option value="idea">아이디어</option>
|
||||
<option value="guide">가이드</option>
|
||||
<option value="reference">참고 자료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<!-- 태그 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
|
||||
<input type="text"
|
||||
x-model="tagInput"
|
||||
@keydown.enter.prevent="addTag()"
|
||||
placeholder="태그를 입력하고 Enter를 누르세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
|
||||
<!-- 태그 목록 -->
|
||||
<div x-show="noteData.tags.length > 0" class="mt-3 flex flex-wrap gap-2">
|
||||
<template x-for="(tag, index) in noteData.tags" :key="index">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
|
||||
<span x-text="tag"></span>
|
||||
<button @click="removeTag(index)" class="ml-2 text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공개 설정 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">공개 설정</label>
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio"
|
||||
x-model="noteData.is_published"
|
||||
:value="false"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">초안</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio"
|
||||
x-model="noteData.is_published"
|
||||
:value="true"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">공개</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML 에디터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">노트 내용</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="toggleEditorMode()"
|
||||
class="text-sm text-gray-600 hover:text-gray-800">
|
||||
<i class="fas fa-code mr-1"></i>
|
||||
<span x-text="editorMode === 'wysiwyg' ? 'HTML 코드' : 'WYSIWYG'"></span>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500" x-text="getWordCount() + '자'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WYSIWYG 에디터 -->
|
||||
<div x-show="editorMode === 'wysiwyg'" id="quill-editor"></div>
|
||||
|
||||
<!-- HTML 코드 에디터 -->
|
||||
<div x-show="editorMode === 'html'" class="p-4">
|
||||
<textarea x-model="noteData.content"
|
||||
rows="20"
|
||||
placeholder="HTML 코드를 직접 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
style="resize: vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 -->
|
||||
<div x-show="noteData.content" class="mt-6 bg-white rounded-lg shadow-sm border">
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">미리보기</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div x-html="noteData.content" class="prose max-w-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/note-editor.js?v=2025012608"></script>
|
||||
</body>
|
||||
</html>
|
||||
330
frontend/notebooks.html
Normal file
330
frontend/notebooks.html
Normal file
@@ -0,0 +1,330 @@
|
||||
<!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>
|
||||
.notebook-card {
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid var(--notebook-color);
|
||||
}
|
||||
.notebook-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
.notebook-icon {
|
||||
color: var(--notebook-color);
|
||||
}
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="notebooksApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-book text-blue-600 mr-3"></i>
|
||||
노트북 관리
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">노트들을 체계적으로 분류하고 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="window.location.href='/notes.html'"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sticky-note mr-2"></i>
|
||||
<span>노트 관리</span>
|
||||
</button>
|
||||
|
||||
<button @click="refreshNotebooks()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
|
||||
<button @click="showCreateModal = true"
|
||||
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>새 노트북</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div x-show="stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-blue-100">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">전체 노트북</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notebooks || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-green-100">
|
||||
<i class="fas fa-check-circle text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">활성 노트북</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.active_notebooks || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-purple-100">
|
||||
<i class="fas fa-sticky-note text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">전체 노트</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-orange-100">
|
||||
<i class="fas fa-folder-open text-orange-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">미분류 노트</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="stats?.notes_without_notebook || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="debounceSearch()"
|
||||
placeholder="노트북 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
x-model="activeOnly"
|
||||
@change="loadNotebooks()"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">활성 노트북만</span>
|
||||
</label>
|
||||
|
||||
<select x-model="sortBy"
|
||||
@change="loadNotebooks()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="updated_at">최근 수정순</option>
|
||||
<option value="created_at">생성일순</option>
|
||||
<option value="title">제목순</option>
|
||||
<option value="sort_order">정렬순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<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-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 그리드 -->
|
||||
<div x-show="!loading && notebooks.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template x-for="notebook in notebooks" :key="notebook.id">
|
||||
<div class="notebook-card bg-white rounded-lg shadow-sm border p-6 cursor-pointer"
|
||||
:style="`--notebook-color: ${notebook.color}`"
|
||||
@click="openNotebook(notebook)">
|
||||
|
||||
<!-- 노트북 헤더 -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<i :class="`fas fa-${notebook.icon} notebook-icon text-2xl mr-3`"></i>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="notebook.title"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="`${notebook.note_count}개 노트`"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click.stop="editNotebook(notebook)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteNotebook(notebook)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 설명 -->
|
||||
<div x-show="notebook.description" class="mb-4">
|
||||
<p class="text-gray-600 text-sm line-clamp-2" x-text="notebook.description"></p>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 메타데이터 -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span x-text="formatDate(notebook.updated_at)"></span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span x-show="!notebook.is_active" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
|
||||
비활성
|
||||
</span>
|
||||
<span class="px-2 py-1 rounded-full text-white"
|
||||
:style="`background-color: ${notebook.color}`"
|
||||
x-text="notebook.created_by">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && notebooks.length === 0" class="text-center py-16">
|
||||
<i class="fas fa-book text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">노트북이 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">첫 번째 노트북을 만들어 노트들을 정리해보세요</p>
|
||||
<button @click="showCreateModal = true"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
새 노트북 만들기
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 노트북 생성/편집 모달 -->
|
||||
<div x-show="showCreateModal || showEditModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900"
|
||||
x-text="showEditModal ? '노트북 편집' : '새 노트북 만들기'"></h3>
|
||||
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveNotebook()">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
|
||||
<input type="text"
|
||||
x-model="notebookForm.title"
|
||||
placeholder="노트북 제목을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="notebookForm.description"
|
||||
rows="3"
|
||||
placeholder="노트북에 대한 설명을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 색상과 아이콘 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="color in availableColors" :key="color">
|
||||
<button type="button"
|
||||
@click="notebookForm.color = color"
|
||||
:class="notebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
|
||||
:style="`background-color: ${color}`">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
|
||||
<select x-model="notebookForm.icon"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<template x-for="icon in availableIcons" :key="icon.value">
|
||||
<option :value="icon.value" x-text="icon.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
@click="closeModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
:disabled="saving || !notebookForm.title"
|
||||
:class="saving || !notebookForm.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<span x-show="!saving" x-text="showEditModal ? '수정' : '생성'"></span>
|
||||
<span x-show="saving">저장 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/notebooks.js?v=2025012609"></script>
|
||||
</body>
|
||||
</html>
|
||||
423
frontend/notes.html
Normal file
423
frontend/notes.html
Normal file
@@ -0,0 +1,423 @@
|
||||
<!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="notesApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-sticky-note text-blue-600 mr-3"></i>
|
||||
노트 관리
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">마크다운으로 노트를 작성하고 서적과 연결하여 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="window.location.href='/'"
|
||||
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
<span>돌아가기</span>
|
||||
</button>
|
||||
|
||||
<button @click="refreshNotes()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
|
||||
<button onclick="window.location.href='/note-editor.html'"
|
||||
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>새 노트 작성</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" x-show="stats">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-sticky-note text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">전체 노트</h3>
|
||||
<p class="text-2xl font-bold text-blue-600" x-text="stats?.total_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-eye text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">공개 노트</h3>
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats?.published_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-edit text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">초안</h3>
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats?.draft_notes || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-clock text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">읽기 시간</h3>
|
||||
<p class="text-2xl font-bold text-purple-600" x-text="(stats?.total_reading_time || 0) + '분'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 작업 도구 -->
|
||||
<div x-show="selectedNotes.length > 0"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-medium text-blue-900">
|
||||
<span x-text="selectedNotes.length"></span>개 노트 선택됨
|
||||
</span>
|
||||
<button @click="clearSelection()"
|
||||
class="text-sm text-blue-600 hover:text-blue-800">
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 노트북 할당 -->
|
||||
<select x-model="bulkNotebookId"
|
||||
class="px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<option value="">노트북 선택...</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<button @click="assignToNotebook()"
|
||||
:disabled="!bulkNotebookId"
|
||||
:class="!bulkNotebookId ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors text-sm">
|
||||
노트북에 할당
|
||||
</button>
|
||||
|
||||
<button @click="showCreateNotebookModal = true"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm">
|
||||
새 노트북 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<!-- 검색 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="debounceSearch()"
|
||||
placeholder="제목이나 내용으로 검색..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트북 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
|
||||
<select x-model="selectedNotebook" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">전체</option>
|
||||
<option value="unassigned">미분류</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 노트 타입 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||
<select x-model="selectedType" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">전체</option>
|
||||
<option value="note">일반 노트</option>
|
||||
<option value="research">연구 노트</option>
|
||||
<option value="summary">요약</option>
|
||||
<option value="idea">아이디어</option>
|
||||
<option value="guide">가이드</option>
|
||||
<option value="reference">참고 자료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 공개 상태 필터 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
|
||||
<select x-model="publishedOnly" @change="loadNotes()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option :value="false">전체</option>
|
||||
<option :value="true">공개만</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border">
|
||||
<!-- 로딩 상태 -->
|
||||
<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 && notes.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
<template x-for="note in notes" :key="note.id">
|
||||
<div class="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer relative"
|
||||
:class="selectedNotes.includes(note.id) ? 'ring-2 ring-blue-500 bg-blue-50' : ''"
|
||||
@click="viewNote(note.id)">
|
||||
|
||||
<!-- 선택 체크박스 -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<input type="checkbox"
|
||||
:checked="selectedNotes.includes(note.id)"
|
||||
@click.stop="toggleNoteSelection(note.id)"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 노트 헤더 -->
|
||||
<div class="flex items-start justify-between mb-4 ml-8">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2 mb-2" x-text="note.title"></h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded-full text-xs" x-text="getNoteTypeLabel(note.note_type)"></span>
|
||||
<span x-show="note.is_published" class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">공개</span>
|
||||
<span x-show="!note.is_published" class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs">초안</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click.stop="editNote(note.id)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="편집">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click.stop="deleteNote(note)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 태그 -->
|
||||
<div x-show="note.tags && note.tags.length > 0" class="mb-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="tag in note.tags.slice(0, 3)" :key="tag">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
|
||||
</template>
|
||||
<span x-show="note.tags.length > 3" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
+<span x-text="note.tags.length - 3"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 정보 -->
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-font mr-1"></i>
|
||||
<span x-text="note.word_count"></span>자
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span x-text="note.reading_time"></span>분
|
||||
</span>
|
||||
<span x-show="note.child_count > 0" class="flex items-center">
|
||||
<i class="fas fa-sitemap mr-1"></i>
|
||||
<span x-text="note.child_count"></span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성 정보 -->
|
||||
<div class="text-xs text-gray-400 border-t pt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span x-text="note.created_by"></span>
|
||||
<span x-text="formatDate(note.updated_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="!loading && notes.length === 0" class="p-8 text-center">
|
||||
<i class="fas fa-sticky-note 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 mb-6">첫 번째 노트를 작성해보세요</p>
|
||||
<button @click="createNewNote()"
|
||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
새 노트 작성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 노트북 생성 모달 -->
|
||||
<div x-show="showCreateNotebookModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900">새 노트북 만들기</h3>
|
||||
<button @click="closeCreateNotebookModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createNotebookAndAssign()">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">노트북 이름 *</label>
|
||||
<input type="text"
|
||||
x-model="newNotebookForm.name"
|
||||
placeholder="노트북 이름을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea x-model="newNotebookForm.description"
|
||||
rows="3"
|
||||
placeholder="노트북에 대한 설명을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 색상과 아이콘 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="color in availableColors" :key="color">
|
||||
<button type="button"
|
||||
@click="newNotebookForm.color = color"
|
||||
:class="newNotebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
|
||||
:style="`background-color: ${color}`">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
|
||||
<select x-model="newNotebookForm.icon"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<template x-for="icon in availableIcons" :key="icon.value">
|
||||
<option :value="icon.value" x-text="icon.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 노트 정보 -->
|
||||
<div x-show="selectedNotes.length > 0" class="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-800">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
선택된 <span x-text="selectedNotes.length"></span>개의 노트가 이 노트북에 할당됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
@click="closeCreateNotebookModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
:disabled="creatingNotebook || !newNotebookForm.name"
|
||||
:class="creatingNotebook || !newNotebookForm.name ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'"
|
||||
class="px-4 py-2 text-white rounded-lg transition-colors">
|
||||
<span x-show="!creatingNotebook">생성 및 할당</span>
|
||||
<span x-show="creatingNotebook">생성 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/notes.js?v=2025012610"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,12 +3,12 @@
|
||||
*/
|
||||
class DocumentServerAPI {
|
||||
constructor() {
|
||||
// 도커 백엔드 API (24102 포트)
|
||||
this.baseURL = 'http://localhost:24102/api';
|
||||
// nginx를 통한 프록시 API (24100 포트)
|
||||
this.baseURL = 'http://localhost:24100/api';
|
||||
this.token = localStorage.getItem('access_token');
|
||||
|
||||
console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL);
|
||||
console.log('🔧 도커 환경 설정 완료 - 버전 2025012415');
|
||||
console.log('🌐 API Base URL (NGINX PROXY):', this.baseURL);
|
||||
console.log('🔧 nginx 프록시 환경 설정 완료 - 버전 2025012607');
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
@@ -221,7 +221,8 @@ class DocumentServerAPI {
|
||||
return await this.delete(`/highlights/${highlightId}`);
|
||||
}
|
||||
|
||||
// 메모 관련 API
|
||||
// === 하이라이트 메모 (Highlight Memo) 관련 API ===
|
||||
// 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
async createNote(noteData) {
|
||||
return await this.post('/notes/', noteData);
|
||||
}
|
||||
@@ -230,6 +231,8 @@ class DocumentServerAPI {
|
||||
return await this.get('/notes/', params);
|
||||
}
|
||||
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
@@ -319,6 +322,8 @@ class DocumentServerAPI {
|
||||
}
|
||||
|
||||
// === 메모 관련 API ===
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
@@ -441,6 +446,8 @@ class DocumentServerAPI {
|
||||
}
|
||||
|
||||
// === 메모 관련 API ===
|
||||
// === 문서 메모 조회 ===
|
||||
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
|
||||
async getDocumentNotes(documentId) {
|
||||
return await this.get(`/notes/document/${documentId}`);
|
||||
}
|
||||
@@ -553,6 +560,96 @@ class DocumentServerAPI {
|
||||
async getDocumentLinkFragments(documentId) {
|
||||
return await this.get(`/documents/${documentId}/link-fragments`);
|
||||
}
|
||||
|
||||
// ===== 노트 문서 관련 API =====
|
||||
|
||||
// 모든 노트 조회
|
||||
async getNoteDocuments(params = {}) {
|
||||
return await this.get('/note-documents/', params);
|
||||
}
|
||||
|
||||
// 특정 노트 조회
|
||||
async getNoteDocument(noteId) {
|
||||
return await this.get(`/note-documents/${noteId}`);
|
||||
}
|
||||
|
||||
// === 노트 문서 (Note Document) 관련 API ===
|
||||
// 용어 정의: 독립적인 문서 작성 (HTML 기반)
|
||||
async createNoteDocument(noteData) {
|
||||
return await this.post('/note-documents/', noteData);
|
||||
}
|
||||
|
||||
// 노트 업데이트
|
||||
async updateNoteDocument(noteId, noteData) {
|
||||
return await this.put(`/note-documents/${noteId}`, noteData);
|
||||
}
|
||||
|
||||
// 노트 삭제
|
||||
async deleteNoteDocument(noteId) {
|
||||
return await this.delete(`/note-documents/${noteId}`);
|
||||
}
|
||||
|
||||
// 노트 HTML 내보내기
|
||||
async exportNoteAsHTML(noteId) {
|
||||
const response = await fetch(`${this.baseURL}/note-documents/${noteId}/export/html`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ===== 노트북 관련 API =====
|
||||
|
||||
// 모든 노트북 조회
|
||||
async getNotebooks(params = {}) {
|
||||
return await this.get('/notebooks/', params);
|
||||
}
|
||||
|
||||
// 특정 노트북 조회
|
||||
async getNotebook(notebookId) {
|
||||
return await this.get(`/notebooks/${notebookId}`);
|
||||
}
|
||||
|
||||
// === 노트북 (Notebook) 관련 API ===
|
||||
// 용어 정의: 노트 문서들을 그룹화하는 폴더
|
||||
async createNotebook(notebookData) {
|
||||
return await this.post('/notebooks/', notebookData);
|
||||
}
|
||||
|
||||
// 노트북 업데이트
|
||||
async updateNotebook(notebookId, notebookData) {
|
||||
return await this.put(`/notebooks/${notebookId}`, notebookData);
|
||||
}
|
||||
|
||||
// 노트북 삭제
|
||||
async deleteNotebook(notebookId, force = false) {
|
||||
return await this.delete(`/notebooks/${notebookId}?force=${force}`);
|
||||
}
|
||||
|
||||
// 노트북 통계
|
||||
async getNotebookStats() {
|
||||
return await this.get('/notebooks/stats');
|
||||
}
|
||||
|
||||
// 노트북의 노트들 조회
|
||||
async getNotebookNotes(notebookId, params = {}) {
|
||||
return await this.get(`/notebooks/${notebookId}/notes`, params);
|
||||
}
|
||||
|
||||
// 노트를 노트북에 추가
|
||||
async addNoteToNotebook(notebookId, noteId) {
|
||||
return await this.post(`/notebooks/${notebookId}/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// 노트를 노트북에서 제거
|
||||
async removeNoteFromNotebook(notebookId, noteId) {
|
||||
return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 API 인스턴스
|
||||
|
||||
@@ -125,7 +125,18 @@ window.documentApp = () => ({
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
this.documents = await window.api.getDocuments();
|
||||
const allDocuments = await window.api.getDocuments();
|
||||
|
||||
// HTML 문서만 필터링 (PDF 파일 제외)
|
||||
this.documents = allDocuments.filter(doc =>
|
||||
doc.html_path &&
|
||||
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
|
||||
);
|
||||
|
||||
console.log('📄 전체 문서:', allDocuments.length, '개');
|
||||
console.log('📄 HTML 문서:', this.documents.length, '개');
|
||||
console.log('📄 PDF 파일:', allDocuments.length - this.documents.length, '개 (제외됨)');
|
||||
|
||||
this.updateAvailableTags();
|
||||
this.filterDocuments();
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
|
||||
333
frontend/static/js/note-editor.js
Normal file
333
frontend/static/js/note-editor.js
Normal file
@@ -0,0 +1,333 @@
|
||||
function noteEditorApp() {
|
||||
return {
|
||||
// 상태 관리
|
||||
noteData: {
|
||||
title: '',
|
||||
content: '',
|
||||
note_type: 'note',
|
||||
tags: [],
|
||||
is_published: false,
|
||||
parent_note_id: null,
|
||||
sort_order: 0,
|
||||
notebook_id: null
|
||||
},
|
||||
|
||||
// 노트북 관련
|
||||
availableNotebooks: [],
|
||||
|
||||
// UI 상태
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
isEditing: false,
|
||||
noteId: null,
|
||||
|
||||
// 에디터 관련
|
||||
quillEditor: null,
|
||||
editorMode: 'wysiwyg', // 'wysiwyg' 또는 'html'
|
||||
tagInput: '',
|
||||
|
||||
// 인증 관련
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
async init() {
|
||||
console.log('📝 노트 에디터 초기화 시작');
|
||||
|
||||
try {
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
console.log('🔧 API 클라이언트 초기화됨:', this.api);
|
||||
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (!this.isAuthenticated) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// URL에서 노트 ID 확인 (편집 모드)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.noteId = urlParams.get('id');
|
||||
|
||||
// 노트북 목록 로드
|
||||
await this.loadNotebooks();
|
||||
|
||||
if (this.noteId) {
|
||||
this.isEditing = true;
|
||||
await this.loadNote(this.noteId);
|
||||
}
|
||||
|
||||
// Quill 에디터 초기화
|
||||
this.initQuillEditor();
|
||||
|
||||
console.log('✅ 노트 에디터 초기화 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 에디터 초기화 실패:', error);
|
||||
this.error = '노트 에디터를 초기화하는 중 오류가 발생했습니다.';
|
||||
}
|
||||
},
|
||||
|
||||
async loadHeader() {
|
||||
try {
|
||||
if (typeof loadHeaderComponent === 'function') {
|
||||
await loadHeaderComponent();
|
||||
} else if (typeof window.loadHeaderComponent === 'function') {
|
||||
await window.loadHeaderComponent();
|
||||
} else {
|
||||
console.warn('헤더 로더 함수를 찾을 수 없습니다. 수동으로 헤더를 로드합니다.');
|
||||
// 수동으로 헤더 로드
|
||||
const headerContainer = document.getElementById('header-container');
|
||||
if (headerContainer) {
|
||||
const response = await fetch('/components/header.html');
|
||||
const headerHTML = await response.text();
|
||||
headerContainer.innerHTML = headerHTML;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const response = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = response;
|
||||
console.log('✅ 인증된 사용자:', this.currentUser.username);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않은 사용자');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
}
|
||||
},
|
||||
|
||||
async loadNotebooks() {
|
||||
try {
|
||||
console.log('📚 노트북 로드 시작...');
|
||||
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
|
||||
|
||||
// 임시: 직접 API 호출
|
||||
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
|
||||
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.availableNotebooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
initQuillEditor() {
|
||||
// Quill 에디터 설정
|
||||
const toolbarOptions = [
|
||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||
[{ 'font': [] }],
|
||||
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'direction': 'rtl' }],
|
||||
[{ 'align': [] }],
|
||||
['blockquote', 'code-block'],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
];
|
||||
|
||||
this.quillEditor = new Quill('#quill-editor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: toolbarOptions
|
||||
},
|
||||
placeholder: '노트 내용을 작성하세요...'
|
||||
});
|
||||
|
||||
// 에디터 내용 변경 시 동기화
|
||||
this.quillEditor.on('text-change', () => {
|
||||
if (this.editorMode === 'wysiwyg') {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
// 기존 내용이 있으면 로드
|
||||
if (this.noteData.content) {
|
||||
this.quillEditor.root.innerHTML = this.noteData.content;
|
||||
}
|
||||
},
|
||||
|
||||
async loadNote(noteId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
console.log('📖 노트 로드 중:', noteId);
|
||||
const note = await this.api.getNoteDocument(noteId);
|
||||
|
||||
this.noteData = {
|
||||
title: note.title || '',
|
||||
content: note.content || '',
|
||||
note_type: note.note_type || 'note',
|
||||
tags: note.tags || [],
|
||||
is_published: note.is_published || false,
|
||||
parent_note_id: note.parent_note_id || null,
|
||||
sort_order: note.sort_order || 0
|
||||
};
|
||||
|
||||
console.log('✅ 노트 로드 완료:', this.noteData.title);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 로드 실패:', error);
|
||||
this.error = '노트를 불러오는 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
if (!this.noteData.title.trim()) {
|
||||
this.showNotification('제목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// WYSIWYG 모드에서 HTML 동기화
|
||||
if (this.editorMode === 'wysiwyg' && this.quillEditor) {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
|
||||
console.log('💾 노트 저장 중:', this.noteData.title);
|
||||
|
||||
let result;
|
||||
if (this.isEditing && this.noteId) {
|
||||
// 기존 노트 업데이트
|
||||
result = await this.api.updateNoteDocument(this.noteId, this.noteData);
|
||||
console.log('✅ 노트 업데이트 완료');
|
||||
} else {
|
||||
// 새 노트 생성
|
||||
result = await this.api.createNoteDocument(this.noteData);
|
||||
console.log('✅ 새 노트 생성 완료');
|
||||
|
||||
// 편집 모드로 전환
|
||||
this.isEditing = true;
|
||||
this.noteId = result.id;
|
||||
|
||||
// URL 업데이트 (새로고침 없이)
|
||||
const newUrl = `${window.location.pathname}?id=${result.id}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
this.showNotification('노트가 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노트 저장 실패:', error);
|
||||
this.error = '노트 저장 중 오류가 발생했습니다.';
|
||||
this.showNotification('노트 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleEditorMode() {
|
||||
if (this.editorMode === 'wysiwyg') {
|
||||
// WYSIWYG → HTML 코드
|
||||
if (this.quillEditor) {
|
||||
this.noteData.content = this.quillEditor.root.innerHTML;
|
||||
}
|
||||
this.editorMode = 'html';
|
||||
} else {
|
||||
// HTML 코드 → WYSIWYG
|
||||
if (this.quillEditor) {
|
||||
this.quillEditor.root.innerHTML = this.noteData.content || '';
|
||||
}
|
||||
this.editorMode = 'wysiwyg';
|
||||
}
|
||||
},
|
||||
|
||||
addTag() {
|
||||
const tag = this.tagInput.trim();
|
||||
if (tag && !this.noteData.tags.includes(tag)) {
|
||||
this.noteData.tags.push(tag);
|
||||
this.tagInput = '';
|
||||
}
|
||||
},
|
||||
|
||||
removeTag(index) {
|
||||
this.noteData.tags.splice(index, 1);
|
||||
},
|
||||
|
||||
getWordCount() {
|
||||
if (!this.noteData.content) return 0;
|
||||
|
||||
// HTML 태그 제거 후 단어 수 계산
|
||||
const textContent = this.noteData.content.replace(/<[^>]*>/g, '');
|
||||
return textContent.length;
|
||||
},
|
||||
|
||||
goBack() {
|
||||
// 변경사항이 있으면 확인
|
||||
if (this.hasUnsavedChanges()) {
|
||||
if (!confirm('저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = '/notes.html';
|
||||
},
|
||||
|
||||
hasUnsavedChanges() {
|
||||
// 간단한 변경사항 감지 (실제로는 더 정교하게 구현 가능)
|
||||
return this.noteData.title.trim() !== '' || this.noteData.content.trim() !== '';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// 간단한 알림 (나중에 더 정교한 토스트 시스템으로 교체 가능)
|
||||
if (type === 'error') {
|
||||
alert('❌ ' + message);
|
||||
} else if (type === 'success') {
|
||||
alert('✅ ' + message);
|
||||
} else {
|
||||
alert('ℹ️ ' + message);
|
||||
}
|
||||
},
|
||||
|
||||
// 키보드 단축키
|
||||
handleKeydown(event) {
|
||||
// Ctrl+S (또는 Cmd+S): 저장
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
this.saveNote();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 키보드 이벤트 리스너 등록
|
||||
document.addEventListener('keydown', function(event) {
|
||||
// Alpine.js 컴포넌트에 접근
|
||||
const app = Alpine.$data(document.querySelector('[x-data]'));
|
||||
if (app && app.handleKeydown) {
|
||||
app.handleKeydown(event);
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 떠날 때 확인
|
||||
window.addEventListener('beforeunload', function(event) {
|
||||
const app = Alpine.$data(document.querySelector('[x-data]'));
|
||||
if (app && app.hasUnsavedChanges && app.hasUnsavedChanges()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '저장하지 않은 변경사항이 있습니다.';
|
||||
return event.returnValue;
|
||||
}
|
||||
});
|
||||
277
frontend/static/js/notebooks.js
Normal file
277
frontend/static/js/notebooks.js
Normal file
@@ -0,0 +1,277 @@
|
||||
// 노트북 관리 애플리케이션 컴포넌트
|
||||
window.notebooksApp = () => ({
|
||||
// 상태 관리
|
||||
notebooks: [],
|
||||
stats: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// 필터링
|
||||
searchQuery: '',
|
||||
activeOnly: true,
|
||||
sortBy: 'updated_at',
|
||||
|
||||
// 검색 디바운스
|
||||
searchTimeout: null,
|
||||
|
||||
// 모달 상태
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
editingNotebook: null,
|
||||
|
||||
// 노트북 폼
|
||||
notebookForm: {
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
},
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
// 색상 옵션
|
||||
availableColors: [
|
||||
'#3B82F6', // blue
|
||||
'#10B981', // emerald
|
||||
'#F59E0B', // amber
|
||||
'#EF4444', // red
|
||||
'#8B5CF6', // violet
|
||||
'#06B6D4', // cyan
|
||||
'#84CC16', // lime
|
||||
'#F97316', // orange
|
||||
'#EC4899', // pink
|
||||
'#6B7280' // gray
|
||||
],
|
||||
|
||||
// 아이콘 옵션
|
||||
availableIcons: [
|
||||
{ value: 'book', label: '📖 책' },
|
||||
{ value: 'sticky-note', label: '📝 노트' },
|
||||
{ value: 'lightbulb', label: '💡 아이디어' },
|
||||
{ value: 'graduation-cap', label: '🎓 학습' },
|
||||
{ value: 'briefcase', label: '💼 업무' },
|
||||
{ value: 'heart', label: '❤️ 개인' },
|
||||
{ value: 'code', label: '💻 개발' },
|
||||
{ value: 'palette', label: '🎨 창작' },
|
||||
{ value: 'flask', label: '🧪 연구' },
|
||||
{ value: 'star', label: '⭐ 즐겨찾기' }
|
||||
],
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('📚 Notebooks App 초기화 시작');
|
||||
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadStats();
|
||||
await this.loadNotebooks();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username || user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
if (typeof loadHeaderComponent === 'function') {
|
||||
await loadHeaderComponent();
|
||||
} else if (typeof window.loadHeaderComponent === 'function') {
|
||||
await window.loadHeaderComponent();
|
||||
} else {
|
||||
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 정보 로드
|
||||
async loadStats() {
|
||||
try {
|
||||
this.stats = await this.api.getNotebookStats();
|
||||
console.log('📊 노트북 통계 로드됨:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트북 목록 로드
|
||||
async loadNotebooks() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const queryParams = {
|
||||
active_only: this.activeOnly,
|
||||
sort_by: this.sortBy,
|
||||
order: 'desc'
|
||||
};
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.search = this.searchQuery;
|
||||
}
|
||||
|
||||
this.notebooks = await this.api.getNotebooks(queryParams);
|
||||
console.log('📚 노트북 로드됨:', this.notebooks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.error = '노트북을 불러오는 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 디바운스
|
||||
debounceSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.loadNotebooks();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 새로고침
|
||||
async refreshNotebooks() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadNotebooks()
|
||||
]);
|
||||
},
|
||||
|
||||
// 노트북 열기 (노트 목록으로 이동)
|
||||
openNotebook(notebook) {
|
||||
window.location.href = `/notes.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.name)}`;
|
||||
},
|
||||
|
||||
// 노트북 편집
|
||||
editNotebook(notebook) {
|
||||
this.editingNotebook = notebook;
|
||||
this.notebookForm = {
|
||||
title: notebook.title,
|
||||
description: notebook.description || '',
|
||||
color: notebook.color,
|
||||
icon: notebook.icon,
|
||||
is_active: notebook.is_active,
|
||||
sort_order: notebook.sort_order
|
||||
};
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
// 노트북 삭제
|
||||
async deleteNotebook(notebook) {
|
||||
if (!confirm(`"${notebook.title}" 노트북을 삭제하시겠습니까?\n\n${notebook.note_count > 0 ? `포함된 ${notebook.note_count}개의 노트는 미분류 상태가 됩니다.` : ''}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.deleteNotebook(notebook.id, true); // force=true
|
||||
this.showNotification('노트북이 삭제되었습니다.', 'success');
|
||||
await this.refreshNotebooks();
|
||||
} catch (error) {
|
||||
console.error('노트북 삭제 실패:', error);
|
||||
this.showNotification('노트북 삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 노트북 저장
|
||||
async saveNotebook() {
|
||||
if (!this.notebookForm.title.trim()) {
|
||||
this.showNotification('제목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
if (this.showEditModal && this.editingNotebook) {
|
||||
// 편집
|
||||
await this.api.updateNotebook(this.editingNotebook.id, this.notebookForm);
|
||||
this.showNotification('노트북이 수정되었습니다.', 'success');
|
||||
} else {
|
||||
// 생성
|
||||
await this.api.createNotebook(this.notebookForm);
|
||||
this.showNotification('노트북이 생성되었습니다.', 'success');
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
await this.refreshNotebooks();
|
||||
} catch (error) {
|
||||
console.error('노트북 저장 실패:', error);
|
||||
this.showNotification('노트북 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 모달 닫기
|
||||
closeModal() {
|
||||
this.showCreateModal = false;
|
||||
this.showEditModal = false;
|
||||
this.editingNotebook = null;
|
||||
this.notebookForm = {
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
};
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '오늘';
|
||||
} else if (diffDays === 2) {
|
||||
return '어제';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays - 1}일 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
if (type === 'error') {
|
||||
alert('❌ ' + message);
|
||||
} else if (type === 'success') {
|
||||
alert('✅ ' + message);
|
||||
} else {
|
||||
alert('ℹ️ ' + message);
|
||||
}
|
||||
}
|
||||
});
|
||||
404
frontend/static/js/notes.js
Normal file
404
frontend/static/js/notes.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// 노트 관리 애플리케이션 컴포넌트
|
||||
window.notesApp = () => ({
|
||||
// 상태 관리
|
||||
notes: [],
|
||||
stats: null,
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// 필터링
|
||||
searchQuery: '',
|
||||
selectedType: '',
|
||||
publishedOnly: false,
|
||||
selectedNotebook: '',
|
||||
|
||||
// 노트북 관련
|
||||
availableNotebooks: [],
|
||||
|
||||
// 일괄 선택 관련
|
||||
selectedNotes: [],
|
||||
bulkNotebookId: '',
|
||||
|
||||
// 노트북 생성 관련
|
||||
showCreateNotebookModal: false,
|
||||
creatingNotebook: false,
|
||||
newNotebookForm: {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book'
|
||||
},
|
||||
|
||||
// 색상 및 아이콘 옵션
|
||||
availableColors: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
'#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6B7280'
|
||||
],
|
||||
availableIcons: [
|
||||
{ value: 'book', label: '📖 책' },
|
||||
{ value: 'sticky-note', label: '📝 노트' },
|
||||
{ value: 'lightbulb', label: '💡 아이디어' },
|
||||
{ value: 'graduation-cap', label: '🎓 학습' },
|
||||
{ value: 'briefcase', label: '💼 업무' },
|
||||
{ value: 'heart', label: '❤️ 개인' },
|
||||
{ value: 'code', label: '💻 개발' },
|
||||
{ value: 'palette', label: '🎨 창작' },
|
||||
{ value: 'flask', label: '🧪 연구' },
|
||||
{ value: 'star', label: '⭐ 즐겨찾기' }
|
||||
],
|
||||
|
||||
// 검색 디바운스
|
||||
searchTimeout: null,
|
||||
|
||||
// 인증 상태
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// API 클라이언트
|
||||
api: null,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 Notes App 초기화 시작');
|
||||
|
||||
// API 클라이언트 초기화
|
||||
this.api = new DocumentServerAPI();
|
||||
console.log('🔧 API 클라이언트 초기화됨:', this.api);
|
||||
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
|
||||
|
||||
// URL 파라미터 확인 (노트북 필터)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const notebookId = urlParams.get('notebook_id');
|
||||
const notebookName = urlParams.get('notebook_name');
|
||||
|
||||
if (notebookId) {
|
||||
this.selectedNotebook = notebookId;
|
||||
console.log('🔍 노트북 필터 적용:', notebookName || notebookId);
|
||||
}
|
||||
|
||||
// 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
await this.loadNotebooks();
|
||||
await this.loadStats();
|
||||
await this.loadNotes();
|
||||
}
|
||||
|
||||
// 헤더 로드
|
||||
await this.loadHeader();
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await this.api.getCurrentUser();
|
||||
this.isAuthenticated = true;
|
||||
this.currentUser = user;
|
||||
console.log('✅ 인증됨:', user.username || user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음');
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
// 로그인 페이지로 리다이렉트하지 않고 메인 페이지로
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
// 헤더 로드
|
||||
async loadHeader() {
|
||||
try {
|
||||
await window.headerLoader.loadHeader();
|
||||
} catch (error) {
|
||||
console.error('헤더 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트북 목록 로드
|
||||
async loadNotebooks() {
|
||||
try {
|
||||
console.log('📚 노트북 로드 시작...');
|
||||
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
|
||||
|
||||
if (typeof this.api.getNotebooks !== 'function') {
|
||||
throw new Error('getNotebooks 메서드가 존재하지 않습니다');
|
||||
}
|
||||
|
||||
// 임시: 직접 API 호출
|
||||
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
|
||||
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('노트북 로드 실패:', error);
|
||||
this.availableNotebooks = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 통계 정보 로드
|
||||
async loadStats() {
|
||||
try {
|
||||
this.stats = await this.api.get('/note-documents/stats');
|
||||
console.log('📊 통계 로드됨:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 노트 목록 로드
|
||||
async loadNotes() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const queryParams = {};
|
||||
|
||||
if (this.searchQuery) {
|
||||
queryParams.search = this.searchQuery;
|
||||
}
|
||||
if (this.selectedType) {
|
||||
queryParams.note_type = this.selectedType;
|
||||
}
|
||||
if (this.publishedOnly) {
|
||||
queryParams.published_only = 'true';
|
||||
}
|
||||
if (this.selectedNotebook) {
|
||||
if (this.selectedNotebook === 'unassigned') {
|
||||
queryParams.notebook_id = 'null';
|
||||
} else {
|
||||
queryParams.notebook_id = this.selectedNotebook;
|
||||
}
|
||||
}
|
||||
|
||||
this.notes = await this.api.getNoteDocuments(queryParams);
|
||||
console.log('📝 노트 로드됨:', this.notes.length, '개');
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트 로드 실패:', error);
|
||||
this.error = '노트를 불러오는데 실패했습니다: ' + error.message;
|
||||
this.notes = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 검색 디바운스
|
||||
debounceSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.loadNotes();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// 노트 새로고침
|
||||
async refreshNotes() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadNotes()
|
||||
]);
|
||||
},
|
||||
|
||||
// 새 노트 생성
|
||||
createNewNote() {
|
||||
window.location.href = '/note-editor.html';
|
||||
},
|
||||
|
||||
// 노트 보기 (뷰어 페이지로 이동)
|
||||
viewNote(noteId) {
|
||||
window.location.href = `/viewer.html?type=note&id=${noteId}`;
|
||||
},
|
||||
|
||||
// 노트 편집
|
||||
editNote(noteId) {
|
||||
window.location.href = `/note-editor.html?id=${noteId}`;
|
||||
},
|
||||
|
||||
// 노트 삭제
|
||||
async deleteNote(note) {
|
||||
if (!confirm(`"${note.title}" 노트를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/note-documents/${note.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('노트가 삭제되었습니다', 'success');
|
||||
await this.refreshNotes();
|
||||
} else {
|
||||
throw new Error('삭제 실패');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트 삭제 실패:', error);
|
||||
this.showNotification('노트 삭제에 실패했습니다: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 노트 타입 라벨
|
||||
getNoteTypeLabel(type) {
|
||||
const labels = {
|
||||
'note': '일반',
|
||||
'research': '연구',
|
||||
'summary': '요약',
|
||||
'idea': '아이디어',
|
||||
'guide': '가이드',
|
||||
'reference': '참고'
|
||||
};
|
||||
return labels[type] || type;
|
||||
},
|
||||
|
||||
// 날짜 포맷팅
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '오늘';
|
||||
} else if (diffDays === 2) {
|
||||
return '어제';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays - 1}일 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// 간단한 토스트 알림 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-600' :
|
||||
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 3초 후 제거
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// === 일괄 선택 관련 메서드 ===
|
||||
|
||||
// 노트 선택/해제
|
||||
toggleNoteSelection(noteId) {
|
||||
const index = this.selectedNotes.indexOf(noteId);
|
||||
if (index > -1) {
|
||||
this.selectedNotes.splice(index, 1);
|
||||
} else {
|
||||
this.selectedNotes.push(noteId);
|
||||
}
|
||||
},
|
||||
|
||||
// 선택 해제
|
||||
clearSelection() {
|
||||
this.selectedNotes = [];
|
||||
this.bulkNotebookId = '';
|
||||
},
|
||||
|
||||
// 선택된 노트들을 노트북에 할당
|
||||
async assignToNotebook() {
|
||||
if (!this.bulkNotebookId || this.selectedNotes.length === 0) {
|
||||
this.showNotification('노트북을 선택하고 노트를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 각 노트를 업데이트
|
||||
const updatePromises = this.selectedNotes.map(noteId =>
|
||||
this.api.put(`/note-documents/${noteId}`, { notebook_id: this.bulkNotebookId })
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
this.showNotification(`${this.selectedNotes.length}개 노트가 노트북에 할당되었습니다.`, 'success');
|
||||
|
||||
// 선택 해제 및 새로고침
|
||||
this.clearSelection();
|
||||
await this.loadNotes();
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트북 할당 실패:', error);
|
||||
this.showNotification('노트북 할당에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// === 노트북 생성 관련 메서드 ===
|
||||
|
||||
// 노트북 생성 모달 닫기
|
||||
closeCreateNotebookModal() {
|
||||
this.showCreateNotebookModal = false;
|
||||
this.newNotebookForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6',
|
||||
icon: 'book'
|
||||
};
|
||||
},
|
||||
|
||||
// 노트북 생성 및 노트 할당
|
||||
async createNotebookAndAssign() {
|
||||
if (!this.newNotebookForm.name.trim()) {
|
||||
this.showNotification('노트북 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingNotebook = true;
|
||||
|
||||
try {
|
||||
// 1. 노트북 생성
|
||||
const newNotebook = await this.api.post('/notebooks/', this.newNotebookForm);
|
||||
console.log('📚 새 노트북 생성됨:', newNotebook.name);
|
||||
|
||||
// 2. 선택된 노트들이 있으면 할당
|
||||
if (this.selectedNotes.length > 0) {
|
||||
const updatePromises = this.selectedNotes.map(noteId =>
|
||||
this.api.put(`/note-documents/${noteId}`, { notebook_id: newNotebook.id })
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log(`📝 ${this.selectedNotes.length}개 노트가 새 노트북에 할당됨`);
|
||||
}
|
||||
|
||||
this.showNotification(
|
||||
`노트북 "${newNotebook.name}"이 생성되었습니다.${this.selectedNotes.length > 0 ? ` ${this.selectedNotes.length}개 노트가 할당되었습니다.` : ''}`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// 3. 정리 및 새로고침
|
||||
this.closeCreateNotebookModal();
|
||||
this.clearSelection();
|
||||
await this.loadNotebooks();
|
||||
await this.loadNotes();
|
||||
|
||||
} catch (error) {
|
||||
console.error('노트북 생성 실패:', error);
|
||||
this.showNotification('노트북 생성에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.creatingNotebook = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 Notes 페이지 로드됨');
|
||||
});
|
||||
92
frontend/static/js/viewer-test.js
Normal file
92
frontend/static/js/viewer-test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 간단한 테스트용 documentViewer
|
||||
*/
|
||||
window.documentViewer = () => ({
|
||||
// 기본 상태
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// 네비게이션
|
||||
navigation: null,
|
||||
|
||||
// 검색
|
||||
searchQuery: '',
|
||||
|
||||
// 데이터
|
||||
notes: [],
|
||||
bookmarks: [],
|
||||
documentLinks: [],
|
||||
backlinks: [],
|
||||
|
||||
// UI 상태
|
||||
activeFeatureMenu: null,
|
||||
selectedHighlightColor: '#FFFF00',
|
||||
|
||||
// 모달 상태
|
||||
showLinksModal: false,
|
||||
showLinkModal: false,
|
||||
showNotesModal: false,
|
||||
showBookmarksModal: false,
|
||||
showBacklinksModal: false,
|
||||
|
||||
// 폼 데이터
|
||||
linkForm: {
|
||||
target_document_id: '',
|
||||
selected_text: '',
|
||||
book_scope: 'same',
|
||||
target_book_id: '',
|
||||
link_type: 'document',
|
||||
target_text: '',
|
||||
description: ''
|
||||
},
|
||||
|
||||
// 기타 데이터
|
||||
availableBooks: [],
|
||||
filteredDocuments: [],
|
||||
|
||||
// 초기화
|
||||
init() {
|
||||
console.log('🔧 간단한 documentViewer 로드됨');
|
||||
this.documentId = new URLSearchParams(window.location.search).get('id');
|
||||
console.log('📋 문서 ID:', this.documentId);
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
console.log('🔙 뒤로가기 클릭됨');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fromPage = urlParams.get('from');
|
||||
|
||||
if (fromPage === 'index') {
|
||||
window.location.href = '/index.html';
|
||||
} else if (fromPage === 'hierarchy') {
|
||||
window.location.href = '/hierarchy.html';
|
||||
} else {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 기본 함수들
|
||||
toggleFeatureMenu(feature) {
|
||||
console.log('🎯 기능 메뉴 토글:', feature);
|
||||
this.activeFeatureMenu = this.activeFeatureMenu === feature ? null : feature;
|
||||
},
|
||||
|
||||
searchInDocument() {
|
||||
console.log('🔍 문서 검색:', this.searchQuery);
|
||||
},
|
||||
|
||||
// 빈 함수들 (오류 방지용)
|
||||
navigateToDocument() { console.log('네비게이션 함수 호출됨'); },
|
||||
goToBookContents() { console.log('목차로 이동 함수 호출됨'); },
|
||||
createHighlightWithColor() { console.log('하이라이트 생성 함수 호출됨'); },
|
||||
resetTargetSelection() { console.log('타겟 선택 리셋 함수 호출됨'); },
|
||||
loadDocumentsFromBook() { console.log('서적 문서 로드 함수 호출됨'); },
|
||||
onTargetDocumentChange() { console.log('타겟 문서 변경 함수 호출됨'); },
|
||||
openTargetDocumentSelector() { console.log('타겟 문서 선택기 열기 함수 호출됨'); },
|
||||
saveDocumentLink() { console.log('문서 링크 저장 함수 호출됨'); },
|
||||
closeLinkModal() { console.log('링크 모달 닫기 함수 호출됨'); },
|
||||
getSelectedBookTitle() { return '테스트 서적'; }
|
||||
});
|
||||
|
||||
console.log('✅ 테스트용 documentViewer 정의됨');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@
|
||||
<title>문서 뷰어 - Document Server</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
|
||||
<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.4.0/css/all.min.css">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
@@ -332,23 +331,34 @@
|
||||
|
||||
<!-- 링크 목록 -->
|
||||
<template x-for="link in documentLinks" :key="link.id">
|
||||
<div class="bg-gray-50 rounded-xl p-4 mb-4 hover:bg-gray-100 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
|
||||
@click="navigateToLink(link)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900 mb-2" x-text="link.target_document_title"></h4>
|
||||
<p class="text-sm text-gray-600 mb-2" x-text="link.selected_text"></p>
|
||||
<p class="text-sm text-gray-500" x-text="link.description || '설명 없음'"></p>
|
||||
<p class="text-xs text-gray-400 mt-2" x-text="new Date(link.created_at).toLocaleDateString()"></p>
|
||||
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div>
|
||||
|
||||
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
|
||||
<div x-show="link.selected_text" class="mb-2">
|
||||
<div class="text-sm text-gray-600 bg-gray-100 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
|
||||
</div>
|
||||
<div x-show="!link.selected_text" class="mb-2">
|
||||
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div>
|
||||
|
||||
<!-- 링크 타입과 날짜 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500"
|
||||
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex space-x-2">
|
||||
<button @click="navigateToLink(link)"
|
||||
class="px-3 py-1 bg-purple-500 text-white text-sm rounded-lg hover:bg-purple-600 transition-colors">
|
||||
이동
|
||||
</button>
|
||||
<button @click="deleteDocumentLink(link.id)"
|
||||
class="px-3 py-1 bg-red-500 text-white text-sm rounded-lg hover:bg-red-600 transition-colors">
|
||||
삭제
|
||||
</button>
|
||||
<div class="ml-3">
|
||||
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -701,8 +711,13 @@
|
||||
<p class="text-sm text-orange-800 font-medium" x-text="backlink.source_document_title"></p>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 텍스트 -->
|
||||
<p class="text-gray-800 mb-2 leading-relaxed" x-text="backlink.selected_text"></p>
|
||||
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
|
||||
<div x-show="backlink.selected_text" class="mb-2">
|
||||
<p class="text-gray-800 leading-relaxed" x-text="backlink.selected_text"></p>
|
||||
</div>
|
||||
<div x-show="!backlink.selected_text" class="mb-2">
|
||||
<p class="text-gray-600 italic">📄 문서 전체 링크</p>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<p class="text-sm text-gray-600 mb-2" x-text="backlink.description || '설명 없음'"></p>
|
||||
@@ -723,12 +738,70 @@
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/api.js?v=2025012415"></script>
|
||||
<script src="/static/js/viewer.js?v=2025012458"></script>
|
||||
<script src="/static/js/api.js?v=2025012614"></script>
|
||||
<script src="/static/js/viewer.js?v=2025012641"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 백링크 강제 스타일 */
|
||||
.backlink-highlight {
|
||||
color: #EA580C !important;
|
||||
background-color: rgba(234, 88, 12, 0.3) !important;
|
||||
border: 3px solid #EA580C !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 6px 8px !important;
|
||||
font-weight: bold !important;
|
||||
text-decoration: underline !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 4px 8px rgba(234, 88, 12, 0.4) !important;
|
||||
display: inline-block !important;
|
||||
margin: 2px !important;
|
||||
}
|
||||
|
||||
/* 하이라이트 스타일 개선 */
|
||||
.highlight {
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.highlight:hover {
|
||||
box-shadow: 0 0 4px rgba(0,0,0,0.3);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.multi-highlight {
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-highlight:hover {
|
||||
box-shadow: 0 0 6px rgba(0,0,0,0.4);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.multi-highlight::after {
|
||||
content: "🎨";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
font-size: 10px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 기존 언어 전환 버튼 숨기기 */
|
||||
.language-toggle-old,
|
||||
button[onclick*="toggleLanguage"],
|
||||
|
||||
Reference in New Issue
Block a user