🔍 고급 통합 검색 시스템 완성

🎯 주요 기능:
- 하이라이트 메모 내용 별도 검색 (highlight_note 타입)
- PDF/HTML 본문 전체 텍스트 검색 (OCR 데이터 활용)
- 검색 결과 미리보기 모달 (전체 내용 로드)
- 메모 트리 노드 검색 지원
- 노트 문서 통합 검색

🔧 백엔드 개선:
- search_highlight_notes: 하이라이트 메모 내용 검색
- search_document_content: HTML/PDF 본문 검색 (BeautifulSoup)
- search_memo_nodes: 메모 트리 노드 검색
- search_note_documents: 노트 문서 검색
- extract_search_context: 검색어 주변 컨텍스트 추출

🎨 프론트엔드 기능:
- 통합 검색 UI (/search.html) 완전 구현
- 검색 필터: 문서/노트/메모/하이라이트/메모/본문
- 미리보기 모달: 전체 내용 로드 및 표시
- 검색 결과 하이라이트 및 컨텍스트 표시
- 타입별 배지 및 관련도 점수 표시

📱 사용자 경험:
- 실시간 검색 디바운스 (500ms)
- 검색어 자동완성 제안
- 검색 통계 및 성능 표시
- 빠른 검색 예시 버튼
- 새 탭에서 결과 열기

🔗 네비게이션 통합:
- 헤더에 '통합 검색' 링크 추가
- 페이지별 활성 상태 관리
This commit is contained in:
Hyungi Ahn
2025-09-02 16:53:56 +09:00
parent 97d60554a9
commit 4329a1c9a6
5 changed files with 1120 additions and 5 deletions

View File

