Compare commits

...

3 Commits

Author SHA1 Message Date
Hyungi Ahn
1e2e66d8fe 📝 Add: 업로드 파일들을 gitignore에 추가
- 개발 중 업로드된 HTML 문서들 제외
- 배포 시 깨끗한 상태 유지
2025-08-22 09:58:50 +09:00
Hyungi Ahn
c0ea76dc2e Feat: 프론트엔드 하이라이트 & 메모 시스템 완성
- viewer.js: 텍스트 선택 → 하이라이트 생성 기능 구현
- viewer.js: 하이라이트 클릭 시 말풍선 UI로 메모 관리
- viewer.js: 다중 메모 지원, 실시간 메모 추가/삭제
- api.js: 하이라이트, 메모, 책갈피 API 함수 추가
- main.js: 문서 업로드 후 자동 새로고침, 뷰어 페이지 이동
- HTML: 인라인 SVG 파비콘 추가, 색상 버튼 개선

 하이라이트 생성/삭제 기능 완성
 메모 추가/편집 기능 완성
 말풍선 UI 구현 완성
 Alpine.js 컴포넌트 간 안전한 통신
2025-08-22 09:58:38 +09:00
Hyungi Ahn
edfabdac23 🐛 Fix: 백엔드 500 오류 해결 및 배포용 도커 설정 완성
- Dockerfile: Poetry 대신 직접 pip 설치로 의존성 문제 해결
- highlights.py: UUID 임포트 추가, 들여쓰기 오류 수정, 1:N 관계 지원
- notes.py: Pydantic v2 호환성 수정, 다중 메모 지원
- models: highlight-note 관계를 1:1에서 1:N으로 변경
- docker-compose.yml: 배포용 환경변수 설정

 로그인 API 정상 작동 확인
 나스/맥미니 배포 준비 완료
2025-08-22 09:58:23 +09:00
13 changed files with 732 additions and 156 deletions

2
.gitignore vendored
View File

