🎉 Initial commit: Document Server MVP

 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)
This commit is contained in:
Hyungi Ahn
2025-08-21 16:09:17 +09:00
commit 3036b8f0fb
40 changed files with 6303 additions and 0 deletions

243
frontend/index.html Normal file
View File

@@ -0,0 +1,243 @@
<!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">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 로그인 모달 -->
<div x-data="authModal" x-show="showLogin" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
<button @click="showLogin = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="login">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" x-model="loginForm.email" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input type="password" x-model="loginForm.password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 메인 앱 -->
<div x-data="documentApp" x-init="init()">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 -->
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-file-alt mr-2"></i>
Document Server
</h1>
</div>
<!-- 검색바 -->
<div class="flex-1 max-w-lg mx-8" x-show="isAuthenticated">
<div class="relative">
<input type="text" x-model="searchQuery" @input="searchDocuments"
placeholder="문서, 메모 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4">
<template x-if="!isAuthenticated">
<button @click="$refs.authModal.showLogin = true"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
로그인
</button>
</template>
<template x-if="isAuthenticated">
<div class="flex items-center space-x-4">
<!-- 업로드 버튼 -->
<button @click="showUploadModal = true"
class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700">
<i class="fas fa-upload mr-2"></i>
업로드
</button>
<!-- 사용자 드롭다운 -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="flex items-center text-gray-700 hover:text-gray-900">
<i class="fas fa-user-circle text-2xl"></i>
<span x-text="user?.full_name || user?.email" class="ml-2"></span>
<i class="fas fa-chevron-down ml-1"></i>
</button>
<div x-show="open" @click.away="open = false" x-cloak
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="#" @click="showProfile = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-user mr-2"></i>프로필
</a>
<a href="#" @click="showMyNotes = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sticky-note mr-2"></i>내 메모
</a>
<a href="#" @click="showBookmarks = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-bookmark mr-2"></i>책갈피
</a>
<template x-if="user?.is_admin">
<a href="#" @click="showAdmin = true" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-cog mr-2"></i>관리자
</a>
</template>
<hr class="my-1">
<a href="#" @click="logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 로그인하지 않은 경우 -->
<template x-if="!isAuthenticated">
<div class="text-center py-16">
<i class="fas fa-file-alt text-6xl text-gray-400 mb-4"></i>
<h2 class="text-3xl font-bold text-gray-900 mb-4">Document Server에 오신 것을 환영합니다</h2>
<p class="text-xl text-gray-600 mb-8">HTML 문서를 관리하고 메모, 하이라이트를 추가해보세요</p>
<button @click="$refs.authModal.showLogin = true"
class="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700">
시작하기
</button>
</div>
</template>
<!-- 로그인한 경우 - 문서 목록 -->
<template x-if="isAuthenticated">
<div>
<!-- 필터 및 정렬 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center space-x-4">
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
<select x-model="selectedTag" @change="loadDocuments"
class="px-3 py-2 border border-gray-300 rounded-md">
<option value="">모든 태그</option>
<template x-for="tag in tags" :key="tag.id">
<option :value="tag.name" x-text="tag.name"></option>
</template>
</select>
</div>
<div class="flex items-center space-x-2">
<button @click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-th-large"></i>
</button>
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-list"></i>
</button>
</div>
</div>
<!-- 문서 그리드/리스트 -->
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="doc in documents" :key="doc.id">
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openDocument(doc)">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3>
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
</div>
<p class="text-gray-600 text-sm mb-4 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
<div class="flex flex-wrap gap-1 mb-4">
<template x-for="tag in doc.tags" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
</div>
<div class="flex justify-between items-center text-sm text-gray-500">
<span x-text="formatDate(doc.created_at)"></span>
<span x-text="doc.uploader_name"></span>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<template x-if="documents.length === 0 && !loading">
<div class="text-center py-16">
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
<button @click="showUploadModal = true"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
<i class="fas fa-upload mr-2"></i>
문서 업로드
</button>
</div>
</template>
</div>
</template>
</main>
</div>
<!-- 인증 모달 컴포넌트 -->
<div x-data="authModal" x-ref="authModal"></div>
<!-- 스크립트 -->
<script src="/static/js/api.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/main.js"></script>
<style>
[x-cloak] { display: none !important; }
.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>
</body>
</html>

View File

