feat: 플로팅 메모창 구현 및 문서 삭제 기능 안정화

 새로운 기능:
- 플로팅 메모창: 드래그 가능한 독립적인 메모/하이라이트 창
- 크기 조절: 마우스로 창 크기 자유롭게 조정
- 위치 이동: 헤더 드래그로 원하는 위치에 배치
- 메모 검색: 실시간 메모 내용 검색 및 필터링
- 하이라이트 이동: 메모창에서 문서 내 하이라이트 위치로 스크롤

🛠️ 개선사항:
- 문서 삭제 안정화: 외래키 제약조건 해결 (Note->Highlight->Document 순서)
- 레이아웃 개선: 문서 뷰어가 전체 화면 사용 가능
- 사용자 경험: 사이드바 대신 플로팅 윈도우로 더 유연한 UI
- 캐시 버스팅: JavaScript 파일 버전 관리 개선

🐛 버그 수정:
- 중복 스크립트 로드 문제 해결
- 하이라이트 메모 업데이트 API 오류 수정
- 문서 삭제 시 500 에러 해결
This commit is contained in:
Hyungi Ahn
2025-08-23 15:00:01 +09:00
parent 46546da55f
commit c7f55ac50d
6 changed files with 362 additions and 30 deletions

View File

@@ -506,25 +506,55 @@ async def delete_document(
if document.thumbnail_path and os.path.exists(document.thumbnail_path): if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(document.thumbnail_path) os.remove(document.thumbnail_path)
# 관련 데이터 먼저 삭제 (외래키 제약 조건 해결) # 관련 데이터 안전하게 삭제 (외래키 제약 조건 해결)
from ...models.highlight import Highlight from ...models.highlight import Highlight
from ...models.note import Note from ...models.note import Note
from ...models.bookmark import Bookmark from ...models.bookmark import Bookmark
# 메모 먼저 삭제 (하이라이트를 참조하므로) try:
await db.execute(delete(Note).where(Note.document_id == document_id)) print(f"DEBUG: Starting deletion of document {document_id}")
# 북마크 삭제 # 1. 먼저 해당 문서의 모든 하이라이트 ID 조회
await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id)) highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id))
highlight_ids = [row[0] for row in highlight_ids_result.fetchall()]
print(f"DEBUG: Found {len(highlight_ids)} highlights to delete")
# 마지막으로 하이라이트 삭제 # 2. 하이라이트에 연결된 모든 메모 삭제
await db.execute(delete(Highlight).where(Highlight.document_id == document_id)) total_notes_deleted = 0
for highlight_id in highlight_ids:
note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id))
total_notes_deleted += note_result.rowcount
print(f"DEBUG: Deleted {total_notes_deleted} notes by highlight_id")
# 문서-태그 관계 삭제 (Document.tags 관계를 통해 자동 처리됨) # 3. document_id로 직접 연결된 메모도 삭제 (혹시 있다면)
direct_note_result = await db.execute(delete(Note).where(Note.document_id == document_id))
print(f"DEBUG: Deleted {direct_note_result.rowcount} notes by document_id")
# 마지막으로 문서 삭제 # 4. 북마크 삭제
await db.execute(delete(Document).where(Document.id == document_id)) bookmark_result = await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id))
await db.commit() print(f"DEBUG: Deleted {bookmark_result.rowcount} bookmarks")
# 5. 하이라이트 삭제 (이제 메모가 모두 삭제되었으므로 안전)
highlight_result = await db.execute(delete(Highlight).where(Highlight.document_id == document_id))
print(f"DEBUG: Deleted {highlight_result.rowcount} highlights")
# 6. 문서-태그 관계는 SQLAlchemy가 자동으로 처리
# 7. 마지막으로 문서 삭제
doc_result = await db.execute(delete(Document).where(Document.id == document_id))
print(f"DEBUG: Deleted {doc_result.rowcount} documents")
# 8. 커밋
await db.commit()
print(f"DEBUG: Successfully deleted document {document_id}")
except Exception as e:
print(f"ERROR: Failed to delete document {document_id}: {e}")
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete document: {str(e)}"
)
return {"message": "Document deleted successfully"} return {"message": "Document deleted successfully"}

View File

@@ -381,9 +381,30 @@ async def delete_highlight(
detail="Not enough permissions" detail="Not enough permissions"
) )
# 하이라이트 삭제 (CASCADE로 메모도 함께 삭제) # 안전한 하이라이트 삭제 (연결된 메모 먼저 삭제)
await db.execute(delete(Highlight).where(Highlight.id == highlight_id)) try:
await db.commit() print(f"DEBUG: Starting deletion of highlight {highlight_id}")
# 1. 먼저 연결된 메모 삭제
from ...models.note import Note
note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id))
print(f"DEBUG: Deleted {note_result.rowcount} notes for highlight {highlight_id}")
# 2. 하이라이트 삭제
highlight_result = await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
print(f"DEBUG: Deleted {highlight_result.rowcount} highlights")
# 3. 커밋
await db.commit()
print(f"DEBUG: Successfully deleted highlight {highlight_id}")
except Exception as e:
print(f"ERROR: Failed to delete highlight {highlight_id}: {e}")
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete highlight: {str(e)}"
)
return {"message": "Highlight deleted successfully"} return {"message": "Highlight deleted successfully"}