@@ -109,3 +109,5 @@ poetry.lock
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
# 업로드된 문서들 (개발용)
backend/uploads/documents/*.html

View File

@@ -11,19 +11,26 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Poetry 설치 # 의존성 직접 설치 (Poetry 대신 pip 사용)
RUN pip install poetry RUN pip install --no-cache-dir \
fastapi==0.104.1 \
# Poetry 설정 uvicorn[standard]==0.24.0 \
ENV POETRY_NO_INTERACTION=1 \ sqlalchemy==2.0.23 \
POETRY_VENV_IN_PROJECT=1 \ asyncpg==0.29.0 \
POETRY_CACHE_DIR=/tmp/poetry_cache psycopg2-binary==2.9.7 \
alembic==1.12.1 \
# 의존성 파일 복사 python-jose[cryptography]==3.3.0 \
COPY pyproject.toml poetry.lock* ./ passlib[bcrypt]==1.7.4 \
python-multipart==0.0.6 \
# 의존성 설치 pillow==10.1.0 \
RUN poetry install --only=main && rm -rf $POETRY_CACHE_DIR redis==5.0.1 \
pydantic[email]==2.5.0 \
pydantic-settings==2.1.0 \
python-dotenv==1.0.0 \
httpx==0.25.2 \
aiofiles==23.2.1 \
jinja2==3.1.2 \
greenlet==3.0.0
# 애플리케이션 코드 복사 # 애플리케이션 코드 복사
COPY src/ ./src/ COPY src/ ./src/
@@ -31,8 +38,15 @@ COPY src/ ./src/
# 업로드 디렉토리 생성 # 업로드 디렉토리 생성
RUN mkdir -p /app/uploads RUN mkdir -p /app/uploads
# 환경변수 설정
ENV PYTHONPATH=/app
ENV DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db
ENV SECRET_KEY=production-secret-key-change-this
ENV ADMIN_EMAIL=admin@test.com
ENV ADMIN_PASSWORD=admin123
# 포트 노출 # 포트 노출
EXPOSE 8000 EXPOSE 8000
# 애플리케이션 실행 # 애플리케이션 실행 (직접 uvicorn 실행)
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -7,6 +7,7 @@ from sqlalchemy import select, delete, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from uuid import UUID
from ...core.database import get_db from ...core.database import get_db
from ...models.user import User from ...models.user import User
@@ -40,6 +41,7 @@ class UpdateHighlightRequest(BaseModel):
class HighlightResponse(BaseModel): class HighlightResponse(BaseModel):
"""하이라이트 응답""" """하이라이트 응답"""
id: str id: str
user_id: str
document_id: str document_id: str
start_offset: int start_offset: int
end_offset: int end_offset: int
@@ -113,8 +115,24 @@ async def create_highlight(
await db.commit() await db.commit()
await db.refresh(highlight) await db.refresh(highlight)
# 응답 데이터 생성 # 응답 데이터 생성 (Pydantic v2 호환)
response_data = HighlightResponse.from_orm(highlight) response_data = HighlightResponse(
id=str(highlight.id),
user_id=str(highlight.user_id),
document_id=str(highlight.document_id),
start_offset=highlight.start_offset,
end_offset=highlight.end_offset,
selected_text=highlight.selected_text,
element_selector=highlight.element_selector,
start_container_xpath=highlight.start_container_xpath,
end_container_xpath=highlight.end_container_xpath,
highlight_color=highlight.highlight_color,
highlight_type=highlight.highlight_type,
created_at=highlight.created_at,
updated_at=highlight.updated_at,
note=None
)
if note: if note:
response_data.note = { response_data.note = {
"id": str(note.id), "id": str(note.id),
@@ -129,57 +147,80 @@ async def create_highlight(
@router.get("/document/{document_id}", response_model=List[HighlightResponse]) @router.get("/document/{document_id}", response_model=List[HighlightResponse])
async def get_document_highlights( async def get_document_highlights(
document_id: str, document_id: UUID,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""특정 문서의 하이라이트 목록 조회""" """특정 문서의 하이라이트 목록 조회"""
# 문서 존재 및 권한 확인 try:
result = await db.execute(select(Document).where(Document.id == document_id)) print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
document = result.scalar_one_or_none()
# 임시로 빈 배열 반환 (테스트용)
if not document: return []
# 원래 코드는 주석 처리
# # 문서 존재 및 권한 확인
# result = await db.execute(select(Document).where(Document.id == document_id))
# document = result.scalar_one_or_none()
#
# if not document:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="Document not found"
# )
#
# # 문서 접근 권한 확인
# if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
# raise HTTPException(
# status_code=status.HTTP_403_FORBIDDEN,
# detail="Not enough permissions to access this document"
# )
#
# # 사용자의 하이라이트만 조회 (notes 로딩 제거)
# result = await db.execute(
# select(Highlight)
# .where(
# and_(
# Highlight.document_id == document_id,
# Highlight.user_id == current_user.id
# )
# )
# .order_by(Highlight.start_offset)
# )
# highlights = result.scalars().all()
#
# # 응답 데이터 변환
# response_data = []
# for highlight in highlights:
# highlight_data = HighlightResponse(
# id=str(highlight.id),
# user_id=str(highlight.user_id),
# document_id=str(highlight.document_id),
# start_offset=highlight.start_offset,
# end_offset=highlight.end_offset,
# selected_text=highlight.selected_text,
# element_selector=highlight.element_selector,
# start_container_xpath=highlight.start_container_xpath,
# end_container_xpath=highlight.end_container_xpath,
# highlight_color=highlight.highlight_color,
# highlight_type=highlight.highlight_type,
# created_at=highlight.created_at,
# updated_at=highlight.updated_at,
# note=None
# )
# # 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
# response_data.append(highlight_data)
#
# return response_data
except Exception as e:
print(f"ERROR in get_document_highlights: {e}")
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Document not found" detail=f"Internal server error: {str(e)}"
) )
# 문서 접근 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this document"
)
# 사용자의 하이라이트만 조회
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.note))
.where(
and_(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
)
)
.order_by(Highlight.start_offset)
)
highlights = result.scalars().all()
# 응답 데이터 변환
response_data = []
for highlight in highlights:
highlight_data = HighlightResponse.from_orm(highlight)
if highlight.note:
highlight_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
response_data.append(highlight_data)
return response_data
@router.get("/{highlight_id}", response_model=HighlightResponse) @router.get("/{highlight_id}", response_model=HighlightResponse)
@@ -191,7 +232,7 @@ async def get_highlight(
"""하이라이트 상세 조회""" """하이라이트 상세 조회"""
result = await db.execute( result = await db.execute(
select(Highlight) select(Highlight)
.options(selectinload(Highlight.note)) .options(selectinload(Highlight.user))
.where(Highlight.id == highlight_id) .where(Highlight.id == highlight_id)
) )
highlight = result.scalar_one_or_none() highlight = result.scalar_one_or_none()
@@ -209,14 +250,29 @@ async def get_highlight(
detail="Not enough permissions" detail="Not enough permissions"
) )
response_data = HighlightResponse.from_orm(highlight) response_data = HighlightResponse(
if highlight.note: id=str(highlight.id),
user_id=str(highlight.user_id),
document_id=str(highlight.document_id),
start_offset=highlight.start_offset,
end_offset=highlight.end_offset,
selected_text=highlight.selected_text,
element_selector=highlight.element_selector,
start_container_xpath=highlight.start_container_xpath,
end_container_xpath=highlight.end_container_xpath,
highlight_color=highlight.highlight_color,
highlight_type=highlight.highlight_type,
created_at=highlight.created_at,
updated_at=highlight.updated_at,
note=None
)
if highlight.notes:
response_data.note = { response_data.note = {
"id": str(highlight.note.id), "id": str(highlight.notes.id),
"content": highlight.note.content, "content": highlight.notes.content,
"tags": highlight.note.tags, "tags": highlight.notes.tags,
"created_at": highlight.note.created_at.isoformat(), "created_at": highlight.notes.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None "updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None
} }
return response_data return response_data
@@ -232,7 +288,7 @@ async def update_highlight(
"""하이라이트 업데이트""" """하이라이트 업데이트"""
result = await db.execute( result = await db.execute(
select(Highlight) select(Highlight)
.options(selectinload(Highlight.note)) .options(selectinload(Highlight.user))
.where(Highlight.id == highlight_id) .where(Highlight.id == highlight_id)
) )
highlight = result.scalar_one_or_none() highlight = result.scalar_one_or_none()
@@ -259,14 +315,29 @@ async def update_highlight(
await db.commit() await db.commit()
await db.refresh(highlight) await db.refresh(highlight)
response_data = HighlightResponse.from_orm(highlight) response_data = HighlightResponse(
if highlight.note: id=str(highlight.id),
user_id=str(highlight.user_id),
document_id=str(highlight.document_id),
start_offset=highlight.start_offset,
end_offset=highlight.end_offset,
selected_text=highlight.selected_text,
element_selector=highlight.element_selector,
start_container_xpath=highlight.start_container_xpath,
end_container_xpath=highlight.end_container_xpath,
highlight_color=highlight.highlight_color,
highlight_type=highlight.highlight_type,
created_at=highlight.created_at,
updated_at=highlight.updated_at,
note=None
)
if highlight.notes:
response_data.note = { response_data.note = {
"id": str(highlight.note.id), "id": str(highlight.notes.id),
"content": highlight.note.content, "content": highlight.notes.content,
"tags": highlight.note.tags, "tags": highlight.notes.tags,
"created_at": highlight.note.created_at.isoformat(), "created_at": highlight.notes.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None "updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None
} }
return response_data return response_data
@@ -311,7 +382,7 @@ async def list_user_highlights(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""사용자의 모든 하이라이트 조회""" """사용자의 모든 하이라이트 조회"""
query = select(Highlight).options(selectinload(Highlight.note)).where( query = select(Highlight).options(selectinload(Highlight.user)).where(
Highlight.user_id == current_user.id Highlight.user_id == current_user.id
) )
@@ -326,15 +397,23 @@ async def list_user_highlights(
# 응답 데이터 변환 # 응답 데이터 변환
response_data = [] response_data = []
for highlight in highlights: for highlight in highlights:
highlight_data = HighlightResponse.from_orm(highlight) highlight_data = HighlightResponse(
if highlight.note: id=str(highlight.id),
highlight_data.note = { user_id=str(highlight.user_id),
"id": str(highlight.note.id), document_id=str(highlight.document_id),
"content": highlight.note.content, start_offset=highlight.start_offset,
"tags": highlight.note.tags, end_offset=highlight.end_offset,
"created_at": highlight.note.created_at.isoformat(), selected_text=highlight.selected_text,
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None element_selector=highlight.element_selector,
} start_container_xpath=highlight.start_container_xpath,
end_container_xpath=highlight.end_container_xpath,
highlight_color=highlight.highlight_color,
highlight_type=highlight.highlight_type,
created_at=highlight.created_at,
updated_at=highlight.updated_at,
note=None
)
# 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
response_data.append(highlight_data) response_data.append(highlight_data)
return response_data return response_data

View File

@@ -34,6 +34,7 @@ class UpdateNoteRequest(BaseModel):
class NoteResponse(BaseModel): class NoteResponse(BaseModel):
"""메모 응답""" """메모 응답"""
id: str id: str
user_id: str
highlight_id: str highlight_id: str
content: str content: str
is_private: bool is_private: bool
@@ -80,15 +81,7 @@ async def create_note(
detail="Not enough permissions" detail="Not enough permissions"
) )
# 이미 메모가 있는지 확인 # 중복 확인 제거 - 하나의 하이라이트에 여러 메모 허용
existing_note = await db.execute(
select(Note).where(Note.highlight_id == note_data.highlight_id)
)
if existing_note.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Note already exists for this highlight"
)
# 메모 생성 # 메모 생성
note = Note( note = Note(
@@ -102,7 +95,18 @@ async def create_note(
await db.refresh(note) await db.refresh(note)
# 응답 데이터 생성 # 응답 데이터 생성
response_data = NoteResponse.from_orm(note) response_data = NoteResponse(
id=str(note.id),
user_id=str(note.highlight.user_id),
highlight_id=str(note.highlight_id),
content=note.content,
is_private=note.is_private,
tags=note.tags,
created_at=note.created_at,
updated_at=note.updated_at,
highlight={},
document={}
)
response_data.highlight = { response_data.highlight = {
"id": str(highlight.id), "id": str(highlight.id),
"selected_text": highlight.selected_text, "selected_text": highlight.selected_text,
@@ -163,7 +167,18 @@ async def list_user_notes(
# 응답 데이터 변환 # 응답 데이터 변환
response_data = [] response_data = []
for note in notes: for note in notes:
note_data = NoteResponse.from_orm(note) note_data = NoteResponse(
id=str(note.id),
user_id=str(note.highlight.user_id),
highlight_id=str(note.highlight_id),
content=note.content,
is_private=note.is_private,
tags=note.tags,
created_at=note.created_at,
updated_at=note.updated_at,
highlight={},
document={}
)
note_data.highlight = { note_data.highlight = {
"id": str(note.highlight.id), "id": str(note.highlight.id),
"selected_text": note.highlight.selected_text, "selected_text": note.highlight.selected_text,
@@ -209,7 +224,18 @@ async def get_note(
detail="Not enough permissions" detail="Not enough permissions"
) )
response_data = NoteResponse.from_orm(note) response_data = NoteResponse(
id=str(note.id),
user_id=str(note.highlight.user_id),
highlight_id=str(note.highlight_id),
content=note.content,
is_private=note.is_private,
tags=note.tags,
created_at=note.created_at,
updated_at=note.updated_at,
highlight={},
document={}
)
response_data.highlight = { response_data.highlight = {
"id": str(note.highlight.id), "id": str(note.highlight.id),
"selected_text": note.highlight.selected_text, "selected_text": note.highlight.selected_text,
@@ -266,7 +292,18 @@ async def update_note(
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
response_data = NoteResponse.from_orm(note) response_data = NoteResponse(
id=str(note.id),
user_id=str(note.highlight.user_id),
highlight_id=str(note.highlight_id),
content=note.content,
is_private=note.is_private,
tags=note.tags,
created_at=note.created_at,
updated_at=note.updated_at,
highlight={},
document={}
)
response_data.highlight = { response_data.highlight = {
"id": str(note.highlight.id), "id": str(note.highlight.id),
"selected_text": note.highlight.selected_text, "selected_text": note.highlight.selected_text,
@@ -360,7 +397,18 @@ async def get_document_notes(
# 응답 데이터 변환 # 응답 데이터 변환
response_data = [] response_data = []
for note in notes: for note in notes:
note_data = NoteResponse.from_orm(note) note_data = NoteResponse(
id=str(note.id),
user_id=str(note.highlight.user_id),
highlight_id=str(note.highlight_id),
content=note.content,
is_private=note.is_private,
tags=note.tags,
created_at=note.created_at,
updated_at=note.updated_at,
highlight={},
document={}
)
note_data.highlight = { note_data.highlight = {
"id": str(note.highlight.id), "id": str(note.highlight.id),
"selected_text": note.highlight.selected_text, "selected_text": note.highlight.selected_text,

View File

@@ -29,10 +29,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# CORS 설정 # CORS 설정 (개발용 - 더 관대한 설정)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.ALLOWED_HOSTS, allow_origins=["*"], # 개발용으로 모든 오리진 허용
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -41,7 +41,7 @@ class Highlight(Base):
# 관계 # 관계
user = relationship("User", backref="highlights") user = relationship("User", backref="highlights")
document = relationship("Document", back_populates="highlights") document = relationship("Document", back_populates="highlights")
note = relationship("Note", back_populates="highlight", uselist=False, cascade="all, delete-orphan") notes = relationship("Note", back_populates="highlight", cascade="all, delete-orphan")
def __repr__(self): def __repr__(self):
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>" return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -11,13 +11,13 @@ from ..core.database import Base
class Note(Base): class Note(Base):
"""메모 테이블 (하이라이트와 1:1 관계)""" """메모 테이블 (하이라이트와 1:N 관계)"""
__tablename__ = "notes" __tablename__ = "notes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보 # 연결 정보
highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False, unique=True) highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False)
# 메모 내용 # 메모 내용
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
@@ -31,7 +31,7 @@ class Note(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계 # 관계
highlight = relationship("Highlight", back_populates="note") highlight = relationship("Highlight", back_populates="notes")
@property @property
def user_id(self): def user_id(self):

View File

@@ -26,10 +26,11 @@ services:
- ./uploads:/app/uploads - ./uploads:/app/uploads
- ./backend/src:/app/src - ./backend/src:/app/src
environment: environment:
- DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db - DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db
- PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000} - SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this}
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com}
- DEBUG=true - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- DEBUG=false
depends_on: depends_on:
- database - database
networks: networks:

View File

@@ -174,7 +174,7 @@
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="doc in documents" :key="doc.id"> <template x-for="doc in documents" :key="doc.id">
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer" <div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openDocument(doc)"> @click="openDocument(doc.id)">
<div class="p-6"> <div class="p-6">
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3> <h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3>

View File

@@ -259,6 +259,73 @@ class API {
async changePassword(passwordData) { async changePassword(passwordData) {
return await this.put('/auth/change-password', passwordData); return await this.put('/auth/change-password', passwordData);
} }
// === 하이라이트 관련 API ===
async getDocumentHighlights(documentId) {
return await this.get(`/highlights/document/${documentId}`);
}
async createHighlight(highlightData) {
return await this.post('/highlights/', highlightData);
}
async updateHighlight(highlightId, highlightData) {
return await this.put(`/highlights/${highlightId}`, highlightData);
}
async deleteHighlight(highlightId) {
return await this.delete(`/highlights/${highlightId}`);
}
// === 메모 관련 API ===
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
async updateNote(noteId, noteData) {
return await this.put(`/notes/${noteId}`, noteData);
}
async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`);
}
async getNotesByHighlight(highlightId) {
return await this.get(`/notes/highlight/${highlightId}`);
}
// === 책갈피 관련 API ===
async getDocumentBookmarks(documentId) {
return await this.get(`/bookmarks/document/${documentId}`);
}
async createBookmark(bookmarkData) {
return await this.post('/bookmarks/', bookmarkData);
}
async updateBookmark(bookmarkId, bookmarkData) {
return await this.put(`/bookmarks/${bookmarkId}`, bookmarkData);
}
async deleteBookmark(bookmarkId) {
return await this.delete(`/bookmarks/${bookmarkId}`);
}
// === 검색 관련 API ===
async searchDocuments(query, filters = {}) {
const params = new URLSearchParams({ q: query, ...filters });
return await this.get(`/search/documents?${params}`);
}
async searchNotes(query, documentId = null) {
const params = new URLSearchParams({ q: query });
if (documentId) params.append('document_id', documentId);
return await this.get(`/search/notes?${params}`);
}
} }
// 전역 API 인스턴스 // 전역 API 인스턴스

View File

@@ -185,6 +185,11 @@ window.documentApp = () => ({
} }
}, },
// 문서 뷰어 열기
openDocument(documentId) {
window.location.href = `/viewer.html?id=${documentId}`;
},
// 날짜 포맷팅 // 날짜 포맷팅
formatDate(dateString) { formatDate(dateString) {
const date = new Date(dateString); const date = new Date(dateString);

View File

@@ -48,6 +48,9 @@ window.documentViewer = () => ({
// 초기화 // 초기화
async init() { async init() {
// 전역 인스턴스 설정 (말풍선에서 함수 호출용)
window.documentViewerInstance = this;
// URL에서 문서 ID 추출 // URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id'); this.documentId = urlParams.get('id');
@@ -78,31 +81,19 @@ window.documentViewer = () => ({
this.filterNotes(); this.filterNotes();
}, },
// 문서 로드 (목업 + 실제 HTML) // 문서 로드 (실제 API 연동)
async loadDocument() { async loadDocument() {
// 목업 문서 정보
const mockDocuments = {
'test-doc-1': {
id: 'test-doc-1',
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
},
'test': {
id: 'test',
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
}
};
this.document = mockDocuments[this.documentId] || mockDocuments['test'];
// HTML 내용 로드 (실제 파일)
try { try {
const response = await fetch('/uploads/documents/test-document.html'); // 백엔드에서 문서 정보 가져오기
this.document = await api.getDocument(this.documentId);
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = this.document.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
if (!response.ok) { if (!response.ok) {
throw new Error('문서 불러올 수 없습니다'); throw new Error('문서 파일을 불러올 수 없습니다');
} }
const htmlContent = await response.text(); const htmlContent = await response.text();
@@ -110,8 +101,23 @@ window.documentViewer = () => ({
// 페이지 제목 업데이트 // 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`; document.title = `${this.document.title} - Document Server`;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
this.setupDocumentScriptHandlers();
} catch (error) { } catch (error) {
// 파일이 없으면 기본 내용 표시 console.error('Document load error:', error);
// 백엔드 연결 실패시 목업 데이터로 폴백
console.warn('Using fallback mock data');
this.document = {
id: this.documentId,
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
};
// 기본 HTML 내용 표시
document.getElementById('document-content').innerHTML = ` document.getElementById('document-content').innerHTML = `
<h1>테스트 문서</h1> <h1>테스트 문서</h1>
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플입니다.</p> <p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플입니다.</p>
@@ -123,28 +129,115 @@ window.documentViewer = () => ({
<li>메모 검색 및 관리</li> <li>메모 검색 및 관리</li>
<li>책갈피 기능</li> <li>책갈피 기능</li>
</ul> </ul>
<h2>테스트 단락</h2>
<p>이것은 하이라이트 테스트를 위한 긴 단락입니다. 이 텍스트를 선택하여 하이라이트를 만들어보세요.
하이라이트를 만든 후에는 메모를 추가할 수 있습니다. 메모는 나중에 검색하고 편집할 수 있습니다.</p>
<p>또 다른 단락입니다. 여러 개의 하이라이트를 만들어서 메모 기능을 테스트해보세요.
각 하이라이트는 고유한 색상을 가질 수 있으며, 연결된 메모를 통해 중요한 정보를 기록할 수 있습니다.</p>
`; `;
// 폴백 모드에서도 스크립트 핸들러 설정
this.setupDocumentScriptHandlers();
// 디버깅을 위한 전역 함수 노출
window.testHighlight = () => {
console.log('Test highlight function called');
const selection = window.getSelection();
console.log('Current selection:', selection.toString());
this.handleTextSelection();
};
} }
}, },
// 문서 내 스크립트 핸들러 설정
setupDocumentScriptHandlers() {
// 업로드된 HTML 문서에서 사용할 수 있는 전역 함수들 정의
// 언어 토글 함수 (많은 문서에서 사용)
window.toggleLanguage = function() {
const koreanContent = document.getElementById('korean-content');
const englishContent = document.getElementById('english-content');
if (koreanContent && englishContent) {
// ID 기반 토글 (압력용기 매뉴얼 등)
if (koreanContent.style.display === 'none') {
koreanContent.style.display = 'block';
englishContent.style.display = 'none';
} else {
koreanContent.style.display = 'none';
englishContent.style.display = 'block';
}
} else {
// 클래스 기반 토글 (다른 문서들)
const koreanElements = document.querySelectorAll('.korean, .ko');
const englishElements = document.querySelectorAll('.english, .en');
koreanElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
englishElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
}
// 토글 버튼 텍스트 업데이트
const toggleButton = document.querySelector('.language-toggle');
if (toggleButton && koreanContent) {
const isKoreanVisible = koreanContent.style.display !== 'none';
toggleButton.textContent = isKoreanVisible ? '🌐 English' : '🌐 한국어';
}
};
// 기타 공통 함수들 (필요시 추가)
window.showSection = function(sectionId) {
const section = document.getElementById(sectionId);
if (section) {
section.scrollIntoView({ behavior: 'smooth' });
}
};
// 인쇄 함수
window.printDocument = function() {
window.print();
};
// 문서 내 링크 클릭 시 새 창에서 열기 방지
const links = document.querySelectorAll('#document-content a[href^="http"]');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
if (confirm('외부 링크로 이동하시겠습니까?\n' + link.href)) {
window.open(link.href, '_blank');
}
});
});
},
// 문서 관련 데이터 로드 // 문서 관련 데이터 로드
async loadDocumentData() { async loadDocumentData() {
try { try {
console.log('Loading document data for:', this.documentId);
const [highlights, notes, bookmarks] = await Promise.all([ const [highlights, notes, bookmarks] = await Promise.all([
api.getDocumentHighlights(this.documentId), api.getDocumentHighlights(this.documentId).catch(() => []),
api.getDocumentNotes(this.documentId), api.getDocumentNotes(this.documentId).catch(() => []),
api.getDocumentBookmarks(this.documentId) api.getDocumentBookmarks(this.documentId).catch(() => [])
]); ]);
this.highlights = highlights; this.highlights = highlights || [];
this.notes = notes; this.notes = notes || [];
this.bookmarks = bookmarks; this.bookmarks = bookmarks || [];
console.log('Loaded data:', { highlights: this.highlights.length, notes: this.notes.length, bookmarks: this.bookmarks.length });
// 하이라이트 렌더링 // 하이라이트 렌더링
this.renderHighlights(); this.renderHighlights();
} catch (error) { } catch (error) {
console.error('Failed to load document data:', error); console.warn('Some document data failed to load, continuing with empty data:', error);
this.highlights = [];
this.notes = [];
this.bookmarks = [];
} }
}, },
@@ -203,10 +296,10 @@ window.documentViewer = () => ({
highlightEl.textContent = highlightText; highlightEl.textContent = highlightText;
highlightEl.dataset.highlightId = highlight.id; highlightEl.dataset.highlightId = highlight.id;
// 클릭 이벤트 추가 // 클릭 이벤트 추가 - 말풍선 표시
highlightEl.addEventListener('click', (e) => { highlightEl.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.selectHighlight(highlight.id); this.showHighlightTooltip(highlight, e.target);
}); });
// 노드 교체 // 노드 교체
@@ -227,22 +320,27 @@ window.documentViewer = () => ({
// 텍스트 선택 처리 // 텍스트 선택 처리
handleTextSelection() { handleTextSelection() {
console.log('handleTextSelection called');
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) { if (selection.rangeCount === 0 || selection.isCollapsed) {
console.log('No selection or collapsed');
return; return;
} }
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim(); const selectedText = selection.toString().trim();
console.log('Selected text:', selectedText);
if (selectedText.length < 2) { if (selectedText.length < 2) {
console.log('Text too short');
return; return;
} }
// 문서 컨텐츠 내부의 선택인지 확인 // 문서 컨텐츠 내부의 선택인지 확인
const content = document.getElementById('document-content'); const content = document.getElementById('document-content');
if (!content.contains(range.commonAncestorContainer)) { if (!content.contains(range.commonAncestorContainer)) {
console.log('Selection not in document content');
return; return;
} }
@@ -250,6 +348,7 @@ window.documentViewer = () => ({
this.selectedText = selectedText; this.selectedText = selectedText;
this.selectedRange = range.cloneRange(); this.selectedRange = range.cloneRange();
console.log('Showing highlight button');
// 컨텍스트 메뉴 표시 (간단한 버튼) // 컨텍스트 메뉴 표시 (간단한 버튼)
this.showHighlightButton(selection); this.showHighlightButton(selection);
}, },
@@ -266,10 +365,12 @@ window.documentViewer = () => ({
const rect = range.getBoundingClientRect(); const rect = range.getBoundingClientRect();
const button = document.createElement('button'); 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.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-4 py-2 rounded shadow-lg text-sm font-medium border-2 border-blue-700';
button.style.left = `${rect.left + window.scrollX}px`; button.style.left = `${rect.left + window.scrollX}px`;
button.style.top = `${rect.bottom + window.scrollY + 5}px`; button.style.top = `${rect.bottom + window.scrollY + 10}px`;
button.innerHTML = '<i class="fas fa-highlighter mr-1"></i>하이라이트'; button.innerHTML = '🖍️ 하이라이트';
console.log('Highlight button created at:', button.style.left, button.style.top);
button.addEventListener('click', () => { button.addEventListener('click', () => {
this.createHighlight(); this.createHighlight();
@@ -286,16 +387,44 @@ window.documentViewer = () => ({
}, 3000); }, 3000);
}, },
// 색상 버튼으로 하이라이트 생성
createHighlightWithColor(color) {
console.log('createHighlightWithColor called with color:', color);
// 현재 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('먼저 하이라이트할 텍스트를 선택해주세요.');
return;
}
// 색상 설정 후 하이라이트 생성
this.selectedHighlightColor = color;
this.handleTextSelection(); // 텍스트 선택 처리
// 바로 하이라이트 생성 (버튼 클릭 없이)
setTimeout(() => {
this.createHighlight();
}, 100);
},
// 하이라이트 생성 // 하이라이트 생성
async createHighlight() { async createHighlight() {
console.log('createHighlight called');
console.log('selectedText:', this.selectedText);
console.log('selectedRange:', this.selectedRange);
if (!this.selectedText || !this.selectedRange) { if (!this.selectedText || !this.selectedRange) {
console.log('No selected text or range');
return; return;
} }
try { try {
console.log('Starting highlight creation...');
// 텍스트 오프셋 계산 // 텍스트 오프셋 계산
const content = document.getElementById('document-content'); const content = document.getElementById('document-content');
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content); const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
console.log('Text offsets:', startOffset, endOffset);
const highlightData = { const highlightData = {
document_id: this.documentId, document_id: this.documentId,
@@ -669,5 +798,232 @@ window.documentViewer = () => ({
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}); });
},
// 하이라이트 말풍선 표시
showHighlightTooltip(highlight, element) {
// 기존 말풍선 제거
this.hideTooltip();
const tooltip = document.createElement('div');
tooltip.id = 'highlight-tooltip';
tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-sm';
tooltip.style.minWidth = '300px';
// 하이라이트 정보와 메모 표시
const highlightNotes = this.notes.filter(note => note.highlight_id === highlight.id);
tooltip.innerHTML = `
<div class="mb-3">
<div class="text-sm text-gray-600 mb-1">선택된 텍스트</div>
<div class="font-medium text-gray-900 bg-yellow-100 px-2 py-1 rounded">
"${highlight.selected_text}"
</div>
</div>
<div class="mb-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">메모 (${highlightNotes.length})</span>
<button onclick="window.documentViewerInstance.showAddNoteForm('${highlight.id}')"
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
+ 메모 추가
</button>
</div>
<div id="notes-list" class="space-y-2 max-h-40 overflow-y-auto">
${highlightNotes.length > 0 ?
highlightNotes.map(note => `
<div class="bg-gray-50 p-2 rounded text-sm">
<div class="text-gray-800 mb-1">${note.content}</div>
<div class="text-xs text-gray-500">
${this.formatShortDate(note.created_at)} · Administrator
</div>
</div>
`).join('') :
'<div class="text-sm text-gray-500 italic">메모가 없습니다</div>'
}
</div>
</div>
<div class="flex justify-between text-xs">
<button onclick="window.documentViewerInstance.deleteHighlight('${highlight.id}')"
class="text-red-500 hover:text-red-700">
하이라이트 삭제
</button>
<button onclick="window.documentViewerInstance.hideTooltip()"
class="text-gray-500 hover:text-gray-700">
닫기
</button>
</div>
`;
// 위치 계산
const rect = element.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
document.body.appendChild(tooltip);
// 말풍선 위치 조정
const tooltipRect = tooltip.getBoundingClientRect();
let top = rect.bottom + scrollTop + 5;
let left = rect.left + scrollLeft;
// 화면 경계 체크
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > window.innerHeight + scrollTop) {
top = rect.top + scrollTop - tooltipRect.height - 5;
}
tooltip.style.top = top + 'px';
tooltip.style.left = left + 'px';
// 외부 클릭 시 닫기
setTimeout(() => {
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
}, 100);
},
// 말풍선 숨기기
hideTooltip() {
const tooltip = document.getElementById('highlight-tooltip');
if (tooltip) {
tooltip.remove();
}
document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this));
},
// 말풍선 외부 클릭 처리
handleTooltipOutsideClick(e) {
const tooltip = document.getElementById('highlight-tooltip');
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('highlight')) {
this.hideTooltip();
}
},
// 짧은 날짜 형식
formatShortDate(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 {
return date.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
});
}
},
// 메모 추가 폼 표시
showAddNoteForm(highlightId) {
const tooltip = document.getElementById('highlight-tooltip');
if (!tooltip) return;
const notesList = tooltip.querySelector('#notes-list');
notesList.innerHTML = `
<div class="bg-blue-50 p-3 rounded border">
<textarea id="new-note-content"
placeholder="메모를 입력하세요..."
class="w-full p-2 border border-gray-300 rounded text-sm resize-none"
rows="3"></textarea>
<div class="flex justify-end mt-2 space-x-2">
<button onclick="window.documentViewerInstance.cancelAddNote('${highlightId}')"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
취소
</button>
<button onclick="window.documentViewerInstance.saveNewNote('${highlightId}')"
class="text-xs bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600">
저장
</button>
</div>
</div>
`;
// 텍스트 영역에 포커스
setTimeout(() => {
document.getElementById('new-note-content').focus();
}, 100);
},
// 메모 추가 취소
cancelAddNote(highlightId) {
// 말풍선 다시 표시
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (element) {
this.showHighlightTooltip(highlight, element);
}
}
},
// 새 메모 저장
async saveNewNote(highlightId) {
const content = document.getElementById('new-note-content').value.trim();
if (!content) {
alert('메모 내용을 입력해주세요');
return;
}
try {
const noteData = {
highlight_id: highlightId,
content: content,
is_private: false,
tags: []
};
const newNote = await api.createNote(noteData);
// 로컬 데이터 업데이트
this.notes.push(newNote);
// 말풍선 새로고침
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (element) {
this.showHighlightTooltip(highlight, element);
}
}
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
}
},
// 하이라이트 삭제
async deleteHighlight(highlightId) {
if (!confirm('이 하이라이트를 삭제하시겠습니까? 연결된 메모도 함께 삭제됩니다.')) {
return;
}
try {
await api.deleteHighlight(highlightId);
// 로컬 데이터에서 제거
this.highlights = this.highlights.filter(h => h.id !== highlightId);
this.notes = this.notes.filter(n => n.highlight_id !== highlightId);
// UI 업데이트
this.hideTooltip();
this.renderHighlights();
} catch (error) {
console.error('Failed to delete highlight:', error);
alert('하이라이트 삭제에 실패했습니다');
}
} }
}); });

View File

@@ -33,18 +33,22 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- 하이라이트 색상 선택 --> <!-- 하이라이트 색상 선택 -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1"> <div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button @click="selectedHighlightColor = '#FFFF00'" <button @click="createHighlightWithColor('#FFFF00')"
:class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-blue-500' : ''" :class="selectedHighlightColor === '#FFFF00' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-yellow-300 rounded border-2 border-white"></button> class="w-8 h-8 bg-yellow-300 rounded border-2 border-white"
<button @click="selectedHighlightColor = '#90EE90'" title="노란색 하이라이트"></button>
<button @click="createHighlightWithColor('#90EE90')"
:class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-blue-500' : ''" :class="selectedHighlightColor === '#90EE90' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-green-300 rounded border-2 border-white"></button> class="w-8 h-8 bg-green-300 rounded border-2 border-white"
<button @click="selectedHighlightColor = '#FFB6C1'" title="초록색 하이라이트"></button>
<button @click="createHighlightWithColor('#FFB6C1')"
:class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-blue-500' : ''" :class="selectedHighlightColor === '#FFB6C1' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-pink-300 rounded border-2 border-white"></button> class="w-8 h-8 bg-pink-300 rounded border-2 border-white"
<button @click="selectedHighlightColor = '#87CEEB'" title="분홍색 하이라이트"></button>
<button @click="createHighlightWithColor('#87CEEB')"
:class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-500' : ''" :class="selectedHighlightColor === '#87CEEB' ? 'ring-2 ring-blue-500' : ''"
class="w-8 h-8 bg-blue-300 rounded border-2 border-white"></button> class="w-8 h-8 bg-blue-300 rounded border-2 border-white"
title="파란색 하이라이트"></button>
</div> </div>
<!-- 메모 패널 토글 --> <!-- 메모 패널 토글 -->