@@ -13,6 +13,8 @@ from ...models.user import User
from ...models.document import Document, Tag from ...models.document import Document, Tag
from ...models.highlight import Highlight from ...models.highlight import Highlight
from ...models.note import Note from ...models.note import Note
from ...models.memo_tree import MemoTree, MemoNode
from ...models.note_document import NoteDocument
from ..dependencies import get_current_active_user from ..dependencies import get_current_active_user
from pydantic import BaseModel from pydantic import BaseModel
@@ -47,7 +49,7 @@ router = APIRouter()
@router.get("/", response_model=SearchResponse) @router.get("/", response_model=SearchResponse)
async def search_all( async def search_all(
q: str = Query(..., description="검색어"), q: str = Query(..., description="검색어"),
type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, highlight"), type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, memo, highlight"),
document_id: Optional[str] = Query(None, description="특정 문서 내 검색"), document_id: Optional[str] = Query(None, description="특정 문서 내 검색"),
tag: Optional[str] = Query(None, description="태그 필터"), tag: Optional[str] = Query(None, description="태그 필터"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
@@ -63,16 +65,36 @@ async def search_all(
document_results = await search_documents(q, document_id, tag, current_user, db) document_results = await search_documents(q, document_id, tag, current_user, db)
results.extend(document_results) results.extend(document_results)
# 2. 메모 검색 # 2. 노트 문서 검색
if not type_filter or type_filter == "note": if not type_filter or type_filter == "note":
note_results = await search_notes(q, document_id, tag, current_user, db) note_results = await search_note_documents(q, current_user, db)
results.extend(note_results) results.extend(note_results)
# 3. 하이라이트 검색 # 3. 메모 트리 노드 검색
if not type_filter or type_filter == "memo":
memo_results = await search_memo_nodes(q, current_user, db)
results.extend(memo_results)
# 4. 기존 메모 검색 (하위 호환성)
if not type_filter or type_filter == "note":
old_note_results = await search_notes(q, document_id, tag, current_user, db)
results.extend(old_note_results)
# 5. 하이라이트 검색
if not type_filter or type_filter == "highlight": if not type_filter or type_filter == "highlight":
highlight_results = await search_highlights(q, document_id, current_user, db) highlight_results = await search_highlights(q, document_id, current_user, db)
results.extend(highlight_results) results.extend(highlight_results)
# 6. 하이라이트 메모 검색
if not type_filter or type_filter == "highlight_note":
highlight_note_results = await search_highlight_notes(q, document_id, current_user, db)
results.extend(highlight_note_results)
# 7. 문서 본문 검색 (OCR 데이터)
if not type_filter or type_filter == "document_content":
content_results = await search_document_content(q, document_id, current_user, db)
results.extend(content_results)
# 관련성 점수로 정렬 # 관련성 점수로 정렬
results.sort(key=lambda x: x.relevance_score, reverse=True) results.sort(key=lambda x: x.relevance_score, reverse=True)
@@ -352,3 +374,239 @@ async def get_search_suggestions(
suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]]) suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]])
return {"suggestions": suggestions[:10]} return {"suggestions": suggestions[:10]}
async def search_highlight_notes(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""하이라이트 메모 내용 검색"""
query_obj = select(Note).options(
selectinload(Note.highlight).selectinload(Highlight.document)
)
# 하이라이트가 있는 노트만
query_obj = query_obj.where(Note.highlight_id.isnot(None))
# 권한 필터링 - 사용자의 노트만
query_obj = query_obj.where(Note.created_by == current_user.id)
# 특정 문서 필터
if document_id:
query_obj = query_obj.join(Highlight).where(Highlight.document_id == document_id)
# 메모 내용에서 검색
query_obj = query_obj.where(Note.content.ilike(f"%{query}%"))
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
if not note.highlight or not note.highlight.document:
continue
# 관련성 점수 계산
score = 1.5 # 메모 내용 매치는 높은 점수
content_lower = (note.content or "").lower()
if query.lower() in content_lower:
score += 2.0
search_results.append(SearchResult(
type="highlight_note",
id=str(note.id),
title=f"하이라이트 메모: {note.highlight.selected_text[:30]}...",
content=note.content or "",
document_id=str(note.highlight.document.id),
document_title=note.highlight.document.title,
created_at=note.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset,
"note_content": note.content
}
))
return search_results
async def search_note_documents(
query: str,
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""노트 문서 검색"""
query_obj = select(NoteDocument).where(
or_(
NoteDocument.title.ilike(f"%{query}%"),
NoteDocument.content.ilike(f"%{query}%")
)
)
# 권한 필터링 - 사용자의 노트만
query_obj = query_obj.where(NoteDocument.created_by == current_user.email)
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
# 관련성 점수 계산
score = 1.0
if query.lower() in note.title.lower():
score += 2.0
if note.content and query.lower() in note.content.lower():
score += 1.0
search_results.append(SearchResult(
type="note",
id=str(note.id),
title=note.title,
content=note.content or "",
document_id=str(note.id), # 노트 자체가 문서
document_title=note.title,
created_at=note.created_at,
relevance_score=score
))
return search_results
async def search_memo_nodes(
query: str,
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""메모 트리 노드 검색"""
query_obj = select(MemoNode).options(
selectinload(MemoNode.tree)
).where(
or_(
MemoNode.title.ilike(f"%{query}%"),
MemoNode.content.ilike(f"%{query}%")
)
)
# 권한 필터링 - 사용자의 트리에 속한 노드만
query_obj = query_obj.join(MemoTree).where(MemoTree.user_id == current_user.id)
result = await db.execute(query_obj)
nodes = result.scalars().all()
search_results = []
for node in nodes:
# 관련성 점수 계산
score = 1.0
if query.lower() in node.title.lower():
score += 2.0
if node.content and query.lower() in node.content.lower():
score += 1.0
search_results.append(SearchResult(
type="memo",
id=str(node.id),
title=node.title,
content=node.content or "",
document_id=str(node.tree.id), # 트리 ID를 문서 ID로 사용
document_title=f"📚 {node.tree.title}",
created_at=node.created_at,
relevance_score=score
))
return search_results
async def search_document_content(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""문서 본문 내용 검색 (OCR 데이터 포함)"""
# 문서 권한 확인
doc_query = select(Document)
if not current_user.is_admin:
doc_query = doc_query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
if document_id:
doc_query = doc_query.where(Document.id == document_id)
result = await db.execute(doc_query)
documents = result.scalars().all()
search_results = []
for doc in documents:
# HTML 파일에서 텍스트 검색
if doc.html_path:
try:
import os
from bs4 import BeautifulSoup
html_file_path = os.path.join("/app/data/documents", doc.html_path)
if os.path.exists(html_file_path):
with open(html_file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# HTML에서 텍스트 추출
soup = BeautifulSoup(html_content, 'html.parser')
text_content = soup.get_text()
# 검색어가 포함된 경우
if query.lower() in text_content.lower():
# 검색어 주변 컨텍스트 추출
context = extract_search_context(text_content, query)
# 관련성 점수 계산
score = 2.0 # 본문 매치는 높은 점수
search_results.append(SearchResult(
type="document_content",
id=str(doc.id),
title=f"📄 {doc.title} (본문)",
content=context,
document_id=str(doc.id),
document_title=doc.title,
created_at=doc.created_at,
relevance_score=score
))
except Exception as e:
print(f"문서 본문 검색 오류: {e}")
continue
return search_results
def extract_search_context(text: str, query: str, context_length: int = 200) -> str:
"""검색어 주변 컨텍스트 추출"""
text_lower = text.lower()
query_lower = query.lower()
# 첫 번째 매치 위치 찾기
match_pos = text_lower.find(query_lower)
if match_pos == -1:
return text[:context_length] + "..."
# 컨텍스트 시작/끝 위치 계산
start = max(0, match_pos - context_length // 2)
end = min(len(text), match_pos + len(query) + context_length // 2)
context = text[start:end]
# 앞뒤에 ... 추가
if start > 0:
context = "..." + context
if end < len(text):
context = context + "..."
return context

View File

@@ -27,6 +27,12 @@
</div> </div>
</div> </div>
<!-- 통합 검색 -->
<a href="search.html" class="nav-link" id="search-nav-link">
<i class="fas fa-search"></i>
<span>통합 검색</span>
</a>
<!-- 소설 관리 시스템 --> <!-- 소설 관리 시스템 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="memo-tree.html" class="nav-link" id="novel-nav-link"> <a href="memo-tree.html" class="nav-link" id="novel-nav-link">

495
frontend/search.html Normal file
View File

@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>통합 검색 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
[x-cloak] { display: none !important; }
.search-result-card {
transition: all 0.3s ease;
}
.search-result-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.highlight-text {
background: linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%);
color: #92400e;
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.search-filter-chip {
transition: all 0.2s ease;
}
.search-filter-chip.active {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.search-stats {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
border-left: 4px solid #3b82f6;
}
.result-type-badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-document {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.badge-note {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
.badge-memo {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
}
.badge-highlight {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.badge-highlight_note {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: white;
}
.badge-document_content {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
color: white;
}
.search-input-container {
position: relative;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 2px solid #e5e7eb;
transition: all 0.3s ease;
}
.search-input-container:focus-within {
border-color: #3b82f6;
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.2);
transform: translateY(-2px);
}
.search-input {
background: transparent;
border: none;
outline: none;
font-size: 18px;
padding: 20px 60px 20px 24px;
width: 100%;
border-radius: 16px;
}
.search-button {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
border-radius: 12px;
padding: 12px 16px;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.search-button:hover {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.empty-state {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 2px dashed #cbd5e1;
border-radius: 16px;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="searchApp()" x-init="init()" x-cloak>
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
<i class="fas fa-search text-blue-600 mr-3"></i>
통합 검색
</h1>
<p class="text-xl text-gray-600">문서, 노트, 메모를 한 번에 검색하세요</p>
</div>
<!-- 검색 입력 -->
<div class="max-w-4xl mx-auto mb-8">
<form @submit.prevent="performSearch()">
<div class="search-input-container">
<input
type="text"
x-model="searchQuery"
placeholder="검색어를 입력하세요..."
class="search-input"
@input="debounceSearch()"
>
<button type="submit" class="search-button" :disabled="loading">
<i class="fas fa-search" :class="{'loading-spinner': loading}"></i>
</button>
</div>
</form>
</div>
<!-- 검색 필터 -->
<div class="max-w-4xl mx-auto mb-8" x-show="searchResults.length > 0 || hasSearched">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex flex-wrap items-center gap-4">
<!-- 타입 필터 -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">타입:</span>
<button
@click="typeFilter = ''"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === '' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
전체
</button>
<button
@click="typeFilter = 'document'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'document' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-alt mr-1"></i>문서
</button>
<button
@click="typeFilter = 'note'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-sticky-note mr-1"></i>노트
</button>
<button
@click="typeFilter = 'memo'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'memo' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-tree mr-1"></i>메모
</button>
<button
@click="typeFilter = 'highlight'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'highlight' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-highlighter mr-1"></i>하이라이트
</button>
<button
@click="typeFilter = 'highlight_note'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'highlight_note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-comment mr-1"></i>메모
</button>
<button
@click="typeFilter = 'document_content'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'document_content' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-text mr-1"></i>본문
</button>
</div>
<!-- 정렬 -->
<div class="flex items-center space-x-2 ml-auto">
<span class="text-sm font-medium text-gray-700">정렬:</span>
<select x-model="sortBy" @change="applyFilters()"
class="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="relevance">관련도순</option>
<option value="date_desc">최신순</option>
<option value="date_asc">오래된순</option>
<option value="title">제목순</option>
</select>
</div>
</div>
</div>
</div>
<!-- 검색 통계 -->
<div class="max-w-4xl mx-auto mb-6" x-show="searchResults.length > 0">
<div class="search-stats rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-gray-700">
<strong x-text="filteredResults.length"></strong>개 결과
<span x-show="searchQuery" class="text-gray-500">
"<span x-text="searchQuery"></span>" 검색
</span>
</span>
<div class="flex items-center space-x-3 text-xs text-gray-500">
<span x-show="getResultCount('document') > 0">
📄 문서 <strong x-text="getResultCount('document')"></strong>
</span>
<span x-show="getResultCount('note') > 0">
📝 노트 <strong x-text="getResultCount('note')"></strong>
</span>
<span x-show="getResultCount('memo') > 0">
🌳 메모 <strong x-text="getResultCount('memo')"></strong>
</span>
<span x-show="getResultCount('highlight') > 0">
🖍️ 하이라이트 <strong x-text="getResultCount('highlight')"></strong>
</span>
</div>
</div>
<div class="text-xs text-gray-500">
<i class="fas fa-clock mr-1"></i>
<span x-text="searchTime"></span>ms
</div>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="max-w-4xl mx-auto text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
<p class="text-gray-600">검색 중...</p>
</div>
<!-- 검색 결과 -->
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
<template x-for="result in filteredResults" :key="result.id">
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
<!-- 결과 헤더 -->
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="result-type-badge"
:class="`badge-${result.type}`"
x-text="getTypeLabel(result.type)"></span>
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
<i class="fas fa-star text-yellow-500 mr-1"></i>
<span x-text="Math.round(result.relevance_score * 100) + '%'"></span>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2 cursor-pointer hover:text-blue-600"
@click="openResult(result)"
x-html="highlightText(result.title, searchQuery)"></h3>
<p class="text-sm text-gray-600 mb-2" x-text="result.document_title"></p>
</div>
<div class="ml-4 flex space-x-2">
<button @click="showPreview(result)"
class="px-3 py-1 bg-gray-600 text-white rounded-lg text-sm hover:bg-gray-700 transition-colors">
<i class="fas fa-eye mr-1"></i>미리보기
</button>
<button @click="openResult(result)"
class="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors">
<i class="fas fa-external-link-alt mr-1"></i>열기
</button>
</div>
</div>
<!-- 결과 내용 -->
<div class="text-gray-700 text-sm leading-relaxed"
x-html="highlightText(truncateText(result.content, 200), searchQuery)"></div>
<!-- 하이라이트 정보 -->
<div x-show="result.type === 'highlight' && result.highlight_info" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="text-xs text-yellow-800 mb-1">
<i class="fas fa-highlighter mr-1"></i>하이라이트 정보
</div>
<div class="text-sm text-yellow-900" x-show="result.highlight_info?.selected_text">
"<span x-text="result.highlight_info?.selected_text"></span>"
</div>
</div>
</div>
</template>
</div>
<!-- 빈 검색 결과 -->
<div x-show="!loading && hasSearched && filteredResults.length === 0" class="max-w-4xl mx-auto">
<div class="empty-state text-center py-16">
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색 결과가 없습니다</h3>
<p class="text-gray-500 mb-6">
<span x-show="searchQuery">
"<span x-text="searchQuery"></span>"에 대한 결과를 찾을 수 없습니다.
</span>
<span x-show="!searchQuery">검색어를 입력해주세요.</span>
</p>
<div class="text-sm text-gray-500">
<p class="mb-2">검색 팁:</p>
<ul class="text-left inline-block space-y-1">
<li>• 다른 키워드로 검색해보세요</li>
<li>• 검색어를 줄여보세요</li>
<li>• 필터를 변경해보세요</li>
</ul>
</div>
</div>
</div>
<!-- 초기 상태 -->
<div x-show="!loading && !hasSearched" class="max-w-4xl mx-auto">
<div class="text-center py-16">
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색을 시작하세요</h3>
<p class="text-gray-500 mb-8">문서, 노트, 메모, 하이라이트를 통합 검색할 수 있습니다</p>
<!-- 빠른 검색 예시 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-2xl mx-auto">
<button @click="searchQuery = '설계'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-blue-500 hover:bg-blue-50 transition-colors">
<i class="fas fa-drafting-compass text-blue-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">설계</div>
</button>
<button @click="searchQuery = '연구'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-green-500 hover:bg-green-50 transition-colors">
<i class="fas fa-flask text-green-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">연구</div>
</button>
<button @click="searchQuery = '프로젝트'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-purple-500 hover:bg-purple-50 transition-colors">
<i class="fas fa-project-diagram text-purple-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">프로젝트</div>
</button>
<button @click="searchQuery = '분석'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-orange-500 hover:bg-orange-50 transition-colors">
<i class="fas fa-chart-line text-orange-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">분석</div>
</button>
</div>
</div>
</div>
</div>
<!-- 미리보기 모달 -->
<div x-show="showPreviewModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between p-6 border-b">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="result-type-badge"
:class="`badge-${previewResult?.type}`"
x-text="getTypeLabel(previewResult?.type)"></span>
<span class="text-sm text-gray-500" x-text="formatDate(previewResult?.created_at)"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900" x-text="previewResult?.title"></h3>
<p class="text-sm text-gray-600" x-text="previewResult?.document_title"></p>
</div>
<div class="flex items-center space-x-2">
<button @click="openResult(previewResult)"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-external-link-alt mr-2"></i>열기
</button>
<button @click="closePreview()"
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 모달 내용 -->
<div class="p-6 overflow-y-auto max-h-[60vh]">
<!-- 하이라이트 정보 -->
<div x-show="previewResult?.type === 'highlight' && previewResult?.highlight_info"
class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="text-sm font-medium text-yellow-800 mb-2">
<i class="fas fa-highlighter mr-2"></i>하이라이트된 텍스트
</div>
<div class="text-yellow-900 font-medium mb-2"
x-text="previewResult?.highlight_info?.selected_text"></div>
<div x-show="previewResult?.highlight_info?.note_content" class="text-sm text-yellow-800">
<strong>메모:</strong> <span x-text="previewResult?.highlight_info?.note_content"></span>
</div>
</div>
<!-- 메모 내용 -->
<div x-show="previewResult?.type === 'highlight_note' && previewResult?.highlight_info"
class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="text-sm font-medium text-blue-800 mb-2">
<i class="fas fa-quote-left mr-2"></i>원본 하이라이트
</div>
<div class="text-blue-900 mb-2" x-text="previewResult?.highlight_info?.selected_text"></div>
<div class="text-sm font-medium text-blue-800 mb-1">메모 내용:</div>
</div>
<!-- 본문 내용 -->
<div class="prose max-w-none">
<div class="text-gray-700 leading-relaxed whitespace-pre-wrap"
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
</div>
<!-- 로딩 상태 -->
<div x-show="previewLoading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-600">내용을 불러오는 중...</p>
</div>
</div>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/search.js?v=2025012610"></script>
</body>
</html>

View File

@@ -124,7 +124,11 @@ class HeaderLoader {
'index': 'index-nav-item', 'index': 'index-nav-item',
'hierarchy': 'hierarchy-nav-item', 'hierarchy': 'hierarchy-nav-item',
'memo-tree': 'memo-tree-nav-item', 'memo-tree': 'memo-tree-nav-item',
'story-view': 'story-view-nav-item' 'story-view': 'story-view-nav-item',
'search': 'search-nav-link',
'notes': 'notes-nav-link',
'notebooks': 'notebooks-nav-item',
'note-editor': 'note-editor-nav-item'
}; };
const itemId = pageItemMap[pageInfo.filename]; const itemId = pageItemMap[pageInfo.filename];

View File

@@ -0,0 +1,352 @@
/**
* 통합 검색 JavaScript
*/
// 검색 애플리케이션 Alpine.js 컴포넌트
window.searchApp = function() {
return {
// 상태 관리
searchQuery: '',
searchResults: [],
filteredResults: [],
loading: false,
hasSearched: false,
searchTime: 0,
// 필터링
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
// 검색 디바운스
searchTimeout: null,
// 미리보기 모달
showPreviewModal: false,
previewResult: null,
previewLoading: false,
// 인증 상태
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
// 초기화
async init() {
console.log('🔍 검색 앱 초기화 시작');
try {
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
// 헤더 로드
await this.loadHeader();
// 인증 상태 확인
await this.checkAuthStatus();
// URL 파라미터에서 검색어 확인
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (query) {
this.searchQuery = query;
await this.performSearch();
}
console.log('✅ 검색 앱 초기화 완료');
} catch (error) {
console.error('❌ 검색 앱 초기화 실패:', error);
}
},
// 인증 상태 확인
async checkAuthStatus() {
try {
const user = await this.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
console.log('✅ 인증됨:', user.username || user.email);
} catch (error) {
console.log('❌ 인증되지 않음');
this.isAuthenticated = false;
this.currentUser = null;
// 검색은 로그인 없이도 가능하도록 허용
}
},
// 헤더 로드
async loadHeader() {
try {
if (typeof loadHeaderComponent === 'function') {
await loadHeaderComponent();
} else if (typeof window.loadHeaderComponent === 'function') {
await window.loadHeaderComponent();
} else {
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
}
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// 검색 디바운스
debounceSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
if (this.searchQuery.trim()) {
this.performSearch();
}
}, 500);
},
// 검색 수행
async performSearch() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = false;
return;
}
this.loading = true;
const startTime = Date.now();
try {
console.log('🔍 검색 시작:', this.searchQuery);
// 검색 API 호출
const response = await this.api.search({
q: this.searchQuery,
type_filter: this.typeFilter || undefined,
limit: 50
});
this.searchResults = response.results || [];
this.hasSearched = true;
this.searchTime = Date.now() - startTime;
// 필터 적용
this.applyFilters();
// URL 업데이트
this.updateURL();
console.log('✅ 검색 완료:', this.searchResults.length, '개 결과');
} catch (error) {
console.error('❌ 검색 실패:', error);
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = true;
} finally {
this.loading = false;
}
},
// 필터 적용
applyFilters() {
let results = [...this.searchResults];
// 타입 필터
if (this.typeFilter) {
results = results.filter(result => result.type === this.typeFilter);
}
// 정렬
results.sort((a, b) => {
switch (this.sortBy) {
case 'relevance':
return (b.relevance_score || 0) - (a.relevance_score || 0);
case 'date_desc':
return new Date(b.created_at) - new Date(a.created_at);
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'title':
return a.title.localeCompare(b.title);
default:
return 0;
}
});
this.filteredResults = results;
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
},
// URL 업데이트
updateURL() {
const url = new URL(window.location);
if (this.searchQuery.trim()) {
url.searchParams.set('q', this.searchQuery);
} else {
url.searchParams.delete('q');
}
window.history.replaceState({}, '', url);
},
// 미리보기 표시
async showPreview(result) {
console.log('👁️ 미리보기 표시:', result);
this.previewResult = result;
this.showPreviewModal = true;
this.previewLoading = true;
try {
// 추가 내용 로드 (필요한 경우)
if (result.type === 'document' || result.type === 'note') {
// 전체 내용 로드
const fullContent = await this.loadFullContent(result);
if (fullContent) {
this.previewResult = { ...result, content: fullContent };
}
}
} catch (error) {
console.error('미리보기 로드 실패:', error);
} finally {
this.previewLoading = false;
}
},
// 전체 내용 로드
async loadFullContent(result) {
try {
let content = '';
switch (result.type) {
case 'document':
// 문서 내용 API 호출
const docContent = await this.api.get(`/documents/${result.document_id}/content`);
content = docContent;
break;
case 'note':
// 노트 내용 API 호출
const noteContent = await this.api.get(`/note-documents/${result.id}/content`);
content = noteContent;
break;
default:
content = result.content;
}
return content;
} catch (error) {
console.error('내용 로드 실패:', error);
return result.content;
}
},
// 미리보기 닫기
closePreview() {
this.showPreviewModal = false;
this.previewResult = null;
this.previewLoading = false;
},
// 검색 결과 열기
openResult(result) {
console.log('📂 검색 결과 열기:', result);
let url = '';
switch (result.type) {
case 'document':
case 'document_content':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
// 하이라이트 위치로 이동
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
case 'note':
url = `/viewer.html?id=${result.id}&contentType=note`;
break;
case 'memo':
// 메모 트리에서 해당 노드로 이동
url = `/memo-tree.html?node_id=${result.id}`;
break;
case 'highlight':
case 'highlight_note':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
default:
console.warn('알 수 없는 결과 타입:', result.type);
return;
}
// 새 탭에서 열기
window.open(url, '_blank');
},
// 타입별 결과 개수
getResultCount(type) {
return this.searchResults.filter(result => result.type === type).length;
},
// 타입 라벨
getTypeLabel(type) {
const labels = {
document: '문서',
document_content: '본문',
note: '노트',
memo: '메모',
highlight: '하이라이트',
highlight_note: '메모'
};
return labels[type] || type;
},
// 텍스트 하이라이트
highlightText(text, query) {
if (!text || !query) return text;
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
return text.replace(regex, '<span class="highlight-text">$1</span>');
},
// 정규식 이스케이프
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
// 텍스트 자르기
truncateText(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return '오늘';
} else if (diffDays === 2) {
return '어제';
} else if (diffDays <= 7) {
return `${diffDays - 1}일 전`;
} else if (diffDays <= 30) {
return `${Math.ceil(diffDays / 7)}주 전`;
} else if (diffDays <= 365) {
return `${Math.ceil(diffDays / 30)}개월 전`;
} else {
return date.toLocaleDateString('ko-KR');
}
}
};
};
console.log('🔍 검색 JavaScript 로드 완료');