View File

@@ -144,7 +144,7 @@
<!-- 메인 컨테이너 --> <!-- 메인 컨테이너 -->
<div class="flex h-screen pt-16"> <div class="flex h-screen pt-16">
<!-- 사이드바 --> <!-- 왼쪽 사이드바 (항상 표시) -->
<aside class="w-80 bg-white shadow-lg border-r flex flex-col"> <aside class="w-80 bg-white shadow-lg border-r flex flex-col">
<!-- 사이드바 헤더 --> <!-- 사이드바 헤더 -->
<div class="p-4 border-b bg-gray-50"> <div class="p-4 border-b bg-gray-50">
@@ -369,8 +369,8 @@
<!-- 메인 콘텐츠 영역 --> <!-- 메인 콘텐츠 영역 -->
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<!-- 콘텐츠 헤더 --> <!-- 콘텐츠 헤더 -->
<div class="bg-white border-b p-4"> <div class="bg-white border-b p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900" x-text="selectedDocument ? selectedDocument.title : '문서를 선택하세요'"></h2> <h2 class="text-lg font-semibold text-gray-900" x-text="selectedDocument ? selectedDocument.title : '문서를 선택하세요'"></h2>
@@ -378,6 +378,17 @@
</div> </div>
<div x-show="selectedDocument" class="flex space-x-2"> <div x-show="selectedDocument" class="flex space-x-2">
<!-- 플로팅 메모창 토글 -->
<button
@click="showFloatingMemo = !showFloatingMemo"
class="px-3 py-2 rounded transition-colors"
:class="showFloatingMemo ? 'bg-blue-500 text-white' : 'text-blue-600 border border-blue-600 hover:bg-blue-50'"
title="메모 & 하이라이트 창"
>
<i class="fas fa-window-restore mr-1"></i>
메모창 (<span x-text="notes.length + highlights.length"></span>)
</button>
<button <button
@click="toggleHighlightMode()" @click="toggleHighlightMode()"
class="px-3 py-2 rounded transition-colors" class="px-3 py-2 rounded transition-colors"
@@ -441,9 +452,154 @@
<button onclick="window.hierarchyInstance.createHighlight('red')" class="w-8 h-8 bg-red-300 rounded hover:bg-red-400" title="빨강"></button> <button onclick="window.hierarchyInstance.createHighlight('red')" class="w-8 h-8 bg-red-300 rounded hover:bg-red-400" title="빨강"></button>
</div> </div>
</div> </div>
</div>
</main> </main>
</div> </div>
<!-- 플로팅 메모창 -->
<div
x-show="showFloatingMemo && selectedDocument"
class="fixed bg-white rounded-lg shadow-2xl border z-40 flex flex-col"
:style="`left: ${floatingMemoPosition.x}px; top: ${floatingMemoPosition.y}px; width: ${floatingMemoSize.width}px; height: ${floatingMemoSize.height}px;`"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
>
<!-- 플로팅 창 헤더 (드래그 가능) -->
<div
class="flex items-center justify-between p-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-t-lg cursor-move"
@mousedown="startDragging($event)"
>
<div class="flex items-center space-x-2">
<i class="fas fa-window-restore"></i>
<h3 class="font-semibold">📝 메모 & 하이라이트</h3>
<div class="flex space-x-1">
<span class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded-full" x-text="`${notes.length}개 메모`"></span>
<span class="text-xs bg-white bg-opacity-20 px-2 py-1 rounded-full" x-text="`${highlights.length}개 하이라이트`"></span>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- 크기 조절 버튼들 -->
<button
@click="toggleFloatingMemoSize()"
class="text-white hover:bg-white hover:bg-opacity-20 p-1 rounded"
title="크기 조절"
>
<i class="fas fa-expand-arrows-alt text-sm"></i>
</button>
<!-- 닫기 버튼 -->
<button
@click="showFloatingMemo = false"
class="text-white hover:bg-white hover:bg-opacity-20 p-1 rounded"
title="닫기"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 메모 검색 -->
<div class="p-3 border-b bg-gray-50">
<div class="relative">
<input
type="text"
x-model="memoSearchQuery"
@input="filterMemos()"
placeholder="메모 검색..."
class="w-full pl-8 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<i class="fas fa-search absolute left-2.5 top-2.5 text-gray-400 text-sm"></i>
</div>
</div>
<!-- 메모 목록 -->
<div class="flex-1 overflow-y-auto p-3">
<div x-show="(filteredNotes.length === 0 && highlights.length === 0)" class="text-center text-gray-500 py-8">
<i class="fas fa-sticky-note text-3xl text-gray-300 mb-2"></i>
<p class="text-sm">아직 메모나 하이라이트가 없습니다.</p>
<p class="text-xs text-gray-400 mt-1">문서에서 텍스트를 선택하여 하이라이트를 만들어보세요.</p>
</div>
<!-- 하이라이트 섹션 -->
<div x-show="highlights.length > 0" class="mb-4">
<h4 class="font-medium text-gray-700 mb-3 flex items-center">
<i class="fas fa-highlighter text-yellow-500 mr-2"></i>
하이라이트 (<span x-text="highlights.length"></span>)
</h4>
<template x-for="highlight in highlights" :key="highlight.id">
<div class="mb-3 p-3 bg-gray-50 rounded-lg border-l-4" :class="{
'border-yellow-400': highlight.highlight_color === 'yellow',
'border-blue-400': highlight.highlight_color === 'blue',
'border-green-400': highlight.highlight_color === 'green',
'border-red-400': highlight.highlight_color === 'red'
}">
<div class="text-sm text-gray-600 mb-1" x-text="highlight.selected_text"></div>
<div x-show="highlight.notes && highlight.notes.content" class="text-xs text-gray-500 mt-2 p-2 bg-white rounded border">
<i class="fas fa-comment mr-1"></i>
<span x-text="highlight.notes.content"></span>
</div>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-400" x-text="new Date(highlight.created_at).toLocaleDateString()"></span>
<div class="flex space-x-1">
<button
@click="scrollToHighlight(highlight.id)"
class="text-xs text-blue-600 hover:text-blue-800 p-1"
title="하이라이트로 이동"
>
<i class="fas fa-arrow-right"></i>
</button>
<button
@click="deleteHighlight(highlight.id)"
class="text-xs text-red-600 hover:text-red-800 p-1"
title="삭제"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</template>
</div>
<!-- 독립 메모 섹션 -->
<div x-show="filteredNotes.length > 0">
<h4 class="font-medium text-gray-700 mb-3 flex items-center">
<i class="fas fa-sticky-note text-blue-500 mr-2"></i>
독립 메모 (<span x-text="filteredNotes.length"></span>)
</h4>
<template x-for="note in filteredNotes" :key="note.id">
<div class="mb-3 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-400">
<div class="text-sm text-gray-700" x-text="note.content"></div>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-400" x-text="new Date(note.created_at).toLocaleDateString()"></span>
<button
@click="deleteNote(note.id)"
class="text-xs text-red-600 hover:text-red-800 p-1"
title="삭제"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
</div>
<!-- 크기 조절 핸들 -->
<div
class="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-gray-300 hover:bg-gray-400"
@mousedown="startResizing($event)"
title="크기 조절"
>
<i class="fas fa-grip-lines text-xs text-gray-600 absolute bottom-0.5 right-0.5"></i>
</div>
</div>
<!-- 로그인 모달 --> <!-- 로그인 모달 -->
<div x-data="authModal()" x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div x-data="authModal()" x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4"> <div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">

