🎉 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:
269
frontend/static/css/main.css
Normal file
269
frontend/static/css/main.css
Normal 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;
|
||||
}
|
||||
455
frontend/static/css/viewer.css
Normal file
455
frontend/static/css/viewer.css
Normal 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
265
frontend/static/js/api.js
Normal 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();
|
||||
90
frontend/static/js/auth.js
Normal file
90
frontend/static/js/auth.js
Normal 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
286
frontend/static/js/main.js
Normal 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();
|
||||
}
|
||||
});
|
||||
641
frontend/static/js/viewer.js
Normal file
641
frontend/static/js/viewer.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user