@@ -0,0 +1,269 @@
/* 메인 스타일 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 알림 애니메이션 */
.notification {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 로딩 스피너 */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 카드 호버 효과 */
.card-hover {
transition: all 0.2s ease-in-out;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
/* 태그 스타일 */
.tag {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
background-color: #dbeafe;
color: #1e40af;
}
/* 검색 입력 포커스 */
.search-input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 드롭다운 애니메이션 */
.dropdown-enter {
opacity: 0;
transform: scale(0.95);
}
.dropdown-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
/* 모달 배경 */
.modal-backdrop {
backdrop-filter: blur(4px);
}
/* 파일 드롭 영역 */
.file-drop-zone {
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.2s ease-in-out;
}
.file-drop-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
}
.file-drop-zone:hover {
border-color: #6b7280;
}
/* 반응형 그리드 */
@media (max-width: 768px) {
.grid-responsive {
grid-template-columns: 1fr;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.grid-responsive {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1025px) {
.grid-responsive {
grid-template-columns: repeat(3, 1fr);
}
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 텍스트 줄임표 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 라인 클램프 유틸리티 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.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;
}
/* 포커스 링 제거 */
.focus-visible:focus {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px #3b82f6;
}
/* 버튼 상태 */
.btn-primary {
background-color: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s ease-in-out;
}
.btn-primary:hover {
background-color: #2563eb;
}
.btn-primary:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6b7280;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s ease-in-out;
}
.btn-secondary:hover {
background-color: #4b5563;
}
/* 입력 필드 스타일 */
.input-field {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.input-field:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-field:invalid {
border-color: #ef4444;
}
/* 에러 메시지 */
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* 성공 메시지 */
.success-message {
color: #10b981;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* 로딩 오버레이 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
/* 빈 상태 일러스트레이션 */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-state i {
font-size: 4rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #6b7280;
margin-bottom: 1.5rem;
}

View File

@@ -0,0 +1,455 @@
/* 뷰어 전용 스타일 */
/* 하이라이트 스타일 */
.highlight {
position: relative;
cursor: pointer;
border-radius: 2px;
padding: 1px 2px;
margin: -1px -2px;
transition: all 0.2s ease-in-out;
}
.highlight:hover {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
z-index: 10;
}
.highlight.selected {
box-shadow: 0 0 0 2px #3B82F6;
z-index: 10;
}
/* 하이라이트 버튼 */
.highlight-button {
animation: fadeInUp 0.2s ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 검색 하이라이트 */
.search-highlight {
background-color: #FEF3C7 !important;
border: 1px solid #F59E0B;
border-radius: 2px;
padding: 1px 2px;
margin: -1px -2px;
}
/* 문서 내용 스타일 */
#document-content {
line-height: 1.7;
font-size: 16px;
color: #374151;
}
#document-content h1,
#document-content h2,
#document-content h3,
#document-content h4,
#document-content h5,
#document-content h6 {
color: #111827;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 1rem;
}
#document-content h1 {
font-size: 2.25rem;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
#document-content h2 {
font-size: 1.875rem;
}
#document-content h3 {
font-size: 1.5rem;
}
#document-content p {
margin-bottom: 1rem;
}
#document-content ul,
#document-content ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
#document-content li {
margin-bottom: 0.25rem;
}
#document-content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #6b7280;
}
#document-content code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
}
#document-content pre {
background-color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
#document-content pre code {
background: none;
padding: 0;
}
#document-content table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
#document-content th,
#document-content td {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
#document-content th {
background-color: #f9fafb;
font-weight: 600;
}
#document-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* 사이드 패널 스타일 */
.side-panel {
background: white;
border-left: 1px solid #e5e7eb;
height: calc(100vh - 4rem);
overflow: hidden;
}
.panel-tab {
transition: all 0.2s ease-in-out;
}
.panel-tab.active {
background-color: #eff6ff;
color: #2563eb;
border-bottom: 2px solid #2563eb;
}
/* 메모 카드 스타일 */
.note-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.note-card:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.note-card.selected {
border-color: #3b82f6;
background: #eff6ff;
}
/* 책갈피 카드 스타일 */
.bookmark-card {
background: #f0fdf4;
border: 1px solid #dcfce7;
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.bookmark-card:hover {
background: #ecfdf5;
border-color: #bbf7d0;
transform: translateY(-1px);
}
/* 색상 선택기 */
.color-picker {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: #f3f4f6;
border-radius: 0.5rem;
}
.color-option {
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
border: 2px solid white;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
box-shadow: 0 0 0 2px #3b82f6;
}
/* 검색 입력 */
.search-input {
background: white;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
width: 100%;
transition: all 0.2s ease-in-out;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 도구 모음 */
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.toolbar-button {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: none;
background: #f3f4f6;
color: #374151;
cursor: pointer;
transition: all 0.2s ease-in-out;
font-size: 0.875rem;
font-weight: 500;
}
.toolbar-button:hover {
background: #e5e7eb;
}
.toolbar-button.active {
background: #3b82f6;
color: white;
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 모달 스타일 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 태그 입력 */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
min-height: 2.5rem;
background: white;
}
.tag-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #eff6ff;
color: #1e40af;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.tag-remove {
cursor: pointer;
color: #6b7280;
font-size: 0.75rem;
}
.tag-remove:hover {
color: #ef4444;
}
/* 스크롤 표시기 */
.scroll-indicator {
position: fixed;
top: 4rem;
right: 1rem;
width: 4px;
height: calc(100vh - 5rem);
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
z-index: 30;
}
.scroll-thumb {
width: 100%;
background: #3b82f6;
border-radius: 2px;
transition: background-color 0.2s ease-in-out;
}
.scroll-thumb:hover {
background: #2563eb;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.side-panel {
position: fixed;
top: 4rem;
right: 0;
width: 100%;
max-width: 24rem;
z-index: 40;
box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1);
}
#document-content {
font-size: 14px;
padding: 1rem;
}
.toolbar {
flex-wrap: wrap;
gap: 0.25rem;
}
.toolbar-button {
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
}
.color-option {
width: 1.5rem;
height: 1.5rem;
}
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
.highlight {
filter: brightness(0.8);
}
.search-highlight {
background-color: #451a03 !important;
border-color: #92400e;
color: #fbbf24;
}
#document-content {
color: #e5e7eb;
}
#document-content h1,
#document-content h2,
#document-content h3,
#document-content h4,
#document-content h5,
#document-content h6 {
color: #f9fafb;
}
}
/* 인쇄 스타일 */
@media print {
.toolbar,
.side-panel,
.highlight-button {
display: none !important;
}
.highlight {
background-color: #fef3c7 !important;
box-shadow: none !important;
}
#document-content {
font-size: 12pt;
line-height: 1.5;
}
}

265
frontend/static/js/api.js Normal file
View File

@@ -0,0 +1,265 @@
/**
* API 통신 유틸리티
*/
class API {
constructor() {
this.baseURL = '/api';
this.token = localStorage.getItem('access_token');
}
// 토큰 설정
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('access_token', token);
} else {
localStorage.removeItem('access_token');
}
}
// 기본 요청 헤더
getHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
// GET 요청
async get(endpoint, params = {}) {
const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin);
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined) {
url.searchParams.append(key, params[key]);
}
});
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
return this.handleResponse(response);
}
// POST 요청
async post(endpoint, data = {}) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse(response);
}
// PUT 요청
async put(endpoint, data = {}) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'PUT',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse(response);
}
// DELETE 요청
async delete(endpoint) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
return this.handleResponse(response);
}
// 파일 업로드
async uploadFile(endpoint, formData) {
const headers = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: headers,
body: formData,
});
return this.handleResponse(response);
}
// 응답 처리
async handleResponse(response) {
if (response.status === 401) {
// 토큰 만료 또는 인증 실패
this.setToken(null);
window.location.reload();
throw new Error('인증이 필요합니다');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return await response.text();
}
// 인증 관련 API
async login(email, password) {
return await this.post('/auth/login', { email, password });
}
async logout() {
try {
await this.post('/auth/logout');
} finally {
this.setToken(null);
}
}
async getCurrentUser() {
return await this.get('/auth/me');
}
async refreshToken(refreshToken) {
return await this.post('/auth/refresh', { refresh_token: refreshToken });
}
// 문서 관련 API
async getDocuments(params = {}) {
return await this.get('/documents/', params);
}
async getDocument(documentId) {
return await this.get(`/documents/${documentId}`);
}
async uploadDocument(formData) {
return await this.uploadFile('/documents/', formData);
}
async deleteDocument(documentId) {
return await this.delete(`/documents/${documentId}`);
}
async getTags() {
return await this.get('/documents/tags/');
}
async createTag(tagData) {
return await this.post('/documents/tags/', tagData);
}
// 하이라이트 관련 API
async createHighlight(highlightData) {
return await this.post('/highlights/', highlightData);
}
async getDocumentHighlights(documentId) {
return await this.get(`/highlights/document/${documentId}`);
}
async updateHighlight(highlightId, data) {
return await this.put(`/highlights/${highlightId}`, data);
}
async deleteHighlight(highlightId) {
return await this.delete(`/highlights/${highlightId}`);
}
// 메모 관련 API
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
async getNotes(params = {}) {
return await this.get('/notes/', params);
}
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
async updateNote(noteId, data) {
return await this.put(`/notes/${noteId}`, data);
}
async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`);
}
async getPopularNoteTags() {
return await this.get('/notes/tags/popular');
}
// 책갈피 관련 API
async createBookmark(bookmarkData) {
return await this.post('/bookmarks/', bookmarkData);
}
async getBookmarks(params = {}) {
return await this.get('/bookmarks/', params);
}
async getDocumentBookmarks(documentId) {
return await this.get(`/bookmarks/document/${documentId}`);
}
async updateBookmark(bookmarkId, data) {
return await this.put(`/bookmarks/${bookmarkId}`, data);
}
async deleteBookmark(bookmarkId) {
return await this.delete(`/bookmarks/${bookmarkId}`);
}
// 검색 관련 API
async search(params = {}) {
return await this.get('/search/', params);
}
async getSearchSuggestions(query) {
return await this.get('/search/suggestions', { q: query });
}
// 사용자 관리 API
async getUsers() {
return await this.get('/users/');
}
async createUser(userData) {
return await this.post('/auth/create-user', userData);
}
async updateUser(userId, userData) {
return await this.put(`/users/${userId}`, userData);
}
async deleteUser(userId) {
return await this.delete(`/users/${userId}`);
}
async updateProfile(profileData) {
return await this.put('/users/profile', profileData);
}
async changePassword(passwordData) {
return await this.put('/auth/change-password', passwordData);
}
}
// 전역 API 인스턴스
window.api = new API();

View File

@@ -0,0 +1,90 @@
/**
* 인증 관련 Alpine.js 컴포넌트
*/
// 인증 모달 컴포넌트
window.authModal = () => ({
showLogin: false,
loginForm: {
email: '',
password: ''
},
loginError: '',
loginLoading: false,
async login() {
this.loginLoading = true;
this.loginError = '';
try {
const response = await api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장
api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 로드
const user = await api.getCurrentUser();
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: true, user }
}));
// 모달 닫기
this.showLogin = false;
this.loginForm = { email: '', password: '' };
} catch (error) {
this.loginError = error.message;
} finally {
this.loginLoading = false;
}
},
async logout() {
try {
await api.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
// 로컬 스토리지 정리
localStorage.removeItem('refresh_token');
// 전역 상태 업데이트
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null }
}));
}
}
});
// 자동 토큰 갱신
async function refreshTokenIfNeeded() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken || !api.token) return;
try {
// 토큰 만료 확인 (JWT 디코딩)
const tokenPayload = JSON.parse(atob(api.token.split('.')[1]));
const now = Date.now() / 1000;
// 토큰이 5분 내에 만료되면 갱신
if (tokenPayload.exp - now < 300) {
const response = await api.refreshToken(refreshToken);
api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
}
} catch (error) {
console.error('Token refresh failed:', error);
// 갱신 실패시 로그아웃
api.setToken(null);
localStorage.removeItem('refresh_token');
window.dispatchEvent(new CustomEvent('auth-changed', {
detail: { isAuthenticated: false, user: null }
}));
}
}
// 5분마다 토큰 갱신 체크
setInterval(refreshTokenIfNeeded, 5 * 60 * 1000);

286
frontend/static/js/main.js Normal file
View File

@@ -0,0 +1,286 @@
/**
* 메인 애플리케이션 Alpine.js 컴포넌트
*/
// 메인 문서 앱 컴포넌트
window.documentApp = () => ({
// 상태
isAuthenticated: false,
user: null,
loading: false,
// 문서 관련
documents: [],
tags: [],
selectedTag: '',
viewMode: 'grid', // 'grid' 또는 'list'
// 검색
searchQuery: '',
searchResults: [],
// 모달 상태
showUploadModal: false,
showProfile: false,
showMyNotes: false,
showBookmarks: false,
showAdmin: false,
// 초기화
async init() {
// 인증 상태 확인
await this.checkAuth();
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-changed', (event) => {
this.isAuthenticated = event.detail.isAuthenticated;
this.user = event.detail.user;
if (this.isAuthenticated) {
this.loadInitialData();
} else {
this.resetData();
}
});
// 초기 데이터 로드
if (this.isAuthenticated) {
await this.loadInitialData();
}
},
// 인증 상태 확인
async checkAuth() {
if (!api.token) {
this.isAuthenticated = false;
return;
}
try {
this.user = await api.getCurrentUser();
this.isAuthenticated = true;
} catch (error) {
console.error('Auth check failed:', error);
this.isAuthenticated = false;
api.setToken(null);
}
},
// 초기 데이터 로드
async loadInitialData() {
this.loading = true;
try {
await Promise.all([
this.loadDocuments(),
this.loadTags()
]);
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
this.loading = false;
}
},
// 데이터 리셋
resetData() {
this.documents = [];
this.tags = [];
this.selectedTag = '';
this.searchQuery = '';
this.searchResults = [];
},
// 문서 목록 로드
async loadDocuments() {
try {
const params = {};
if (this.selectedTag) {
params.tag = this.selectedTag;
}
this.documents = await api.getDocuments(params);
} catch (error) {
console.error('Failed to load documents:', error);
this.showNotification('문서 목록을 불러오는데 실패했습니다', 'error');
}
},
// 태그 목록 로드
async loadTags() {
try {
this.tags = await api.getTags();
} catch (error) {
console.error('Failed to load tags:', error);
}
},
// 문서 검색
async searchDocuments() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
return;
}
try {
const results = await api.search({
q: this.searchQuery,
limit: 20
});
this.searchResults = results.results;
} catch (error) {
console.error('Search failed:', error);
}
},
// 문서 열기
openDocument(document) {
// 문서 뷰어 페이지로 이동
window.location.href = `/viewer.html?id=${document.id}`;
},
// 로그아웃
async logout() {
try {
await api.logout();
this.isAuthenticated = false;
this.user = null;
this.resetData();
} catch (error) {
console.error('Logout failed:', error);
}
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
// 알림 표시
showNotification(message, type = 'info') {
// 간단한 알림 구현 (나중에 토스트 라이브러리로 교체 가능)
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 p-4 rounded-lg text-white z-50 ${
type === 'error' ? 'bg-red-500' :
type === 'success' ? 'bg-green-500' :
'bg-blue-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
});
// 파일 업로드 컴포넌트
window.uploadModal = () => ({
show: false,
uploading: false,
uploadForm: {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
},
uploadError: '',
// 파일 선택
onFileSelect(event, fileType) {
const file = event.target.files[0];
if (file) {
this.uploadForm[fileType] = file;
// HTML 파일의 경우 제목 자동 설정
if (fileType === 'html_file' && !this.uploadForm.title) {
this.uploadForm.title = file.name.replace(/\.[^/.]+$/, "");
}
}
},
// 업로드 실행
async upload() {
if (!this.uploadForm.html_file) {
this.uploadError = 'HTML 파일을 선택해주세요';
return;
}
if (!this.uploadForm.title.trim()) {
this.uploadError = '제목을 입력해주세요';
return;
}
this.uploading = true;
this.uploadError = '';
try {
const formData = new FormData();
formData.append('title', this.uploadForm.title);
formData.append('description', this.uploadForm.description);
formData.append('tags', this.uploadForm.tags);
formData.append('is_public', this.uploadForm.is_public);
formData.append('html_file', this.uploadForm.html_file);
if (this.uploadForm.pdf_file) {
formData.append('pdf_file', this.uploadForm.pdf_file);
}
if (this.uploadForm.document_date) {
formData.append('document_date', this.uploadForm.document_date);
}
await api.uploadDocument(formData);
// 성공시 모달 닫기 및 목록 새로고침
this.show = false;
this.resetForm();
// 문서 목록 새로고침
window.dispatchEvent(new CustomEvent('documents-changed'));
// 성공 알림
document.querySelector('[x-data="documentApp"]').__x.$data.showNotification('문서가 성공적으로 업로드되었습니다', 'success');
} catch (error) {
this.uploadError = error.message;
} finally {
this.uploading = false;
}
},
// 폼 리셋
resetForm() {
this.uploadForm = {
title: '',
description: '',
tags: '',
is_public: false,
document_date: '',
html_file: null,
pdf_file: null
};
this.uploadError = '';
// 파일 입력 필드 리셋
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => input.value = '');
}
});
// 문서 변경 이벤트 리스너
window.addEventListener('documents-changed', () => {
const app = document.querySelector('[x-data="documentApp"]').__x.$data;
if (app && app.isAuthenticated) {
app.loadDocuments();
app.loadTags();
}
});

View File

@@ -0,0 +1,641 @@
/**
* 문서 뷰어 Alpine.js 컴포넌트
*/
window.documentViewer = () => ({
// 상태
loading: true,
error: null,
document: null,
documentId: null,
// 하이라이트 및 메모
highlights: [],
notes: [],
selectedHighlightColor: '#FFFF00',
selectedText: '',
selectedRange: null,
// 책갈피
bookmarks: [],
// UI 상태
showNotesPanel: false,
showBookmarksPanel: false,
activePanel: 'notes',
// 검색
searchQuery: '',
noteSearchQuery: '',
filteredNotes: [],
// 모달
showNoteModal: false,
showBookmarkModal: false,
editingNote: null,
editingBookmark: null,
noteLoading: false,
bookmarkLoading: false,
// 폼 데이터
noteForm: {
content: '',
tags: ''
},
bookmarkForm: {
title: '',
description: ''
},
// 초기화
async init() {
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
if (!this.documentId) {
this.error = '문서 ID가 없습니다';
this.loading = false;
return;
}
// 인증 확인
if (!api.token) {
window.location.href = '/';
return;
}
try {
await this.loadDocument();
await this.loadDocumentData();
} catch (error) {
console.error('Failed to load document:', error);
this.error = error.message;
} finally {
this.loading = false;
}
// 초기 필터링
this.filterNotes();
},
// 문서 로드
async loadDocument() {
this.document = await api.getDocument(this.documentId);
// HTML 내용 로드
const response = await fetch(`/uploads/documents/${this.documentId}.html`);
if (!response.ok) {
throw new Error('문서를 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
},
// 문서 관련 데이터 로드
async loadDocumentData() {
try {
const [highlights, notes, bookmarks] = await Promise.all([
api.getDocumentHighlights(this.documentId),
api.getDocumentNotes(this.documentId),
api.getDocumentBookmarks(this.documentId)
]);
this.highlights = highlights;
this.notes = notes;
this.bookmarks = bookmarks;
// 하이라이트 렌더링
this.renderHighlights();
} catch (error) {
console.error('Failed to load document data:', error);
}
},
// 하이라이트 렌더링
renderHighlights() {
const content = document.getElementById('document-content');
// 기존 하이라이트 제거
content.querySelectorAll('.highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 새 하이라이트 적용
this.highlights.forEach(highlight => {
this.applyHighlight(highlight);
});
},
// 개별 하이라이트 적용
applyHighlight(highlight) {
const content = document.getElementById('document-content');
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
const nodeStart = currentOffset;
const nodeEnd = currentOffset + nodeLength;
// 하이라이트 범위와 겹치는지 확인
if (nodeStart < highlight.end_offset && nodeEnd > highlight.start_offset) {
const startInNode = Math.max(0, highlight.start_offset - nodeStart);
const endInNode = Math.min(nodeLength, highlight.end_offset - nodeStart);
if (startInNode < endInNode) {
// 텍스트 노드를 분할하고 하이라이트 적용
const beforeText = node.textContent.substring(0, startInNode);
const highlightText = node.textContent.substring(startInNode, endInNode);
const afterText = node.textContent.substring(endInNode);
const parent = node.parentNode;
// 하이라이트 요소 생성
const highlightEl = document.createElement('span');
highlightEl.className = 'highlight';
highlightEl.style.backgroundColor = highlight.highlight_color;
highlightEl.textContent = highlightText;
highlightEl.dataset.highlightId = highlight.id;
// 클릭 이벤트 추가
highlightEl.addEventListener('click', (e) => {
e.stopPropagation();
this.selectHighlight(highlight.id);
});
// 노드 교체
if (beforeText) {
parent.insertBefore(document.createTextNode(beforeText), node);
}
parent.insertBefore(highlightEl, node);
if (afterText) {
parent.insertBefore(document.createTextNode(afterText), node);
}
parent.removeChild(node);
}
}
currentOffset = nodeEnd;
}
},
// 텍스트 선택 처리
handleTextSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
if (selectedText.length < 2) {
return;
}
// 문서 컨텐츠 내부의 선택인지 확인
const content = document.getElementById('document-content');
if (!content.contains(range.commonAncestorContainer)) {
return;
}
// 선택된 텍스트와 범위 저장
this.selectedText = selectedText;
this.selectedRange = range.cloneRange();
// 컨텍스트 메뉴 표시 (간단한 버튼)
this.showHighlightButton(selection);
},
// 하이라이트 버튼 표시
showHighlightButton(selection) {
// 기존 버튼 제거
const existingButton = document.querySelector('.highlight-button');
if (existingButton) {
existingButton.remove();
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const button = document.createElement('button');
button.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-3 py-1 rounded shadow-lg text-sm';
button.style.left = `${rect.left + window.scrollX}px`;
button.style.top = `${rect.bottom + window.scrollY + 5}px`;
button.innerHTML = '<i class="fas fa-highlighter mr-1"></i>하이라이트';
button.addEventListener('click', () => {
this.createHighlight();
button.remove();
});
document.body.appendChild(button);
// 3초 후 자동 제거
setTimeout(() => {
if (button.parentNode) {
button.remove();
}
}, 3000);
},
// 하이라이트 생성
async createHighlight() {
if (!this.selectedText || !this.selectedRange) {
return;
}
try {
// 텍스트 오프셋 계산
const content = document.getElementById('document-content');
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
const highlightData = {
document_id: this.documentId,
start_offset: startOffset,
end_offset: endOffset,
selected_text: this.selectedText,
highlight_color: this.selectedHighlightColor,
highlight_type: 'highlight'
};
const highlight = await api.createHighlight(highlightData);
this.highlights.push(highlight);
// 하이라이트 렌더링
this.renderHighlights();
// 선택 해제
window.getSelection().removeAllRanges();
this.selectedText = '';
this.selectedRange = null;
// 메모 추가 여부 확인
if (confirm('이 하이라이트에 메모를 추가하시겠습니까?')) {
this.openNoteModal(highlight);
}
} catch (error) {
console.error('Failed to create highlight:', error);
alert('하이라이트 생성에 실패했습니다');
}
},
// 텍스트 오프셋 계산
calculateTextOffsets(range, container) {
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let startOffset = -1;
let endOffset = -1;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (range.startContainer === node) {
startOffset = currentOffset + range.startOffset;
}
if (range.endContainer === node) {
endOffset = currentOffset + range.endOffset;
break;
}
currentOffset += nodeLength;
}
return { startOffset, endOffset };
},
// 하이라이트 선택
selectHighlight(highlightId) {
// 모든 하이라이트에서 selected 클래스 제거
document.querySelectorAll('.highlight').forEach(el => {
el.classList.remove('selected');
});
// 선택된 하이라이트에 selected 클래스 추가
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.classList.add('selected');
}
// 해당 하이라이트의 메모 찾기
const note = this.notes.find(n => n.highlight.id === highlightId);
if (note) {
this.editNote(note);
} else {
// 메모가 없으면 새로 생성
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
this.openNoteModal(highlight);
}
}
},
// 하이라이트로 스크롤
scrollToHighlight(highlightId) {
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
highlightEl.classList.add('selected');
// 2초 후 선택 해제
setTimeout(() => {
highlightEl.classList.remove('selected');
}, 2000);
}
},
// 메모 모달 열기
openNoteModal(highlight = null) {
this.editingNote = null;
this.noteForm = {
content: '',
tags: ''
};
if (highlight) {
this.selectedHighlight = highlight;
this.selectedText = highlight.selected_text;
}
this.showNoteModal = true;
},
// 메모 편집
editNote(note) {
this.editingNote = note;
this.noteForm = {
content: note.content,
tags: note.tags ? note.tags.join(', ') : ''
};
this.selectedText = note.highlight.selected_text;
this.showNoteModal = true;
},
// 메모 저장
async saveNote() {
this.noteLoading = true;
try {
const noteData = {
content: this.noteForm.content,
tags: this.noteForm.tags ? this.noteForm.tags.split(',').map(t => t.trim()).filter(t => t) : []
};
if (this.editingNote) {
// 메모 수정
const updatedNote = await api.updateNote(this.editingNote.id, noteData);
const index = this.notes.findIndex(n => n.id === this.editingNote.id);
if (index !== -1) {
this.notes[index] = updatedNote;
}
} else {
// 새 메모 생성
noteData.highlight_id = this.selectedHighlight.id;
const newNote = await api.createNote(noteData);
this.notes.push(newNote);
}
this.filterNotes();
this.closeNoteModal();
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
} finally {
this.noteLoading = false;
}
},
// 메모 삭제
async deleteNote(noteId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteNote(noteId);
this.notes = this.notes.filter(n => n.id !== noteId);
this.filterNotes();
} catch (error) {
console.error('Failed to delete note:', error);
alert('메모 삭제에 실패했습니다');
}
},
// 메모 모달 닫기
closeNoteModal() {
this.showNoteModal = false;
this.editingNote = null;
this.selectedHighlight = null;
this.selectedText = '';
this.noteForm = { content: '', tags: '' };
},
// 메모 필터링
filterNotes() {
if (!this.noteSearchQuery.trim()) {
this.filteredNotes = [...this.notes];
} else {
const query = this.noteSearchQuery.toLowerCase();
this.filteredNotes = this.notes.filter(note =>
note.content.toLowerCase().includes(query) ||
note.highlight.selected_text.toLowerCase().includes(query) ||
(note.tags && note.tags.some(tag => tag.toLowerCase().includes(query)))
);
}
},
// 책갈피 추가
async addBookmark() {
const scrollPosition = window.scrollY;
this.bookmarkForm = {
title: `${this.document.title} - ${new Date().toLocaleString()}`,
description: ''
};
this.currentScrollPosition = scrollPosition;
this.showBookmarkModal = true;
},
// 책갈피 편집
editBookmark(bookmark) {
this.editingBookmark = bookmark;
this.bookmarkForm = {
title: bookmark.title,
description: bookmark.description || ''
};
this.showBookmarkModal = true;
},
// 책갈피 저장
async saveBookmark() {
this.bookmarkLoading = true;
try {
const bookmarkData = {
title: this.bookmarkForm.title,
description: this.bookmarkForm.description,
scroll_position: this.currentScrollPosition || 0
};
if (this.editingBookmark) {
// 책갈피 수정
const updatedBookmark = await api.updateBookmark(this.editingBookmark.id, bookmarkData);
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id);
if (index !== -1) {
this.bookmarks[index] = updatedBookmark;
}
} else {
// 새 책갈피 생성
bookmarkData.document_id = this.documentId;
const newBookmark = await api.createBookmark(bookmarkData);
this.bookmarks.push(newBookmark);
}
this.closeBookmarkModal();
} catch (error) {
console.error('Failed to save bookmark:', error);
alert('책갈피 저장에 실패했습니다');
} finally {
this.bookmarkLoading = false;
}
},
// 책갈피 삭제
async deleteBookmark(bookmarkId) {
if (!confirm('이 책갈피를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteBookmark(bookmarkId);
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
} catch (error) {
console.error('Failed to delete bookmark:', error);
alert('책갈피 삭제에 실패했습니다');
}
},
// 책갈피로 스크롤
scrollToBookmark(bookmark) {
window.scrollTo({
top: bookmark.scroll_position,
behavior: 'smooth'
});
},
// 책갈피 모달 닫기
closeBookmarkModal() {
this.showBookmarkModal = false;
this.editingBookmark = null;
this.bookmarkForm = { title: '', description: '' };
this.currentScrollPosition = null;
},
// 문서 내 검색
searchInDocument() {
// 기존 검색 하이라이트 제거
document.querySelectorAll('.search-highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
if (!this.searchQuery.trim()) {
return;
}
// 새 검색 하이라이트 적용
const content = document.getElementById('document-content');
this.highlightSearchResults(content, this.searchQuery);
},
// 검색 결과 하이라이트
highlightSearchResults(element, searchText) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
const text = textNode.textContent;
const regex = new RegExp(`(${searchText})`, 'gi');
if (regex.test(text)) {
const parent = textNode.parentNode;
const highlightedHTML = text.replace(regex, '<span class="search-highlight">$1</span>');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = highlightedHTML;
while (tempDiv.firstChild) {
parent.insertBefore(tempDiv.firstChild, textNode);
}
parent.removeChild(textNode);
}
});
},
// 문서 클릭 처리
handleDocumentClick(event) {
// 하이라이트 버튼 제거
const button = document.querySelector('.highlight-button');
if (button && !button.contains(event.target)) {
button.remove();
}
// 하이라이트 선택 해제
document.querySelectorAll('.highlight.selected').forEach(el => {
el.classList.remove('selected');
});
},
// 뒤로가기
goBack() {
window.history.back();
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
});

363
frontend/viewer.html Normal file
View File

@@ -0,0 +1,363 @@
<!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>