View File

@@ -458,10 +458,7 @@
<!-- 인증 모달 컴포넌트 --> <!-- 인증 모달 컴포넌트 -->
<div x-data="authModal" x-ref="authModal"></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> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
@@ -480,8 +477,8 @@
</style> </style>
<!-- JavaScript 파일들 --> <!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012222"></script> <script src="/static/js/api.js?v=2025012225"></script>
<script src="/static/js/auth.js?v=2025012222"></script> <script src="/static/js/auth.js?v=2025012225"></script>
<script src="/static/js/main.js?v=2025012222"></script> <script src="/static/js/main.js?v=2025012225"></script>
</body> </body>
</html> </html>

View File

@@ -30,6 +30,18 @@ window.hierarchyApp = function() {
highlightMenuPosition: { x: 0, y: 0 }, highlightMenuPosition: { x: 0, y: 0 },
highlightMode: false, // 하이라이트 모드 토글 highlightMode: false, // 하이라이트 모드 토글
// 메모 사이드바
memoSearchQuery: '',
filteredNotes: [],
// 플로팅 메모창
showFloatingMemo: false,
floatingMemoPosition: { x: 100, y: 100 },
floatingMemoSize: { width: 400, height: 500 },
isDragging: false,
isResizing: false,
dragOffset: { x: 0, y: 0 },
// 드래그 앤 드롭 // 드래그 앤 드롭
draggedDocument: null, draggedDocument: null,
@@ -193,6 +205,9 @@ window.hierarchyApp = function() {
this.highlights = highlights || []; this.highlights = highlights || [];
this.notes = notes || []; this.notes = notes || [];
// 메모 사이드바용 필터링된 노트 초기화
this.filterMemos();
console.log(`📝 최종 저장: 하이라이트 ${this.highlights.length}개, 메모 ${this.notes.length}`); console.log(`📝 최종 저장: 하이라이트 ${this.highlights.length}개, 메모 ${this.notes.length}`);
if (this.highlights.length > 0) { if (this.highlights.length > 0) {
@@ -1108,6 +1123,119 @@ window.authModal = function() {
resetLoginForm() { resetLoginForm() {
this.loginForm = { email: '', password: '' }; this.loginForm = { email: '', password: '' };
this.loginError = null; this.loginError = null;
},
// 메모 필터링
filterMemos() {
console.log('🔍 메모 필터링 시작:', {
totalNotes: this.notes.length,
searchQuery: this.memoSearchQuery,
notes: this.notes
});
if (!this.memoSearchQuery.trim()) {
this.filteredNotes = this.notes.filter(note => !note.highlight_id);
console.log('📝 독립 메모 필터링 결과:', this.filteredNotes.length, this.filteredNotes);
return;
}
const query = this.memoSearchQuery.toLowerCase();
this.filteredNotes = this.notes
.filter(note => !note.highlight_id) // 독립 메모만
.filter(note => note.content.toLowerCase().includes(query));
console.log('🔍 검색 결과:', this.filteredNotes.length, this.filteredNotes);
},
// 하이라이트로 스크롤
scrollToHighlight(highlightId) {
const highlightElement = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightElement) {
highlightElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// 잠시 강조 효과
highlightElement.style.boxShadow = '0 0 10px rgba(59, 130, 246, 0.5)';
setTimeout(() => {
highlightElement.style.boxShadow = '';
}, 2000);
}
},
// 플로팅 윈도우 드래그 시작
startDragging(event) {
this.isDragging = true;
this.dragOffset.x = event.clientX - this.floatingMemoPosition.x;
this.dragOffset.y = event.clientY - this.floatingMemoPosition.y;
document.addEventListener('mousemove', this.handleDragging.bind(this));
document.addEventListener('mouseup', this.stopDragging.bind(this));
event.preventDefault();
},
// 드래그 중
handleDragging(event) {
if (!this.isDragging) return;
const newX = event.clientX - this.dragOffset.x;
const newY = event.clientY - this.dragOffset.y;
// 화면 경계 제한
const maxX = window.innerWidth - this.floatingMemoSize.width;
const maxY = window.innerHeight - this.floatingMemoSize.height;
this.floatingMemoPosition.x = Math.max(0, Math.min(newX, maxX));
this.floatingMemoPosition.y = Math.max(0, Math.min(newY, maxY));
},
// 드래그 종료
stopDragging() {
this.isDragging = false;
document.removeEventListener('mousemove', this.handleDragging.bind(this));
document.removeEventListener('mouseup', this.stopDragging.bind(this));
},
// 리사이즈 시작
startResizing(event) {
this.isResizing = true;
document.addEventListener('mousemove', this.handleResizing.bind(this));
document.addEventListener('mouseup', this.stopResizing.bind(this));
event.preventDefault();
event.stopPropagation();
},
// 리사이즈 중
handleResizing(event) {
if (!this.isResizing) return;
const rect = event.target.closest('.fixed').getBoundingClientRect();
const newWidth = event.clientX - rect.left;
const newHeight = event.clientY - rect.top;
// 최소/최대 크기 제한
this.floatingMemoSize.width = Math.max(300, Math.min(newWidth, 800));
this.floatingMemoSize.height = Math.max(200, Math.min(newHeight, 600));
},
// 리사이즈 종료
stopResizing() {
this.isResizing = false;
document.removeEventListener('mousemove', this.handleResizing.bind(this));
document.removeEventListener('mouseup', this.stopResizing.bind(this));
},
// 플로팅 메모창 크기 토글
toggleFloatingMemoSize() {
if (this.floatingMemoSize.width === 400) {
// 큰 크기로
this.floatingMemoSize.width = 600;
this.floatingMemoSize.height = 700;
} else {
// 기본 크기로
this.floatingMemoSize.width = 400;
this.floatingMemoSize.height = 500;
}
} }
}; };
}; };

View File

@@ -313,8 +313,8 @@
</div> </div>
<!-- 스크립트 --> <!-- 스크립트 -->
<script src="/static/js/api.js"></script> <script src="/static/js/api.js?v=2025012225"></script>
<script src="/static/js/viewer.js"></script> <script src="/static/js/viewer.js?v=2025012225"></script>
<style> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }