✨ Features implemented: - FastAPI backend with JWT authentication - PostgreSQL database with async SQLAlchemy - HTML document viewer with smart highlighting - Note system connected to highlights (1:1 relationship) - Bookmark system for quick navigation - Integrated search (documents + notes) - Tag system for document organization - Docker containerization with Nginx 🔧 Technical stack: - Backend: FastAPI + PostgreSQL + Redis - Frontend: Alpine.js + Tailwind CSS - Authentication: JWT tokens - File handling: HTML + PDF support - Search: Full-text search with relevance scoring 📋 Core functionality: - Text selection → Highlight creation - Highlight → Note attachment - Note management with search/filtering - Bookmark creation at scroll positions - Document upload with metadata - User management (admin creates accounts)
364 lines
20 KiB
HTML
364 lines
20 KiB
HTML
<!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.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">
|
|
<link rel="stylesheet" href="/static/css/viewer.css">
|
|
</head>
|
|
<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">
|
|
<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">
|
|
<button @click="goBack" class="text-gray-600 hover:text-gray-900">
|
|
<i class="fas fa-arrow-left text-xl"></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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 도구 모음 -->
|
|
<div class="flex items-center space-x-2">
|
|
<!-- 하이라이트 색상 선택 -->
|
|
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
|
|
<button @click="selectedHighlightColor = '#FFFF00'"
|
|
:class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-blue-500' : ''"
|
|
class="w-8 h-8 bg-yellow-300 rounded border-2 border-white"></button>
|
|
<button @click="selectedHighlightColor = '#90EE90'"
|
|
:class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-blue-500' : ''"
|
|
class="w-8 h-8 bg-green-300 rounded border-2 border-white"></button>
|
|
<button @click="selectedHighlightColor = '#FFB6C1'"
|
|
:class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-blue-500' : ''"
|
|
class="w-8 h-8 bg-pink-300 rounded border-2 border-white"></button>
|
|
<button @click="selectedHighlightColor = '#87CEEB'"
|
|
:class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-500' : ''"
|
|
class="w-8 h-8 bg-blue-300 rounded border-2 border-white"></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>
|
|
|
|
<!-- 책갈피 패널 토글 -->
|
|
<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>
|
|
|
|
<!-- 검색 -->
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- 메인 컨텐츠 -->
|
|
<div class="flex max-w-7xl mx-auto">
|
|
<!-- 문서 뷰어 -->
|
|
<main class="flex-1 bg-white shadow-sm" :class="(showNotesPanel || showBookmarksPanel) ? 'mr-80' : ''">
|
|
<div class="p-8">
|
|
<!-- 로딩 상태 -->
|
|
<div x-show="loading" class="text-center py-16">
|
|
<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="text-center py-16">
|
|
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-4"></i>
|
|
<p class="text-red-600" x-text="error"></p>
|
|
<button @click="goBack" class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
|
돌아가기
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 문서 내용 -->
|
|
<div x-show="!loading && !error"
|
|
id="document-content"
|
|
class="prose prose-lg max-w-none"
|
|
@mouseup="handleTextSelection"
|
|
@click="handleDocumentClick">
|
|
<!-- 문서 HTML이 여기에 로드됩니다 -->
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- 사이드 패널 -->
|
|
<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">
|
|
|
|
<!-- 패널 탭 -->
|
|
<div class="flex border-b">
|
|
<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>)
|
|
</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>)
|
|
</button>
|
|
<button @click="showNotesPanel = false; showBookmarksPanel = false"
|
|
class="px-3 py-3 text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 메모 패널 -->
|
|
<div x-show="activePanel === 'notes'" class="flex-1 overflow-hidden flex flex-col">
|
|
<div class="p-4 border-b">
|
|
<input type="text" x-model="noteSearchQuery" @input="filterNotes"
|
|
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 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>
|
|
</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"
|
|
@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>
|
|
<div class="flex space-x-1 ml-2">
|
|
<button @click.stop="editNote(note)" class="text-blue-500 hover:text-blue-700">
|
|
<i class="fas fa-edit text-xs"></i>
|
|
</button>
|
|
<button @click.stop="deleteNote(note.id)" class="text-red-500 hover:text-red-700">
|
|
<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">
|
|
<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>
|
|
</template>
|
|
</div>
|
|
<span class="text-xs text-gray-500" x-text="formatDate(note.created_at)"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 책갈피 패널 -->
|
|
<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">
|
|
<i class="fas fa-plus mr-2"></i>
|
|
현재 위치에 책갈피 추가
|
|
</button>
|
|
</div>
|
|
|
|
<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>
|
|
</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"
|
|
@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">
|
|
<i class="fas fa-edit text-xs"></i>
|
|
</button>
|
|
<button @click.stop="deleteBookmark(bookmark.id)" class="text-red-500 hover:text-red-700">
|
|
<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>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- 메모 추가/편집 모달 -->
|
|
<div x-show="showNoteModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold" x-text="editingNote ? '메모 편집' : '메모 추가'"></h3>
|
|
<button @click="closeNoteModal" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 선택된 텍스트 표시 -->
|
|
<div x-show="selectedText" class="mb-4 p-3 bg-yellow-100 rounded-lg">
|
|
<p class="text-sm text-gray-600 mb-1">선택된 텍스트:</p>
|
|
<p class="text-sm font-medium" x-text="selectedText"></p>
|
|
</div>
|
|
|
|
<form @submit.prevent="saveNote">
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">메모 내용</label>
|
|
<textarea x-model="noteForm.content" rows="4" 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="메모를 입력하세요..."></textarea>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">태그 (쉼표로 구분)</label>
|
|
<input type="text" x-model="noteForm.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="예: 중요, 질문, 아이디어">
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3">
|
|
<button type="button" @click="closeNoteModal"
|
|
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
|
|
취소
|
|
</button>
|
|
<button type="submit" :disabled="noteLoading"
|
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
|
<span x-show="!noteLoading" x-text="editingNote ? '수정' : '저장'"></span>
|
|
<span x-show="noteLoading">저장 중...</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 책갈피 추가/편집 모달 -->
|
|
<div x-show="showBookmarkModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold" x-text="editingBookmark ? '책갈피 편집' : '책갈피 추가'"></h3>
|
|
<button @click="closeBookmarkModal" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="saveBookmark">
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
|
|
<input type="text" x-model="bookmarkForm.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>
|
|
<textarea x-model="bookmarkForm.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="flex justify-end space-x-3">
|
|
<button type="button" @click="closeBookmarkModal"
|
|
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
|
|
취소
|
|
</button>
|
|
<button type="submit" :disabled="bookmarkLoading"
|
|
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50">
|
|
<span x-show="!bookmarkLoading" x-text="editingBookmark ? '수정' : '저장'"></span>
|
|
<span x-show="bookmarkLoading">저장 중...</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 스크립트 -->
|
|
<script src="/static/js/api.js"></script>
|
|
<script src="/static/js/viewer.js"></script>
|
|
|
|
<style>
|
|
[x-cloak] { display: none !important; }
|
|
|
|
.highlight {
|
|
position: relative;
|
|
cursor: pointer;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.highlight:hover {
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
|
}
|
|
|
|
.highlight.selected {
|
|
box-shadow: 0 0 0 2px #3B82F6;
|
|
}
|
|
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 검색 하이라이트 */
|
|
.search-highlight {
|
|
background-color: #FEF3C7 !important;
|
|
border: 1px solid #F59E0B;
|
|
}
|
|
|
|
/* 스크롤바 스타일링 */
|
|
.overflow-y-auto::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-track {
|
|
background: #f1f1f1;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
background: #c1c1c1;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
|
background: #a8a8a8;
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|