🐛 Fix Alpine.js SyntaxError and backlink visibility issues

- Fix SyntaxError in viewer.js line 2868 (.bind(this) issue in setTimeout)
- Resolve Alpine.js 'Can't find variable' errors (documentViewer, goBack, etc.)
- Fix backlink rendering and persistence during temporary highlights
- Add backlink protection and restoration mechanism in highlightAndScrollToText
- Implement Note Management System with hierarchical notebooks
- Add note highlights and memos functionality
- Update cache version to force browser refresh (v=2025012641)
- Add comprehensive logging for debugging backlink issues
This commit is contained in:
Hyungi Ahn
2025-08-26 23:50:48 +09:00
parent 8d7f4c04bb
commit 3e0a03f149
31 changed files with 5176 additions and 567 deletions

View File

@@ -6,6 +6,35 @@ HTML 문서 관리 및 뷰어 시스템
PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적으로 관리하고 열람할 수 있는 시스템입니다.
## 📝 용어 정의
시스템에서 사용하는 주요 용어들을 명확히 구분합니다:
### 핵심 용어
- **메모 (Memo)**: 하이라이트 기반의 메모 기능
- 하이라이트에 달리는 짧은 코멘트
- 문서 뷰어에서 텍스트 선택 → 하이라이트 → 메모 작성
- API: `/api/notes/` (하이라이트 메모 전용)
- **노트 (Note)**: 독립적인 문서 작성 기능
- HTML 기반의 완전한 문서
- 기본 뷰어 페이지에서 확인 및 편집
- 하이라이트, 메모, 링크 등 모든 기능 사용 가능
- 노트북에 그룹화 가능
- API: `/api/note-documents/`
- **노트북 (Notebook)**: 노트 문서들을 그룹화하는 폴더
- 노트들의 컨테이너 역할
- 계층적 구조 지원
- API: `/api/notebooks/`
### 기능별 구분
| 기능 | 용어 | 설명 | 주요 API | 뷰어 지원 |
|------|------|------|----------|----------|
| 하이라이트 메모 | 메모 (Memo) | 하이라이트에 달리는 짧은 코멘트 | `/api/notes/` | ✅ 문서 뷰어 |
| 독립 문서 작성 | 노트 (Note) | HTML 기반 완전한 문서 | `/api/note-documents/` | ✅ 동일 뷰어 (모든 기능) |
| 문서 그룹화 | 노트북 (Notebook) | 노트들을 담는 폴더 | `/api/notebooks/` | - |
### 문서 처리 워크플로우
1. PDF 스캔 후 OCR 처리
2. AI를 통한 HTML 변환 (필요시 번역 포함)
@@ -202,7 +231,13 @@ notes (
- [x] API 오류 처리 및 사용자 피드백
- [x] 실시간 문서 목록 새로고침
### Phase 7: 향후 개선사항 (예정)
### Phase 7: 최우선 개선사항 (진행 중) 🔥
- [ ] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합
- [ ] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원
- [ ] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리
- [ ] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보
### Phase 8: 향후 개선사항 (예정)
- [ ] 관리자 대시보드 UI
- [ ] 문서 통계 및 분석
- [ ] 모바일 반응형 최적화

View File

@@ -0,0 +1,81 @@
-- 노트 관리 시스템 생성
-- 009_create_notes_system.sql
-- 노트 문서 테이블
CREATE TABLE IF NOT EXISTS notes_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
content TEXT, -- 마크다운 내용
html_content TEXT, -- 변환된 HTML 내용
note_type VARCHAR(50) DEFAULT 'note', -- note, research, summary, idea 등
tags TEXT[] DEFAULT '{}', -- 태그 배열
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL,
is_published BOOLEAN DEFAULT false, -- 공개 여부
parent_note_id UUID REFERENCES notes_documents(id) ON DELETE SET NULL, -- 계층 구조
sort_order INTEGER DEFAULT 0, -- 정렬 순서
word_count INTEGER DEFAULT 0, -- 단어 수
reading_time INTEGER DEFAULT 0, -- 예상 읽기 시간 (분)
-- 인덱스
CONSTRAINT notes_documents_title_check CHECK (char_length(title) > 0)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_by ON notes_documents(created_by);
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_at ON notes_documents(created_at);
CREATE INDEX IF NOT EXISTS idx_notes_documents_note_type ON notes_documents(note_type);
CREATE INDEX IF NOT EXISTS idx_notes_documents_parent_note_id ON notes_documents(parent_note_id);
CREATE INDEX IF NOT EXISTS idx_notes_documents_tags ON notes_documents USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_notes_documents_is_published ON notes_documents(is_published);
-- 업데이트 시간 자동 갱신 트리거
CREATE OR REPLACE FUNCTION update_notes_documents_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_notes_documents_updated_at
BEFORE UPDATE ON notes_documents
FOR EACH ROW
EXECUTE FUNCTION update_notes_documents_updated_at();
-- 기존 document_links 테이블에 노트 지원 추가
-- (이미 존재하는 테이블이므로 ALTER 사용)
DO $$
BEGIN
-- source_type, target_type 컬럼이 없다면 추가
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'document_links' AND column_name = 'source_type'
) THEN
ALTER TABLE document_links
ADD COLUMN source_type VARCHAR(20) DEFAULT 'document',
ADD COLUMN target_type VARCHAR(20) DEFAULT 'document';
-- 기존 데이터는 모두 'document' 타입으로 설정
UPDATE document_links SET source_type = 'document', target_type = 'document';
END IF;
END $$;
-- 노트 관련 링크를 위한 인덱스
CREATE INDEX IF NOT EXISTS idx_document_links_source_type ON document_links(source_type);
CREATE INDEX IF NOT EXISTS idx_document_links_target_type ON document_links(target_type);
-- 샘플 노트 타입 데이터
INSERT INTO notes_documents (title, content, html_content, note_type, tags, created_by, is_published)
VALUES
('노트 시스템 사용법',
'# 노트 시스템 사용법\n\n## 기본 기능\n- 마크다운으로 노트 작성\n- HTML로 자동 변환\n- 태그 기반 분류\n\n## 고급 기능\n- 서적과 링크 연결\n- 계층 구조 지원\n- 내보내기 기능',
'<h1>노트 시스템 사용법</h1><h2>기본 기능</h2><ul><li>마크다운으로 노트 작성</li><li>HTML로 자동 변환</li><li>태그 기반 분류</li></ul><h2>고급 기능</h2><ul><li>서적과 링크 연결</li><li>계층 구조 지원</li><li>내보내기 기능</li></ul>',
'guide',
ARRAY['가이드', '사용법', '시스템'],
'Administrator',
true)
ON CONFLICT DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,25 @@
-- 노트북 시스템 생성
CREATE TABLE notebooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#3B82F6', -- 헥스 컬러 코드
icon VARCHAR(50) DEFAULT 'book', -- FontAwesome 아이콘 이름
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0
);
-- 노트북-노트 관계 테이블 (기존 notes_documents의 parent_note_id 대신 사용)
ALTER TABLE notes_documents ADD COLUMN notebook_id UUID REFERENCES notebooks(id);
-- 인덱스 생성
CREATE INDEX idx_notebooks_created_by ON notebooks(created_by);
CREATE INDEX idx_notebooks_created_at ON notebooks(created_at);
CREATE INDEX idx_notes_notebook_id ON notes_documents(notebook_id);
-- 기본 노트북 생성 (기존 노트들을 위한)
INSERT INTO notebooks (title, description, created_by, color, icon)
VALUES ('기본 노트북', '분류되지 않은 노트들', 'admin@test.com', '#6B7280', 'sticky-note');

View File

@@ -0,0 +1,48 @@
-- 노트용 하이라이트 테이블 생성
CREATE TABLE note_highlights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
selected_text TEXT NOT NULL,
highlight_color VARCHAR(50) NOT NULL DEFAULT '#FFFF00',
highlight_type VARCHAR(50) NOT NULL DEFAULT 'highlight',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL
);
-- 노트용 메모 테이블 생성
CREATE TABLE note_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
highlight_id UUID REFERENCES note_highlights(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL
);
-- 인덱스 생성
CREATE INDEX ix_note_highlights_note_id ON note_highlights (note_id);
CREATE INDEX ix_note_highlights_created_by ON note_highlights (created_by);
CREATE INDEX ix_note_notes_note_id ON note_notes (note_id);
CREATE INDEX ix_note_notes_highlight_id ON note_notes (highlight_id);
CREATE INDEX ix_note_notes_created_by ON note_notes (created_by);
-- updated_at 자동 업데이트 트리거
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_note_highlights_updated_at
BEFORE UPDATE ON note_highlights
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_note_notes_updated_at
BEFORE UPDATE ON note_notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -402,6 +402,8 @@ class BacklinkResponse(BaseModel):
source_document_id: str
source_document_title: str
source_document_book_id: Optional[str]
target_document_id: str # 추가
target_document_title: str # 추가
selected_text: str
start_offset: int
end_offset: int
@@ -421,16 +423,21 @@ async def get_document_backlinks(
db: AsyncSession = Depends(get_db)
):
"""문서의 백링크 조회 (이 문서를 참조하는 모든 링크)"""
print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}")
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
print(f"❌ 문서를 찾을 수 없음: {document_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
print(f"✅ 문서 찾음: {document.title}")
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
@@ -454,12 +461,20 @@ async def get_document_backlinks(
result = await db.execute(query)
backlinks = []
print(f"🔍 백링크 쿼리 실행 완료")
for link, source_doc, book in result.fetchall():
print(f"📋 백링크 발견: {source_doc.title} -> {document.title}")
print(f" - 선택된 텍스트: {link.selected_text}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
source_document_title=source_doc.title,
source_document_book_id=str(book.id) if book else None,
target_document_id=str(link.target_document_id), # 추가
target_document_title=document.title, # 추가
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
@@ -469,6 +484,7 @@ async def get_document_backlinks(
created_at=link.created_at.isoformat()
))
print(f"✅ 총 {len(backlinks)}개의 백링크 반환")
return backlinks

View File

@@ -63,6 +63,10 @@ class HighlightResponse(BaseModel):
router = APIRouter()
@router.post("/", response_model=HighlightResponse)
async def create_highlight(
highlight_data: CreateHighlightRequest,

View File

@@ -0,0 +1,103 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_highlight import NoteHighlight, NoteHighlightCreate, NoteHighlightUpdate, NoteHighlightResponse
from ...models.note_document import NoteDocument
router = APIRouter()
@router.get("/note/{note_id}/highlights", response_model=List[NoteHighlightResponse])
def get_note_highlights(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트의 하이라이트 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 조회
highlights = db.query(NoteHighlight).filter(
NoteHighlight.note_id == note_id
).order_by(NoteHighlight.start_offset).all()
return [NoteHighlightResponse.from_orm(highlight) for highlight in highlights]
@router.post("/note-highlights/", response_model=NoteHighlightResponse)
def create_note_highlight(
highlight_data: NoteHighlightCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 생성"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == highlight_data.note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 생성
highlight = NoteHighlight(
note_id=highlight_data.note_id,
start_offset=highlight_data.start_offset,
end_offset=highlight_data.end_offset,
selected_text=highlight_data.selected_text,
highlight_color=highlight_data.highlight_color,
highlight_type=highlight_data.highlight_type,
created_by=current_user.email
)
db.add(highlight)
db.commit()
db.refresh(highlight)
return NoteHighlightResponse.from_orm(highlight)
@router.put("/note-highlights/{highlight_id}", response_model=NoteHighlightResponse)
def update_note_highlight(
highlight_id: str,
highlight_data: NoteHighlightUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 수정"""
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 권한 확인
if highlight.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트
for field, value in highlight_data.dict(exclude_unset=True).items():
setattr(highlight, field, value)
db.commit()
db.refresh(highlight)
return NoteHighlightResponse.from_orm(highlight)
@router.delete("/note-highlights/{highlight_id}")
def delete_note_highlight(
highlight_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 삭제"""
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 권한 확인
if highlight.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(highlight)
db.commit()
return {"message": "Highlight deleted successfully"}

View File

@@ -0,0 +1,128 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, selectinload
from typing import List
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_note import NoteNote, NoteNoteCreate, NoteNoteUpdate, NoteNoteResponse
from ...models.note_document import NoteDocument
from ...models.note_highlight import NoteHighlight
router = APIRouter()
@router.get("/note/{note_id}/notes", response_model=List[NoteNoteResponse])
def get_note_notes(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트의 메모 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 메모 조회
notes = db.query(NoteNote).filter(
NoteNote.note_id == note_id
).options(
selectinload(NoteNote.highlight)
).order_by(NoteNote.created_at.desc()).all()
return [NoteNoteResponse.from_orm(note) for note in notes]
@router.get("/note-highlights/{highlight_id}/notes", response_model=List[NoteNoteResponse])
def get_highlight_notes(
highlight_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 하이라이트의 메모 목록 조회"""
# 하이라이트 존재 확인
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 메모 조회
notes = db.query(NoteNote).filter(
NoteNote.highlight_id == highlight_id
).order_by(NoteNote.created_at.desc()).all()
return [NoteNoteResponse.from_orm(note) for note in notes]
@router.post("/note-notes/", response_model=NoteNoteResponse)
def create_note_note(
note_data: NoteNoteCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 생성"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_data.note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 존재 확인 (선택사항)
if note_data.highlight_id:
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == note_data.highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 메모 생성
note_note = NoteNote(
note_id=note_data.note_id,
highlight_id=note_data.highlight_id,
content=note_data.content,
created_by=current_user.email
)
db.add(note_note)
db.commit()
db.refresh(note_note)
return NoteNoteResponse.from_orm(note_note)
@router.put("/note-notes/{note_note_id}", response_model=NoteNoteResponse)
def update_note_note(
note_note_id: str,
note_data: NoteNoteUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 수정"""
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
if not note_note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note_note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트
for field, value in note_data.dict(exclude_unset=True).items():
setattr(note_note, field, value)
db.commit()
db.refresh(note_note)
return NoteNoteResponse.from_orm(note_note)
@router.delete("/note-notes/{note_note_id}")
def delete_note_note(
note_note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 삭제"""
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
if not note_note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note_note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(note_note)
db.commit()
return {"message": "Note deleted successfully"}

View File

@@ -0,0 +1,270 @@
"""
노트북 (Notebook) 관리 API
용어 정의:
- 노트북 (Notebook): 노트 문서들을 그룹화하는 폴더
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, asc, select
from typing import List, Optional
from ...core.database import get_sync_db
from ...models.notebook import (
Notebook,
NotebookCreate,
NotebookUpdate,
NotebookResponse,
NotebookListItem,
NotebookStats
)
from ...models.note_document import NoteDocument
from ...models.user import User
from ..dependencies import get_current_user
router = APIRouter()
@router.get("/", response_model=List[NotebookListItem])
def get_notebooks(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None),
active_only: bool = Query(True),
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|sort_order)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 목록 조회"""
query = db.query(Notebook)
# 필터링
if search:
search_term = f"%{search}%"
query = query.filter(
(Notebook.title.ilike(search_term)) |
(Notebook.description.ilike(search_term))
)
if active_only:
query = query.filter(Notebook.is_active == True)
# 정렬
if sort_by == 'title':
query = query.order_by(asc(Notebook.title) if order == 'asc' else desc(Notebook.title))
elif sort_by == 'created_at':
query = query.order_by(asc(Notebook.created_at) if order == 'asc' else desc(Notebook.created_at))
elif sort_by == 'sort_order':
query = query.order_by(asc(Notebook.sort_order) if order == 'asc' else desc(Notebook.sort_order))
else:
query = query.order_by(desc(Notebook.updated_at))
# 페이지네이션
notebooks = query.offset(skip).limit(limit).all()
# 노트 개수 계산
result = []
for notebook in notebooks:
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
notebook_item = NotebookListItem.from_orm(notebook, note_count)
result.append(notebook_item)
return result
@router.get("/stats", response_model=NotebookStats)
def get_notebook_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 통계 정보"""
total_notebooks = db.query(func.count(Notebook.id)).scalar()
active_notebooks = db.query(func.count(Notebook.id)).filter(
Notebook.is_active == True
).scalar()
total_notes = db.query(func.count(NoteDocument.id)).scalar()
notes_without_notebook = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id.is_(None)
).scalar()
return NotebookStats(
total_notebooks=total_notebooks,
active_notebooks=active_notebooks,
total_notes=total_notes,
notes_without_notebook=notes_without_notebook
)
@router.get("/{notebook_id}", response_model=NotebookResponse)
def get_notebook(
notebook_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트북 조회"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트 개수 계산
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
return NotebookResponse.from_orm(notebook, note_count)
@router.post("/", response_model=NotebookResponse)
def create_notebook(
notebook_data: NotebookCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트북 생성"""
notebook = Notebook(
title=notebook_data.title,
description=notebook_data.description,
color=notebook_data.color,
icon=notebook_data.icon,
is_active=notebook_data.is_active,
sort_order=notebook_data.sort_order,
created_by=current_user.email
)
db.add(notebook)
db.commit()
db.refresh(notebook)
return NotebookResponse.from_orm(notebook, 0)
@router.put("/{notebook_id}", response_model=NotebookResponse)
def update_notebook(
notebook_id: str,
notebook_data: NotebookUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 업데이트"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 업데이트할 필드만 적용
update_data = notebook_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(notebook, field, value)
db.commit()
db.refresh(notebook)
# 노트 개수 계산
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
return NotebookResponse.from_orm(notebook, note_count)
@router.delete("/{notebook_id}")
def delete_notebook(
notebook_id: str,
force: bool = Query(False, description="강제 삭제 (노트가 있어도 삭제)"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 삭제"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트북에 포함된 노트 확인
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
if note_count > 0 and not force:
raise HTTPException(
status_code=400,
detail=f"Cannot delete notebook with {note_count} notes. Use force=true to delete anyway."
)
if force and note_count > 0:
# 노트들의 notebook_id를 NULL로 설정 (기본 노트북으로 이동)
db.query(NoteDocument).filter(
NoteDocument.notebook_id == notebook.id
).update({NoteDocument.notebook_id: None})
db.delete(notebook)
db.commit()
return {"message": "Notebook deleted successfully"}
@router.get("/{notebook_id}/notes")
def get_notebook_notes(
notebook_id: str,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북에 포함된 노트들 조회"""
# 노트북 존재 확인
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트들 조회
notes = db.query(NoteDocument).filter(
NoteDocument.notebook_id == notebook_id
).order_by(desc(NoteDocument.updated_at)).offset(skip).limit(limit).all()
return notes
@router.post("/{notebook_id}/notes/{note_id}")
def add_note_to_notebook(
notebook_id: str,
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 노트북에 추가"""
# 노트북과 노트 존재 확인
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 노트를 노트북에 할당
note.notebook_id = notebook_id
db.commit()
return {"message": "Note added to notebook successfully"}
@router.delete("/{notebook_id}/notes/{note_id}")
def remove_note_from_notebook(
notebook_id: str,
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 노트북에서 제거"""
note = db.query(NoteDocument).filter(
NoteDocument.id == note_id,
NoteDocument.notebook_id == notebook_id
).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found in this notebook")
# 노트북에서 제거 (기본 노트북으로 이동)
note.notebook_id = None
db.commit()
return {"message": "Note removed from notebook successfully"}

View File

@@ -1,452 +1,452 @@
"""
메모 관리 API 라우터
노트 문서 (Note Document) 관리 API
용어 정의:
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
- 노트북 (Notebook): 노트들을 그룹화하는 폴더
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API - highlights.py)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload, joinedload
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import func, desc, asc, select
from typing import List, Optional
from datetime import datetime
# import markdown # 임시로 비활성화
import re
from datetime import datetime, timedelta
from ...core.database import get_db
from ...core.database import get_sync_db
from ...models.note_document import (
NoteDocument,
NoteDocumentCreate,
NoteDocumentUpdate,
NoteDocumentResponse,
NoteDocumentListItem,
NoteStats
)
from ...models.user import User
from ...models.highlight import Highlight
from ...models.note import Note
from ...models.document import Document
from ..dependencies import get_current_active_user
from pydantic import BaseModel
class CreateNoteRequest(BaseModel):
"""메모 생성 요청"""
highlight_id: str
content: str
tags: Optional[List[str]] = None
class UpdateNoteRequest(BaseModel):
"""메모 업데이트 요청"""
content: Optional[str] = None
tags: Optional[List[str]] = None
is_private: Optional[bool] = None
class NoteResponse(BaseModel):
"""메모 응답"""
id: str
user_id: str
highlight_id: str
content: str
is_private: bool
tags: Optional[List[str]]
created_at: datetime
updated_at: Optional[datetime]
# 연결된 하이라이트 정보
highlight: dict
# 문서 정보
document: dict
class Config:
from_attributes = True
from ..dependencies import get_current_user
router = APIRouter()
# === 하이라이트 메모 (Highlight Memo) API ===
# 용어 정의: 하이라이트에 달리는 짧은 코멘트
@router.post("/", response_model=NoteResponse)
@router.post("/")
async def create_note(
note_data: CreateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
note_data: dict,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""메모 생성 (하이라이트에 연결)"""
# 하이라이트 존재 및 소유권 확인
result = await db.execute(
select(Highlight)
.options(joinedload(Highlight.document))
.where(Highlight.id == note_data.highlight_id)
)
highlight = result.scalar_one_or_none()
"""하이라이트 메모 생성"""
from ...models.note import Note
from ...models.highlight import Highlight
# 하이라이트 소유권 확인
highlight = db.query(Highlight).filter(
Highlight.id == note_data.get('highlight_id'),
Highlight.user_id == current_user.id
).first()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 하이라이트 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 중복 확인 제거 - 하나의 하이라이트에 여러 메모 허용
raise HTTPException(status_code=404, detail="하이라이트를 찾을 수 없습니다")
# 메모 생성
note = Note(
highlight_id=note_data.highlight_id,
content=note_data.content,
tags=note_data.tags or []
highlight_id=note_data.get('highlight_id'),
content=note_data.get('content', ''),
is_private=note_data.get('is_private', False),
tags=note_data.get('tags', [])
)
db.add(note)
await db.commit()
await db.refresh(note)
db.commit()
db.refresh(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 = {
"id": str(highlight.id),
"selected_text": highlight.selected_text,
"highlight_color": highlight.highlight_color,
"start_offset": highlight.start_offset,
"end_offset": highlight.end_offset
}
response_data.document = {
"id": str(highlight.document.id),
"title": highlight.document.title
}
return response_data
return note
@router.get("/", response_model=List[NoteResponse])
async def list_user_notes(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
@router.get("/document/{document_id}")
async def get_document_notes(
document_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""사용자의 모든 메모 조회 (검색 가능)"""
query = (
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
"""특정 문서의 모든 하이라이트 메모 조회"""
from ...models.note import Note
from ...models.highlight import Highlight
# 문서 필터링
if document_id:
query = query.where(Highlight.document_id == document_id)
notes = db.query(Note).join(Highlight).filter(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
).options(
selectinload(Note.highlight)
).all()
# 태그 필터링
if tag:
query = query.where(Note.tags.contains([tag]))
return notes
def clean_html_content(content: str) -> str:
"""HTML 내용 정리 및 검증"""
if not content:
return ""
# 기본적인 HTML 정리 (나중에 더 정교하게 할 수 있음)
return content.strip()
def calculate_reading_time(content: str) -> int:
"""읽기 시간 계산 (분 단위)"""
if not content:
return 0
# 단어 수 계산 (한글, 영문 모두 고려)
korean_chars = len(re.findall(r'[가-힣]', content))
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
# 한글: 분당 500자, 영문: 분당 200단어 기준
korean_time = korean_chars / 500
english_time = english_words / 200
total_minutes = max(1, int(korean_time + english_time))
return total_minutes
def calculate_word_count(content: str) -> int:
"""단어/글자 수 계산"""
if not content:
return 0
korean_chars = len(re.findall(r'[가-힣]', content))
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
return korean_chars + english_words
@router.get("/", response_model=List[NoteDocumentListItem])
def get_notes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
note_type: Optional[str] = Query(None),
tags: Optional[str] = Query(None), # 쉼표로 구분된 태그
search: Optional[str] = Query(None),
published_only: bool = Query(False),
parent_id: Optional[str] = Query(None),
notebook_id: Optional[str] = Query(None), # 노트북 필터
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 목록 조회"""
# 동기 SQLAlchemy 스타일
query = db.query(NoteDocument)
# 필터링
if note_type:
query = query.filter(NoteDocument.note_type == note_type)
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
query = query.filter(NoteDocument.tags.overlap(tag_list))
# 검색 필터링 (메모 내용 + 하이라이트된 텍스트)
if search:
query = query.where(
or_(
Note.content.ilike(f"%{search}%"),
Highlight.selected_text.ilike(f"%{search}%")
)
search_term = f"%{search}%"
query = query.filter(
(NoteDocument.title.ilike(search_term)) |
(NoteDocument.content.ilike(search_term))
)
query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit)
if published_only:
query = query.filter(NoteDocument.is_published == True)
result = await db.execute(query)
notes = result.scalars().all()
if notebook_id:
if notebook_id == 'null':
# 미분류 노트 (notebook_id가 None인 것들)
query = query.filter(NoteDocument.notebook_id.is_(None))
else:
query = query.filter(NoteDocument.notebook_id == notebook_id)
# 응답 데이터 변환
response_data = []
if parent_id:
query = query.filter(NoteDocument.parent_note_id == parent_id)
else:
# 최상위 노트만 (parent_id가 None인 것들)
query = query.filter(NoteDocument.parent_note_id.is_(None))
# 정렬 및 페이징
query = query.order_by(desc(NoteDocument.updated_at))
notes = query.offset(skip).limit(limit).all()
# 자식 노트 개수 계산
result = []
for note in notes:
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 = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
result.append(note_item)
return response_data
return result
@router.get("/{note_id}", response_model=NoteResponse)
async def get_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
@router.get("/stats", response_model=NoteStats)
def get_note_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""메모 상세 조회"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
"""노트 통계 정보"""
total_notes = db.query(func.count(NoteDocument.id)).scalar()
published_notes = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.is_published == True
).scalar()
draft_notes = total_notes - published_notes
# 노트 타입별 통계
type_stats = db.query(
NoteDocument.note_type,
func.count(NoteDocument.id)
).group_by(NoteDocument.note_type).all()
note_types = {note_type: count for note_type, count in type_stats}
# 총 단어 수와 읽기 시간
totals = db.query(
func.sum(NoteDocument.word_count),
func.sum(NoteDocument.reading_time)
).first()
total_words = totals[0] or 0
total_reading_time = totals[1] or 0
# 최근 노트 (5개)
recent_notes_query = db.query(NoteDocument).order_by(
desc(NoteDocument.updated_at)
).limit(5)
recent_notes = []
for note in recent_notes_query.all():
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
recent_notes.append(note_item)
return NoteStats(
total_notes=total_notes,
published_notes=published_notes,
draft_notes=draft_notes,
note_types=note_types,
total_words=total_words,
total_reading_time=total_reading_time,
recent_notes=recent_notes
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
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 = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
@router.put("/{note_id}", response_model=NoteResponse)
async def update_note(
@router.get("/{note_id}", response_model=NoteDocumentResponse)
def get_note(
note_id: str,
note_data: UpdateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""메모 업데이트"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
"""특정 노트 조회"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
raise HTTPException(status_code=404, detail="Note not found")
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if note_data.content is not None:
note.content = note_data.content
if note_data.tags is not None:
note.tags = note_data.tags
if note_data.is_private is not None:
note.is_private = note_data.is_private
await db.commit()
await db.refresh(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 = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
return NoteDocumentResponse.from_orm(note)
@router.post("/", response_model=NoteDocumentResponse)
def create_note(
note_data: NoteDocumentCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트 생성"""
# HTML 내용 정리
cleaned_content = clean_html_content(note_data.content or "")
# 통계 계산
word_count = calculate_word_count(note_data.content or "")
reading_time = calculate_reading_time(note_data.content or "")
note = NoteDocument(
title=note_data.title,
content=cleaned_content,
note_type=note_data.note_type,
tags=note_data.tags,
is_published=note_data.is_published,
parent_note_id=note_data.parent_note_id,
sort_order=note_data.sort_order,
word_count=word_count,
reading_time=reading_time,
created_by=current_user.email
)
db.add(note)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.put("/{note_id}", response_model=NoteDocumentResponse)
def update_note(
note_id: str,
note_data: NoteDocumentUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 수정"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 수정 권한 확인 (작성자만 수정 가능)
if note.created_by != current_user.username and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 필드 업데이트
update_data = note_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(note, field, value)
# 내용이 변경된 경우 통계 재계산
if 'content' in update_data:
note.content = clean_html_content(note.content or "")
note.word_count = calculate_word_count(note.content or "")
note.reading_time = calculate_reading_time(note.content or "")
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.delete("/{note_id}")
async def delete_note(
def delete_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""메모 삭제 (하이라이트는 유지)"""
result = await db.execute(
select(Note)
.options(joinedload(Note.highlight))
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
"""노트 삭제"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
raise HTTPException(status_code=404, detail="Note not found")
# 소유자 확인
if note.highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 삭제 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 메모만 삭제 (하이라이트는 유지)
await db.execute(delete(Note).where(Note.id == note_id))
await db.commit()
# 자식 노트들의 parent_note_id를 NULL로 설정
db.query(NoteDocument).filter(
NoteDocument.parent_note_id == note_id
).update({"parent_note_id": None})
db.delete(note)
db.commit()
return {"message": "Note deleted successfully"}
@router.get("/document/{document_id}", response_model=List[NoteResponse])
async def get_document_notes(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
@router.get("/{note_id}/children", response_model=List[NoteDocumentListItem])
async def get_note_children(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 문서의 모든 메모 조회"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
"""노트의 자식 노트들 조회"""
children = db.query(NoteDocument).filter(
NoteDocument.parent_note_id == note_id
).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
result = []
for child in children:
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == child.id
).scalar()
child_item = NoteDocumentListItem.from_orm(child)
child_item.child_count = child_count
result.append(child_item)
# 문서 접근 권한 확인
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(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(
and_(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
)
)
.order_by(Highlight.start_offset)
)
notes = result.scalars().all()
# 응답 데이터 변환
response_data = []
for note in notes:
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 = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
return response_data
return result
@router.get("/tags/popular")
async def get_popular_note_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
@router.get("/{note_id}/export/html")
async def export_note_html(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""인기 메모 태그 조회"""
# 사용자의 메모에서 태그 빈도 계산
result = await db.execute(
select(Note)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
"""노트를 HTML 파일로 내보내기"""
from fastapi.responses import Response
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# HTML 템플릿 생성
html_template = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
h1, h2, h3 {{ color: #333; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="meta">
<strong>제목:</strong> {note.title}<br>
<strong>타입:</strong> {note.note_type}<br>
<strong>작성자:</strong> {note.created_by}<br>
<strong>작성일:</strong> {note.created_at.strftime('%Y-%m-%d %H:%M')}<br>
<strong>태그:</strong> {', '.join(note.tags) if note.tags else '없음'}
</div>
<hr>
{note.content or ''}
</body>
</html>"""
filename = f"{note.title.replace(' ', '_')}.html"
return Response(
content=html_template,
media_type="text/html",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
notes = result.scalars().all()
@router.get("/{note_id}/export/markdown")
async def export_note_markdown(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 마크다운 파일로 내보내기"""
from fastapi.responses import Response
# 태그 빈도 계산
tag_counts = {}
for note in notes:
if note.tags:
for tag in note.tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 빈도순 정렬
popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
# 메타데이터 포함한 마크다운
markdown_content = f"""---
title: {note.title}
type: {note.note_type}
author: {note.created_by}
created: {note.created_at.strftime('%Y-%m-%d %H:%M')}
tags: [{', '.join(note.tags) if note.tags else ''}]
---
# {note.title}
{note.content or ''}
"""
return [{"tag": tag, "count": count} for tag, count in popular_tags]
filename = f"{note.title.replace(' ', '_')}.md"
return Response(
content=markdown_content,
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)

View File

@@ -2,9 +2,9 @@
데이터베이스 설정 및 연결
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from typing import AsyncGenerator
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from sqlalchemy import MetaData, create_engine
from typing import AsyncGenerator, Generator
from .config import settings
@@ -35,6 +35,15 @@ engine = create_async_engine(
pool_recycle=300,
)
# 동기 데이터베이스 엔진 생성 (노트 API용)
sync_database_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
sync_engine = create_engine(
sync_database_url,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_recycle=300,
)
# 비동기 세션 팩토리
AsyncSessionLocal = async_sessionmaker(
engine,
@@ -42,9 +51,16 @@ AsyncSessionLocal = async_sessionmaker(
expire_on_commit=False,
)
# 동기 세션 팩토리
SyncSessionLocal = sessionmaker(
sync_engine,
class_=Session,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""데이터베이스 세션 의존성"""
"""비동기 데이터베이스 세션 의존성"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -55,6 +71,18 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
await session.close()
def get_sync_db() -> Generator[Session, None, None]:
"""동기 데이터베이스 세션 의존성 (노트 API용)"""
session = SyncSessionLocal()
try:
yield session
except Exception:
session.rollback()
raise
finally:
session.close()
async def init_db() -> None:
"""데이터베이스 초기화"""
from ..models import user, document, highlight, note, bookmark

View File

@@ -9,7 +9,8 @@ import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes
from .api.routes.notes import router as note_documents_router
@asynccontextmanager
@@ -53,6 +54,10 @@ app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]
app.include_router(search.router, prefix="/api/search", tags=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
app.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
@app.get("/")

View File

@@ -8,6 +8,10 @@ from .highlight import Highlight
from .note import Note
from .bookmark import Bookmark
from .document_link import DocumentLink
from .note_document import NoteDocument
from .notebook import Notebook
from .note_highlight import NoteHighlight
from .note_note import NoteNote
__all__ = [
"User",
@@ -18,4 +22,8 @@ __all__ = [
"Note",
"Bookmark",
"DocumentLink",
"NoteDocument",
"Notebook",
"NoteHighlight",
"NoteNote"
]

View File

@@ -0,0 +1,148 @@
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import uuid
from ..core.database import Base
class NoteDocument(Base):
"""노트 문서 모델"""
__tablename__ = "notes_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False)
content = Column(Text) # HTML 내용 (기본)
markdown_content = Column(Text) # 마크다운 내용 (선택사항)
note_type = Column(String(50), default='note') # note, research, summary, idea 등
tags = Column(ARRAY(String), default=[])
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
is_published = Column(Boolean, default=False)
parent_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True)
notebook_id = Column(UUID(as_uuid=True), ForeignKey('notebooks.id'), nullable=True)
sort_order = Column(Integer, default=0)
# 관계 설정
notebook = relationship("Notebook", back_populates="notes")
highlights = relationship("NoteHighlight", back_populates="note", cascade="all, delete-orphan")
notes = relationship("NoteNote", back_populates="note", cascade="all, delete-orphan")
word_count = Column(Integer, default=0)
reading_time = Column(Integer, default=0) # 예상 읽기 시간 (분)
# 관계
parent_note = relationship("NoteDocument", remote_side=[id], back_populates="child_notes")
child_notes = relationship("NoteDocument", back_populates="parent_note")
# Pydantic 모델들
class NoteDocumentBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
content: Optional[str] = None
note_type: str = Field(default='note', pattern='^(note|research|summary|idea|guide|reference)$')
tags: List[str] = Field(default=[])
is_published: bool = Field(default=False)
parent_note_id: Optional[str] = None
sort_order: int = Field(default=0)
class NoteDocumentCreate(NoteDocumentBase):
pass
class NoteDocumentUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=500)
content: Optional[str] = None
note_type: Optional[str] = Field(None, pattern='^(note|research|summary|idea|guide|reference)$')
tags: Optional[List[str]] = None
is_published: Optional[bool] = None
parent_note_id: Optional[str] = None
sort_order: Optional[int] = None
class NoteDocumentResponse(NoteDocumentBase):
id: str
markdown_content: Optional[str] = None
created_at: datetime
updated_at: datetime
created_by: str
word_count: int
reading_time: int
# 계층 구조 정보
parent_note: Optional['NoteDocumentResponse'] = None
child_notes: List['NoteDocumentResponse'] = []
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id), # UUID를 문자열로 변환
'title': obj.title,
'content': obj.content,
'note_type': obj.note_type,
'tags': obj.tags or [],
'is_published': obj.is_published,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'sort_order': obj.sort_order,
'markdown_content': obj.markdown_content,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'word_count': obj.word_count or 0,
'reading_time': obj.reading_time or 0,
}
return cls(**data)
# 자기 참조 관계를 위한 모델 업데이트
NoteDocumentResponse.model_rebuild()
class NoteDocumentListItem(BaseModel):
"""노트 목록용 간소화된 모델"""
id: str
title: str
note_type: str
tags: List[str]
created_at: datetime
updated_at: datetime
created_by: str
is_published: bool
word_count: int
reading_time: int
parent_note_id: Optional[str] = None
child_count: int = 0 # 자식 노트 개수
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, child_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id), # UUID를 문자열로 변환
'title': obj.title,
'note_type': obj.note_type,
'tags': obj.tags or [],
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_published': obj.is_published,
'word_count': obj.word_count or 0,
'reading_time': obj.reading_time or 0,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'child_count': child_count,
}
return cls(**data)
class NoteStats(BaseModel):
"""노트 통계 정보"""
total_notes: int
published_notes: int
draft_notes: int
note_types: dict # {type: count}
total_words: int
total_reading_time: int
recent_notes: List[NoteDocumentListItem]

View File

@@ -0,0 +1,69 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
import uuid
from ..core.database import Base
class NoteHighlight(Base):
"""노트 하이라이트 모델"""
__tablename__ = "note_highlights"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
start_offset = Column(Integer, nullable=False)
end_offset = Column(Integer, nullable=False)
selected_text = Column(Text, nullable=False)
highlight_color = Column(String(50), nullable=False, default='#FFFF00')
highlight_type = Column(String(50), nullable=False, default='highlight')
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
# 관계
note = relationship("NoteDocument", back_populates="highlights")
notes = relationship("NoteNote", back_populates="highlight", cascade="all, delete-orphan")
# Pydantic 모델들
class NoteHighlightBase(BaseModel):
note_id: str
start_offset: int
end_offset: int
selected_text: str
highlight_color: str = '#FFFF00'
highlight_type: str = 'highlight'
class NoteHighlightCreate(NoteHighlightBase):
pass
class NoteHighlightUpdate(BaseModel):
highlight_color: Optional[str] = None
highlight_type: Optional[str] = None
class NoteHighlightResponse(NoteHighlightBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
return cls(
id=str(obj.id),
note_id=str(obj.note_id),
start_offset=obj.start_offset,
end_offset=obj.end_offset,
selected_text=obj.selected_text,
highlight_color=obj.highlight_color,
highlight_type=obj.highlight_type,
created_at=obj.created_at,
updated_at=obj.updated_at,
created_by=obj.created_by
)

View File

@@ -0,0 +1,59 @@
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
import uuid
from ..core.database import Base
class NoteNote(Base):
"""노트의 메모 모델 (노트 안의 하이라이트에 대한 메모)"""
__tablename__ = "note_notes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
highlight_id = Column(UUID(as_uuid=True), ForeignKey("note_highlights.id", ondelete="CASCADE"), nullable=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
# 관계
note = relationship("NoteDocument", back_populates="notes")
highlight = relationship("NoteHighlight", back_populates="notes")
# Pydantic 모델들
class NoteNoteBase(BaseModel):
note_id: str
highlight_id: Optional[str] = None
content: str
class NoteNoteCreate(NoteNoteBase):
pass
class NoteNoteUpdate(BaseModel):
content: Optional[str] = None
class NoteNoteResponse(NoteNoteBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
return cls(
id=str(obj.id),
note_id=str(obj.note_id),
highlight_id=str(obj.highlight_id) if obj.highlight_id else None,
content=obj.content,
created_at=obj.created_at,
updated_at=obj.updated_at,
created_by=obj.created_by
)

View File

@@ -0,0 +1,126 @@
"""
노트북 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import uuid
from ..core.database import Base
class Notebook(Base):
"""노트북 테이블"""
__tablename__ = "notebooks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
color = Column(String(7), default='#3B82F6') # 헥스 컬러 코드
icon = Column(String(50), default='book') # FontAwesome 아이콘
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
# 관계 설정 (노트들)
notes = relationship("NoteDocument", back_populates="notebook")
# Pydantic 모델들
class NotebookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
description: Optional[str] = None
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
icon: str = Field(default='book', min_length=1, max_length=50)
is_active: bool = True
sort_order: int = 0
class NotebookCreate(NotebookBase):
pass
class NotebookUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=500)
description: Optional[str] = None
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
icon: Optional[str] = Field(None, min_length=1, max_length=50)
is_active: Optional[bool] = None
sort_order: Optional[int] = None
class NotebookResponse(NotebookBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
note_count: int = 0 # 포함된 노트 개수
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, note_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id),
'title': obj.title,
'description': obj.description,
'color': obj.color,
'icon': obj.icon,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_active': obj.is_active,
'sort_order': obj.sort_order,
'note_count': note_count,
}
return cls(**data)
class NotebookListItem(BaseModel):
"""노트북 목록용 간소화된 모델"""
id: str
title: str
description: Optional[str]
color: str
icon: str
created_at: datetime
updated_at: datetime
created_by: str
is_active: bool
note_count: int = 0
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, note_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id),
'title': obj.title,
'description': obj.description,
'color': obj.color,
'icon': obj.icon,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_active': obj.is_active,
'note_count': note_count,
}
return cls(**data)
class NotebookStats(BaseModel):
"""노트북 통계 정보"""
total_notebooks: int
active_notebooks: int
total_notes: int
notes_without_notebook: int

View File

@@ -8,8 +8,8 @@
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
</div>
<!-- 메인 네비게이션 - 2가지 기능 -->
<nav class="flex space-x-8">
<!-- 메인 네비게이션 - 3가지 기능 -->
<nav class="flex space-x-6">
<!-- 문서 관리 시스템 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="index.html" class="nav-link" id="doc-nav-link">
@@ -27,11 +27,11 @@
</div>
</div>
<!-- 메모장 시스템 -->
<!-- 소설 관리 시스템 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="memo-tree.html" class="nav-link" id="memo-nav-link">
<i class="fas fa-sitemap"></i>
<span>메모장</span>
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
<i class="fas fa-feather-alt"></i>
<span>소설 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
@@ -43,6 +43,26 @@
</a>
</div>
</div>
<!-- 노트 관리 시스템 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="notes.html" class="nav-link" id="notes-nav-link">
<i class="fas fa-sticky-note"></i>
<span>노트 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
</a>
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
</a>
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
</a>
</div>
</div>
</nav>
<!-- 사용자 메뉴 -->
@@ -114,12 +134,17 @@
isDocumentPage() {
const page = this.getCurrentPage();
return ['index', 'hierarchy'].includes(page);
return ['index', 'hierarchy', 'pdf-manager'].includes(page);
},
isMemoPage() {
isNovelPage() {
const page = this.getCurrentPage();
return ['memo-tree', 'story-view'].includes(page);
return ['memo-tree', 'story-view', 'story-reader'].includes(page);
},
isNotePage() {
const page = this.getCurrentPage();
return ['notes', 'note-editor'].includes(page);
}
};
@@ -129,7 +154,8 @@
Alpine.store('header', {
getCurrentPage: () => headerUtils.getCurrentPage(),
isDocumentPage: () => headerUtils.isDocumentPage(),
isMemoPage: () => headerUtils.isMemoPage(),
isNovelPage: () => headerUtils.isNovelPage(),
isNotePage: () => headerUtils.isNotePage(),
});
} else {
// Alpine 로드 대기
@@ -137,7 +163,8 @@
Alpine.store('header', {
getCurrentPage: () => headerUtils.getCurrentPage(),
isDocumentPage: () => headerUtils.isDocumentPage(),
isMemoPage: () => headerUtils.isMemoPage(),
isNovelPage: () => headerUtils.isNovelPage(),
isNotePage: () => headerUtils.isNotePage(),
});
});
}

View File

@@ -88,6 +88,11 @@
class="px-3 py-2 rounded-md">
<i class="fas fa-list mr-2"></i>목차
</button>
<button onclick="window.location.href='/notes.html'"
class="px-3 py-2 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">
<i class="fas fa-sticky-note mr-2"></i>노트
</button>
</div>
</div>
@@ -487,6 +492,6 @@
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012380"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/main.js?v=2025012374"></script>
<script src="/static/js/main.js?v=2025012462"></script>
</body>
</html>

202
frontend/note-editor.html Normal file
View File

@@ -0,0 +1,202 @@
<!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">
<!-- Quill.js (WYSIWYG HTML 에디터) -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<style>
.ql-editor {
min-height: 400px;
font-size: 16px;
line-height: 1.6;
}
.ql-toolbar {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
.ql-container {
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="noteEditorApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-edit text-blue-600 mr-3"></i>
<span x-text="isEditing ? '노트 편집' : '새 노트 작성'"></span>
</h1>
<p class="text-gray-600 mt-2">HTML 에디터로 풍부한 노트를 작성하세요</p>
</div>
<div class="flex space-x-3">
<button @click="goBack()"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>
<span>돌아가기</span>
</button>
<button @click="saveNote()"
:disabled="saving || !noteData.title"
:class="saving || !noteData.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="flex items-center px-4 py-2 text-white rounded-lg transition-colors">
<i class="fas fa-save mr-2" :class="{'fa-spin': saving}"></i>
<span x-text="saving ? '저장 중...' : '저장'"></span>
</button>
</div>
</div>
</div>
<!-- 노트 설정 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- 제목 -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text"
x-model="noteData.title"
placeholder="노트 제목을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 노트북 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
<select x-model="noteData.notebook_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option>
</template>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<!-- 노트 타입 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
<select x-model="noteData.note_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="note">일반 노트</option>
<option value="research">연구 노트</option>
<option value="summary">요약</option>
<option value="idea">아이디어</option>
<option value="guide">가이드</option>
<option value="reference">참고 자료</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<!-- 태그 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input type="text"
x-model="tagInput"
@keydown.enter.prevent="addTag()"
placeholder="태그를 입력하고 Enter를 누르세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<!-- 태그 목록 -->
<div x-show="noteData.tags.length > 0" class="mt-3 flex flex-wrap gap-2">
<template x-for="(tag, index) in noteData.tags" :key="index">
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
<span x-text="tag"></span>
<button @click="removeTag(index)" class="ml-2 text-blue-600 hover:text-blue-800">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</template>
</div>
</div>
<!-- 공개 설정 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">공개 설정</label>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="radio"
x-model="noteData.is_published"
:value="false"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">초안</span>
</label>
<label class="flex items-center">
<input type="radio"
x-model="noteData.is_published"
:value="true"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">공개</span>
</label>
</div>
</div>
</div>
</div>
<!-- HTML 에디터 -->
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">노트 내용</h3>
<div class="flex items-center space-x-4">
<button @click="toggleEditorMode()"
class="text-sm text-gray-600 hover:text-gray-800">
<i class="fas fa-code mr-1"></i>
<span x-text="editorMode === 'wysiwyg' ? 'HTML 코드' : 'WYSIWYG'"></span>
</button>
<span class="text-sm text-gray-500" x-text="getWordCount() + '자'"></span>
</div>
</div>
</div>
<!-- WYSIWYG 에디터 -->
<div x-show="editorMode === 'wysiwyg'" id="quill-editor"></div>
<!-- HTML 코드 에디터 -->
<div x-show="editorMode === 'html'" class="p-4">
<textarea x-model="noteData.content"
rows="20"
placeholder="HTML 코드를 직접 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
style="resize: vertical;"></textarea>
</div>
</div>
<!-- 미리보기 -->
<div x-show="noteData.content" class="mt-6 bg-white rounded-lg shadow-sm border">
<div class="p-4 border-b bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">미리보기</h3>
</div>
<div class="p-6">
<div x-html="noteData.content" class="prose max-w-none"></div>
</div>
</div>
</main>
<!-- 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/note-editor.js?v=2025012608"></script>
</body>
</html>

330
frontend/notebooks.html Normal file
View File

@@ -0,0 +1,330 @@
<!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>
.notebook-card {
transition: all 0.3s ease;
border-left: 4px solid var(--notebook-color);
}
.notebook-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.notebook-icon {
color: var(--notebook-color);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="notebooksApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-book text-blue-600 mr-3"></i>
노트북 관리
</h1>
<p class="text-gray-600 mt-2">노트들을 체계적으로 분류하고 관리하세요</p>
</div>
<div class="flex space-x-3">
<button onclick="window.location.href='/notes.html'"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-sticky-note mr-2"></i>
<span>노트 관리</span>
</button>
<button @click="refreshNotebooks()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
<button @click="showCreateModal = true"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>
<span>새 노트북</span>
</button>
</div>
</div>
</div>
<!-- 통계 카드 -->
<div x-show="stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100">
<i class="fas fa-book text-blue-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">전체 노트북</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notebooks || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100">
<i class="fas fa-check-circle text-green-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">활성 노트북</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.active_notebooks || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100">
<i class="fas fa-sticky-note text-purple-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">전체 노트</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-orange-100">
<i class="fas fa-folder-open text-orange-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">미분류 노트</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.notes_without_notebook || 0"></p>
</div>
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div class="flex-1 max-w-md">
<div class="relative">
<input type="text"
x-model="searchQuery"
@input="debounceSearch()"
placeholder="노트북 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox"
x-model="activeOnly"
@change="loadNotebooks()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">활성 노트북만</span>
</label>
<select x-model="sortBy"
@change="loadNotebooks()"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="updated_at">최근 수정순</option>
<option value="created_at">생성일순</option>
<option value="title">제목순</option>
<option value="sort_order">정렬순</option>
</select>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-600">노트북을 불러오는 중...</p>
</div>
<!-- 오류 메시지 -->
<div x-show="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span x-text="error"></span>
</div>
</div>
<!-- 노트북 그리드 -->
<div x-show="!loading && notebooks.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="notebook in notebooks" :key="notebook.id">
<div class="notebook-card bg-white rounded-lg shadow-sm border p-6 cursor-pointer"
:style="`--notebook-color: ${notebook.color}`"
@click="openNotebook(notebook)">
<!-- 노트북 헤더 -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-center">
<i :class="`fas fa-${notebook.icon} notebook-icon text-2xl mr-3`"></i>
<div>
<h3 class="text-lg font-semibold text-gray-900" x-text="notebook.title"></h3>
<p class="text-sm text-gray-500" x-text="`${notebook.note_count}개 노트`"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click.stop="editNotebook(notebook)"
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors">
<i class="fas fa-edit"></i>
</button>
<button @click.stop="deleteNotebook(notebook)"
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- 노트북 설명 -->
<div x-show="notebook.description" class="mb-4">
<p class="text-gray-600 text-sm line-clamp-2" x-text="notebook.description"></p>
</div>
<!-- 노트북 메타데이터 -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span x-text="formatDate(notebook.updated_at)"></span>
<div class="flex items-center space-x-2">
<span x-show="!notebook.is_active" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
비활성
</span>
<span class="px-2 py-1 rounded-full text-white"
:style="`background-color: ${notebook.color}`"
x-text="notebook.created_by">
</span>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && notebooks.length === 0" class="text-center py-16">
<i class="fas fa-book 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">첫 번째 노트북을 만들어 노트들을 정리해보세요</p>
<button @click="showCreateModal = true"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-plus mr-2"></i>
새 노트북 만들기
</button>
</div>
</main>
<!-- 노트북 생성/편집 모달 -->
<div x-show="showCreateModal || showEditModal"
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-md w-full p-6"
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 mb-6">
<h3 class="text-lg font-semibold text-gray-900"
x-text="showEditModal ? '노트북 편집' : '새 노트북 만들기'"></h3>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="saveNotebook()">
<!-- 제목 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text"
x-model="notebookForm.title"
placeholder="노트북 제목을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 설명 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="notebookForm.description"
rows="3"
placeholder="노트북에 대한 설명을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
<!-- 색상과 아이콘 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
<div class="flex flex-wrap gap-2">
<template x-for="color in availableColors" :key="color">
<button type="button"
@click="notebookForm.color = color"
:class="notebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
:style="`background-color: ${color}`">
</button>
</template>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
<select x-model="notebookForm.icon"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<template x-for="icon in availableIcons" :key="icon.value">
<option :value="icon.value" x-text="icon.label"></option>
</template>
</select>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="submit"
:disabled="saving || !notebookForm.title"
:class="saving || !notebookForm.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!saving" x-text="showEditModal ? '수정' : '생성'"></span>
<span x-show="saving">저장 중...</span>
</button>
</div>
</form>
</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/notebooks.js?v=2025012609"></script>
</body>
</html>

423
frontend/notes.html Normal file
View File

@@ -0,0 +1,423 @@
<!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>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="notesApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-sticky-note text-blue-600 mr-3"></i>
노트 관리
</h1>
<p class="text-gray-600 mt-2">마크다운으로 노트를 작성하고 서적과 연결하여 관리하세요</p>
</div>
<div class="flex space-x-3">
<button onclick="window.location.href='/'"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>
<span>돌아가기</span>
</button>
<button @click="refreshNotes()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
<button onclick="window.location.href='/note-editor.html'"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>
<span>새 노트 작성</span>
</button>
</div>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" x-show="stats">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-sticky-note text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">전체 노트</h3>
<p class="text-2xl font-bold text-blue-600" x-text="stats?.total_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-eye text-green-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">공개 노트</h3>
<p class="text-2xl font-bold text-green-600" x-text="stats?.published_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-edit text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">초안</h3>
<p class="text-2xl font-bold text-yellow-600" x-text="stats?.draft_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-clock text-purple-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">읽기 시간</h3>
<p class="text-2xl font-bold text-purple-600" x-text="(stats?.total_reading_time || 0) + '분'"></p>
</div>
</div>
</div>
</div>
<!-- 일괄 작업 도구 -->
<div x-show="selectedNotes.length > 0"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-blue-900">
<span x-text="selectedNotes.length"></span>개 노트 선택됨
</span>
<button @click="clearSelection()"
class="text-sm text-blue-600 hover:text-blue-800">
선택 해제
</button>
</div>
<div class="flex items-center space-x-3">
<!-- 노트북 할당 -->
<select x-model="bulkNotebookId"
class="px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">노트북 선택...</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option>
</template>
</select>
<button @click="assignToNotebook()"
:disabled="!bulkNotebookId"
:class="!bulkNotebookId ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-lg transition-colors text-sm">
노트북에 할당
</button>
<button @click="showCreateNotebookModal = true"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm">
새 노트북 만들기
</button>
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<!-- 검색 -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
<div class="relative">
<input type="text"
x-model="searchQuery"
@input="debounceSearch()"
placeholder="제목이나 내용으로 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- 노트북 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
<select x-model="selectedNotebook" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">전체</option>
<option value="unassigned">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option>
</template>
</select>
</div>
<!-- 노트 타입 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
<select x-model="selectedType" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">전체</option>
<option value="note">일반 노트</option>
<option value="research">연구 노트</option>
<option value="summary">요약</option>
<option value="idea">아이디어</option>
<option value="guide">가이드</option>
<option value="reference">참고 자료</option>
</select>
</div>
<!-- 공개 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select x-model="publishedOnly" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option :value="false">전체</option>
<option :value="true">공개만</option>
</select>
</div>
</div>
</div>
<!-- 노트 목록 -->
<div class="bg-white rounded-lg shadow-sm border">
<!-- 로딩 상태 -->
<div x-show="loading" class="p-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">노트를 불러오는 중...</p>
</div>
<!-- 노트 카드들 -->
<div x-show="!loading && notes.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
<template x-for="note in notes" :key="note.id">
<div class="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer relative"
:class="selectedNotes.includes(note.id) ? 'ring-2 ring-blue-500 bg-blue-50' : ''"
@click="viewNote(note.id)">
<!-- 선택 체크박스 -->
<div class="absolute top-4 left-4">
<input type="checkbox"
:checked="selectedNotes.includes(note.id)"
@click.stop="toggleNoteSelection(note.id)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
</div>
<!-- 노트 헤더 -->
<div class="flex items-start justify-between mb-4 ml-8">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2 mb-2" x-text="note.title"></h3>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<span class="px-2 py-1 bg-gray-100 rounded-full text-xs" x-text="getNoteTypeLabel(note.note_type)"></span>
<span x-show="note.is_published" class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">공개</span>
<span x-show="!note.is_published" class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs">초안</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button @click.stop="editNote(note.id)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="편집">
<i class="fas fa-edit"></i>
</button>
<button @click.stop="deleteNote(note)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- 태그 -->
<div x-show="note.tags && note.tags.length > 0" class="mb-4">
<div class="flex flex-wrap gap-1">
<template x-for="tag in note.tags.slice(0, 3)" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
<span x-show="note.tags.length > 3" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
+<span x-text="note.tags.length - 3"></span>
</span>
</div>
</div>
<!-- 통계 정보 -->
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
<div class="flex items-center space-x-4">
<span class="flex items-center">
<i class="fas fa-font mr-1"></i>
<span x-text="note.word_count"></span>
</span>
<span class="flex items-center">
<i class="fas fa-clock mr-1"></i>
<span x-text="note.reading_time"></span>
</span>
<span x-show="note.child_count > 0" class="flex items-center">
<i class="fas fa-sitemap mr-1"></i>
<span x-text="note.child_count"></span>
</span>
</div>
</div>
<!-- 작성 정보 -->
<div class="text-xs text-gray-400 border-t pt-3">
<div class="flex items-center justify-between">
<span x-text="note.created_by"></span>
<span x-text="formatDate(note.updated_at)"></span>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && notes.length === 0" class="p-8 text-center">
<i class="fas fa-sticky-note text-gray-400 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">노트가 없습니다</h3>
<p class="text-gray-500 mb-6">첫 번째 노트를 작성해보세요</p>
<button @click="createNewNote()"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
<i class="fas fa-plus mr-2"></i>
새 노트 작성
</button>
</div>
</div>
</main>
<!-- 노트북 생성 모달 -->
<div x-show="showCreateNotebookModal"
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-md w-full p-6"
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 mb-6">
<h3 class="text-lg font-semibold text-gray-900">새 노트북 만들기</h3>
<button @click="closeCreateNotebookModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="createNotebookAndAssign()">
<!-- 제목 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">노트북 이름 *</label>
<input type="text"
x-model="newNotebookForm.name"
placeholder="노트북 이름을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 설명 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="newNotebookForm.description"
rows="3"
placeholder="노트북에 대한 설명을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
<!-- 색상과 아이콘 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
<div class="flex flex-wrap gap-2">
<template x-for="color in availableColors" :key="color">
<button type="button"
@click="newNotebookForm.color = color"
:class="newNotebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
:style="`background-color: ${color}`">
</button>
</template>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
<select x-model="newNotebookForm.icon"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<template x-for="icon in availableIcons" :key="icon.value">
<option :value="icon.value" x-text="icon.label"></option>
</template>
</select>
</div>
</div>
<!-- 선택된 노트 정보 -->
<div x-show="selectedNotes.length > 0" class="mb-4 p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<i class="fas fa-info-circle mr-1"></i>
선택된 <span x-text="selectedNotes.length"></span>개의 노트가 이 노트북에 할당됩니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeCreateNotebookModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="submit"
:disabled="creatingNotebook || !newNotebookForm.name"
:class="creatingNotebook || !newNotebookForm.name ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!creatingNotebook">생성 및 할당</span>
<span x-show="creatingNotebook">생성 중...</span>
</button>
</div>
</form>
</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/notes.js?v=2025012610"></script>
</body>
</html>

View File

@@ -3,12 +3,12 @@
*/
class DocumentServerAPI {
constructor() {
// 도커 백엔드 API (24102 포트)
this.baseURL = 'http://localhost:24102/api';
// nginx를 통한 프록시 API (24100 포트)
this.baseURL = 'http://localhost:24100/api';
this.token = localStorage.getItem('access_token');
console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL);
console.log('🔧 도커 환경 설정 완료 - 버전 2025012415');
console.log('🌐 API Base URL (NGINX PROXY):', this.baseURL);
console.log('🔧 nginx 프록시 환경 설정 완료 - 버전 2025012607');
}
// 토큰 설정
@@ -221,7 +221,8 @@ class DocumentServerAPI {
return await this.delete(`/highlights/${highlightId}`);
}
// 메모 관련 API
// === 하이라이트 메모 (Highlight Memo) 관련 API ===
// 용어 정의: 하이라이트에 달리는 짧은 코멘트
async createNote(noteData) {
return await this.post('/notes/', noteData);
}
@@ -230,6 +231,8 @@ class DocumentServerAPI {
return await this.get('/notes/', params);
}
// === 문서 메모 조회 ===
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
@@ -319,6 +322,8 @@ class DocumentServerAPI {
}
// === 메모 관련 API ===
// === 문서 메모 조회 ===
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
@@ -441,6 +446,8 @@ class DocumentServerAPI {
}
// === 메모 관련 API ===
// === 문서 메모 조회 ===
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`);
}
@@ -553,6 +560,96 @@ class DocumentServerAPI {
async getDocumentLinkFragments(documentId) {
return await this.get(`/documents/${documentId}/link-fragments`);
}
// ===== 노트 문서 관련 API =====
// 모든 노트 조회
async getNoteDocuments(params = {}) {
return await this.get('/note-documents/', params);
}
// 특정 노트 조회
async getNoteDocument(noteId) {
return await this.get(`/note-documents/${noteId}`);
}
// === 노트 문서 (Note Document) 관련 API ===
// 용어 정의: 독립적인 문서 작성 (HTML 기반)
async createNoteDocument(noteData) {
return await this.post('/note-documents/', noteData);
}
// 노트 업데이트
async updateNoteDocument(noteId, noteData) {
return await this.put(`/note-documents/${noteId}`, noteData);
}
// 노트 삭제
async deleteNoteDocument(noteId) {
return await this.delete(`/note-documents/${noteId}`);
}
// 노트 HTML 내보내기
async exportNoteAsHTML(noteId) {
const response = await fetch(`${this.baseURL}/note-documents/${noteId}/export/html`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.blob();
}
// ===== 노트북 관련 API =====
// 모든 노트북 조회
async getNotebooks(params = {}) {
return await this.get('/notebooks/', params);
}
// 특정 노트북 조회
async getNotebook(notebookId) {
return await this.get(`/notebooks/${notebookId}`);
}
// === 노트북 (Notebook) 관련 API ===
// 용어 정의: 노트 문서들을 그룹화하는 폴더
async createNotebook(notebookData) {
return await this.post('/notebooks/', notebookData);
}
// 노트북 업데이트
async updateNotebook(notebookId, notebookData) {
return await this.put(`/notebooks/${notebookId}`, notebookData);
}
// 노트북 삭제
async deleteNotebook(notebookId, force = false) {
return await this.delete(`/notebooks/${notebookId}?force=${force}`);
}
// 노트북 통계
async getNotebookStats() {
return await this.get('/notebooks/stats');
}
// 노트북의 노트들 조회
async getNotebookNotes(notebookId, params = {}) {
return await this.get(`/notebooks/${notebookId}/notes`, params);
}
// 노트를 노트북에 추가
async addNoteToNotebook(notebookId, noteId) {
return await this.post(`/notebooks/${notebookId}/notes/${noteId}`);
}
// 노트를 노트북에서 제거
async removeNoteFromNotebook(notebookId, noteId) {
return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`);
}
}
// 전역 API 인스턴스

View File

@@ -125,7 +125,18 @@ window.documentApp = () => ({
this.error = '';
try {
this.documents = await window.api.getDocuments();
const allDocuments = await window.api.getDocuments();
// HTML 문서만 필터링 (PDF 파일 제외)
this.documents = allDocuments.filter(doc =>
doc.html_path &&
doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
);
console.log('📄 전체 문서:', allDocuments.length, '개');
console.log('📄 HTML 문서:', this.documents.length, '개');
console.log('📄 PDF 파일:', allDocuments.length - this.documents.length, '개 (제외됨)');
this.updateAvailableTags();
this.filterDocuments();
this.syncUIState(); // UI 상태 동기화

View File

@@ -0,0 +1,333 @@
function noteEditorApp() {
return {
// 상태 관리
noteData: {
title: '',
content: '',
note_type: 'note',
tags: [],
is_published: false,
parent_note_id: null,
sort_order: 0,
notebook_id: null
},
// 노트북 관련
availableNotebooks: [],
// UI 상태
loading: false,
saving: false,
error: null,
isEditing: false,
noteId: null,
// 에디터 관련
quillEditor: null,
editorMode: 'wysiwyg', // 'wysiwyg' 또는 'html'
tagInput: '',
// 인증 관련
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
async init() {
console.log('📝 노트 에디터 초기화 시작');
try {
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
console.log('🔧 API 클라이언트 초기화됨:', this.api);
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
// 헤더 로드
await this.loadHeader();
// 인증 상태 확인
await this.checkAuthStatus();
if (!this.isAuthenticated) {
window.location.href = '/';
return;
}
// URL에서 노트 ID 확인 (편집 모드)
const urlParams = new URLSearchParams(window.location.search);
this.noteId = urlParams.get('id');
// 노트북 목록 로드
await this.loadNotebooks();
if (this.noteId) {
this.isEditing = true;
await this.loadNote(this.noteId);
}
// Quill 에디터 초기화
this.initQuillEditor();
console.log('✅ 노트 에디터 초기화 완료');
} catch (error) {
console.error('❌ 노트 에디터 초기화 실패:', error);
this.error = '노트 에디터를 초기화하는 중 오류가 발생했습니다.';
}
},
async loadHeader() {
try {
if (typeof loadHeaderComponent === 'function') {
await loadHeaderComponent();
} else if (typeof window.loadHeaderComponent === 'function') {
await window.loadHeaderComponent();
} else {
console.warn('헤더 로더 함수를 찾을 수 없습니다. 수동으로 헤더를 로드합니다.');
// 수동으로 헤더 로드
const headerContainer = document.getElementById('header-container');
if (headerContainer) {
const response = await fetch('/components/header.html');
const headerHTML = await response.text();
headerContainer.innerHTML = headerHTML;
}
}
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
async checkAuthStatus() {
try {
const response = await this.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = response;
console.log('✅ 인증된 사용자:', this.currentUser.username);
} catch (error) {
console.log('❌ 인증되지 않은 사용자');
this.isAuthenticated = false;
this.currentUser = null;
}
},
async loadNotebooks() {
try {
console.log('📚 노트북 로드 시작...');
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
// 임시: 직접 API 호출
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
} catch (error) {
console.error('노트북 로드 실패:', error);
this.availableNotebooks = [];
}
},
initQuillEditor() {
// Quill 에디터 설정
const toolbarOptions = [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'font': [] }],
[{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'direction': 'rtl' }],
[{ 'align': [] }],
['blockquote', 'code-block'],
['link', 'image', 'video'],
['clean']
];
this.quillEditor = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: toolbarOptions
},
placeholder: '노트 내용을 작성하세요...'
});
// 에디터 내용 변경 시 동기화
this.quillEditor.on('text-change', () => {
if (this.editorMode === 'wysiwyg') {
this.noteData.content = this.quillEditor.root.innerHTML;
}
});
// 기존 내용이 있으면 로드
if (this.noteData.content) {
this.quillEditor.root.innerHTML = this.noteData.content;
}
},
async loadNote(noteId) {
this.loading = true;
this.error = null;
try {
console.log('📖 노트 로드 중:', noteId);
const note = await this.api.getNoteDocument(noteId);
this.noteData = {
title: note.title || '',
content: note.content || '',
note_type: note.note_type || 'note',
tags: note.tags || [],
is_published: note.is_published || false,
parent_note_id: note.parent_note_id || null,
sort_order: note.sort_order || 0
};
console.log('✅ 노트 로드 완료:', this.noteData.title);
} catch (error) {
console.error('❌ 노트 로드 실패:', error);
this.error = '노트를 불러오는 중 오류가 발생했습니다.';
} finally {
this.loading = false;
}
},
async saveNote() {
if (!this.noteData.title.trim()) {
this.showNotification('제목을 입력해주세요.', 'error');
return;
}
this.saving = true;
this.error = null;
try {
// WYSIWYG 모드에서 HTML 동기화
if (this.editorMode === 'wysiwyg' && this.quillEditor) {
this.noteData.content = this.quillEditor.root.innerHTML;
}
console.log('💾 노트 저장 중:', this.noteData.title);
let result;
if (this.isEditing && this.noteId) {
// 기존 노트 업데이트
result = await this.api.updateNoteDocument(this.noteId, this.noteData);
console.log('✅ 노트 업데이트 완료');
} else {
// 새 노트 생성
result = await this.api.createNoteDocument(this.noteData);
console.log('✅ 새 노트 생성 완료');
// 편집 모드로 전환
this.isEditing = true;
this.noteId = result.id;
// URL 업데이트 (새로고침 없이)
const newUrl = `${window.location.pathname}?id=${result.id}`;
window.history.replaceState({}, '', newUrl);
}
this.showNotification('노트가 성공적으로 저장되었습니다.', 'success');
} catch (error) {
console.error('❌ 노트 저장 실패:', error);
this.error = '노트 저장 중 오류가 발생했습니다.';
this.showNotification('노트 저장에 실패했습니다.', 'error');
} finally {
this.saving = false;
}
},
toggleEditorMode() {
if (this.editorMode === 'wysiwyg') {
// WYSIWYG → HTML 코드
if (this.quillEditor) {
this.noteData.content = this.quillEditor.root.innerHTML;
}
this.editorMode = 'html';
} else {
// HTML 코드 → WYSIWYG
if (this.quillEditor) {
this.quillEditor.root.innerHTML = this.noteData.content || '';
}
this.editorMode = 'wysiwyg';
}
},
addTag() {
const tag = this.tagInput.trim();
if (tag && !this.noteData.tags.includes(tag)) {
this.noteData.tags.push(tag);
this.tagInput = '';
}
},
removeTag(index) {
this.noteData.tags.splice(index, 1);
},
getWordCount() {
if (!this.noteData.content) return 0;
// HTML 태그 제거 후 단어 수 계산
const textContent = this.noteData.content.replace(/<[^>]*>/g, '');
return textContent.length;
},
goBack() {
// 변경사항이 있으면 확인
if (this.hasUnsavedChanges()) {
if (!confirm('저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?')) {
return;
}
}
window.location.href = '/notes.html';
},
hasUnsavedChanges() {
// 간단한 변경사항 감지 (실제로는 더 정교하게 구현 가능)
return this.noteData.title.trim() !== '' || this.noteData.content.trim() !== '';
},
showNotification(message, type = 'info') {
// 간단한 알림 (나중에 더 정교한 토스트 시스템으로 교체 가능)
if (type === 'error') {
alert('❌ ' + message);
} else if (type === 'success') {
alert('✅ ' + message);
} else {
alert(' ' + message);
}
},
// 키보드 단축키
handleKeydown(event) {
// Ctrl+S (또는 Cmd+S): 저장
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
this.saveNote();
}
}
};
}
// 키보드 이벤트 리스너 등록
document.addEventListener('keydown', function(event) {
// Alpine.js 컴포넌트에 접근
const app = Alpine.$data(document.querySelector('[x-data]'));
if (app && app.handleKeydown) {
app.handleKeydown(event);
}
});
// 페이지 떠날 때 확인
window.addEventListener('beforeunload', function(event) {
const app = Alpine.$data(document.querySelector('[x-data]'));
if (app && app.hasUnsavedChanges && app.hasUnsavedChanges()) {
event.preventDefault();
event.returnValue = '저장하지 않은 변경사항이 있습니다.';
return event.returnValue;
}
});

View File

@@ -0,0 +1,277 @@
// 노트북 관리 애플리케이션 컴포넌트
window.notebooksApp = () => ({
// 상태 관리
notebooks: [],
stats: null,
loading: false,
saving: false,
error: '',
// 필터링
searchQuery: '',
activeOnly: true,
sortBy: 'updated_at',
// 검색 디바운스
searchTimeout: null,
// 모달 상태
showCreateModal: false,
showEditModal: false,
editingNotebook: null,
// 노트북 폼
notebookForm: {
title: '',
description: '',
color: '#3B82F6',
icon: 'book',
is_active: true,
sort_order: 0
},
// 인증 상태
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
// 색상 옵션
availableColors: [
'#3B82F6', // blue
'#10B981', // emerald
'#F59E0B', // amber
'#EF4444', // red
'#8B5CF6', // violet
'#06B6D4', // cyan
'#84CC16', // lime
'#F97316', // orange
'#EC4899', // pink
'#6B7280' // gray
],
// 아이콘 옵션
availableIcons: [
{ value: 'book', label: '📖 책' },
{ value: 'sticky-note', label: '📝 노트' },
{ value: 'lightbulb', label: '💡 아이디어' },
{ value: 'graduation-cap', label: '🎓 학습' },
{ value: 'briefcase', label: '💼 업무' },
{ value: 'heart', label: '❤️ 개인' },
{ value: 'code', label: '💻 개발' },
{ value: 'palette', label: '🎨 창작' },
{ value: 'flask', label: '🧪 연구' },
{ value: 'star', label: '⭐ 즐겨찾기' }
],
// 초기화
async init() {
console.log('📚 Notebooks App 초기화 시작');
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
// 인증 상태 확인
await this.checkAuthStatus();
if (this.isAuthenticated) {
await this.loadStats();
await this.loadNotebooks();
}
// 헤더 로드
await this.loadHeader();
},
// 인증 상태 확인
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;
window.location.href = '/';
}
},
// 헤더 로드
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);
}
},
// 통계 정보 로드
async loadStats() {
try {
this.stats = await this.api.getNotebookStats();
console.log('📊 노트북 통계 로드됨:', this.stats);
} catch (error) {
console.error('통계 로드 실패:', error);
}
},
// 노트북 목록 로드
async loadNotebooks() {
this.loading = true;
this.error = '';
try {
const queryParams = {
active_only: this.activeOnly,
sort_by: this.sortBy,
order: 'desc'
};
if (this.searchQuery) {
queryParams.search = this.searchQuery;
}
this.notebooks = await this.api.getNotebooks(queryParams);
console.log('📚 노트북 로드됨:', this.notebooks.length, '개');
} catch (error) {
console.error('노트북 로드 실패:', error);
this.error = '노트북을 불러오는 중 오류가 발생했습니다.';
} finally {
this.loading = false;
}
},
// 검색 디바운스
debounceSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.loadNotebooks();
}, 300);
},
// 새로고침
async refreshNotebooks() {
await Promise.all([
this.loadStats(),
this.loadNotebooks()
]);
},
// 노트북 열기 (노트 목록으로 이동)
openNotebook(notebook) {
window.location.href = `/notes.html?notebook_id=${notebook.id}&notebook_name=${encodeURIComponent(notebook.name)}`;
},
// 노트북 편집
editNotebook(notebook) {
this.editingNotebook = notebook;
this.notebookForm = {
title: notebook.title,
description: notebook.description || '',
color: notebook.color,
icon: notebook.icon,
is_active: notebook.is_active,
sort_order: notebook.sort_order
};
this.showEditModal = true;
},
// 노트북 삭제
async deleteNotebook(notebook) {
if (!confirm(`"${notebook.title}" 노트북을 삭제하시겠습니까?\n\n${notebook.note_count > 0 ? `포함된 ${notebook.note_count}개의 노트는 미분류 상태가 됩니다.` : ''}`)) {
return;
}
try {
await this.api.deleteNotebook(notebook.id, true); // force=true
this.showNotification('노트북이 삭제되었습니다.', 'success');
await this.refreshNotebooks();
} catch (error) {
console.error('노트북 삭제 실패:', error);
this.showNotification('노트북 삭제에 실패했습니다.', 'error');
}
},
// 노트북 저장
async saveNotebook() {
if (!this.notebookForm.title.trim()) {
this.showNotification('제목을 입력해주세요.', 'error');
return;
}
this.saving = true;
try {
if (this.showEditModal && this.editingNotebook) {
// 편집
await this.api.updateNotebook(this.editingNotebook.id, this.notebookForm);
this.showNotification('노트북이 수정되었습니다.', 'success');
} else {
// 생성
await this.api.createNotebook(this.notebookForm);
this.showNotification('노트북이 생성되었습니다.', 'success');
}
this.closeModal();
await this.refreshNotebooks();
} catch (error) {
console.error('노트북 저장 실패:', error);
this.showNotification('노트북 저장에 실패했습니다.', 'error');
} finally {
this.saving = false;
}
},
// 모달 닫기
closeModal() {
this.showCreateModal = false;
this.showEditModal = false;
this.editingNotebook = null;
this.notebookForm = {
title: '',
description: '',
color: '#3B82F6',
icon: 'book',
is_active: true,
sort_order: 0
};
},
// 날짜 포맷팅
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 {
return date.toLocaleDateString('ko-KR');
}
},
// 알림 표시
showNotification(message, type = 'info') {
if (type === 'error') {
alert('❌ ' + message);
} else if (type === 'success') {
alert('✅ ' + message);
} else {
alert(' ' + message);
}
}
});

404
frontend/static/js/notes.js Normal file
View File

@@ -0,0 +1,404 @@
// 노트 관리 애플리케이션 컴포넌트
window.notesApp = () => ({
// 상태 관리
notes: [],
stats: null,
loading: false,
error: '',
// 필터링
searchQuery: '',
selectedType: '',
publishedOnly: false,
selectedNotebook: '',
// 노트북 관련
availableNotebooks: [],
// 일괄 선택 관련
selectedNotes: [],
bulkNotebookId: '',
// 노트북 생성 관련
showCreateNotebookModal: false,
creatingNotebook: false,
newNotebookForm: {
name: '',
description: '',
color: '#3B82F6',
icon: 'book'
},
// 색상 및 아이콘 옵션
availableColors: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6B7280'
],
availableIcons: [
{ value: 'book', label: '📖 책' },
{ value: 'sticky-note', label: '📝 노트' },
{ value: 'lightbulb', label: '💡 아이디어' },
{ value: 'graduation-cap', label: '🎓 학습' },
{ value: 'briefcase', label: '💼 업무' },
{ value: 'heart', label: '❤️ 개인' },
{ value: 'code', label: '💻 개발' },
{ value: 'palette', label: '🎨 창작' },
{ value: 'flask', label: '🧪 연구' },
{ value: 'star', label: '⭐ 즐겨찾기' }
],
// 검색 디바운스
searchTimeout: null,
// 인증 상태
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
// 초기화
async init() {
console.log('🚀 Notes App 초기화 시작');
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
console.log('🔧 API 클라이언트 초기화됨:', this.api);
console.log('🔧 getNotebooks 메서드 존재 여부:', typeof this.api.getNotebooks);
// URL 파라미터 확인 (노트북 필터)
const urlParams = new URLSearchParams(window.location.search);
const notebookId = urlParams.get('notebook_id');
const notebookName = urlParams.get('notebook_name');
if (notebookId) {
this.selectedNotebook = notebookId;
console.log('🔍 노트북 필터 적용:', notebookName || notebookId);
}
// 인증 상태 확인
await this.checkAuthStatus();
if (this.isAuthenticated) {
await this.loadNotebooks();
await this.loadStats();
await this.loadNotes();
}
// 헤더 로드
await this.loadHeader();
},
// 인증 상태 확인
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;
// 로그인 페이지로 리다이렉트하지 않고 메인 페이지로
window.location.href = '/';
}
},
// 헤더 로드
async loadHeader() {
try {
await window.headerLoader.loadHeader();
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// 노트북 목록 로드
async loadNotebooks() {
try {
console.log('📚 노트북 로드 시작...');
console.log('🔧 API 메서드 확인:', typeof this.api.getNotebooks);
if (typeof this.api.getNotebooks !== 'function') {
throw new Error('getNotebooks 메서드가 존재하지 않습니다');
}
// 임시: 직접 API 호출
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
} catch (error) {
console.error('노트북 로드 실패:', error);
this.availableNotebooks = [];
}
},
// 통계 정보 로드
async loadStats() {
try {
this.stats = await this.api.get('/note-documents/stats');
console.log('📊 통계 로드됨:', this.stats);
} catch (error) {
console.error('통계 로드 실패:', error);
}
},
// 노트 목록 로드
async loadNotes() {
this.loading = true;
this.error = '';
try {
const queryParams = {};
if (this.searchQuery) {
queryParams.search = this.searchQuery;
}
if (this.selectedType) {
queryParams.note_type = this.selectedType;
}
if (this.publishedOnly) {
queryParams.published_only = 'true';
}
if (this.selectedNotebook) {
if (this.selectedNotebook === 'unassigned') {
queryParams.notebook_id = 'null';
} else {
queryParams.notebook_id = this.selectedNotebook;
}
}
this.notes = await this.api.getNoteDocuments(queryParams);
console.log('📝 노트 로드됨:', this.notes.length, '개');
} catch (error) {
console.error('노트 로드 실패:', error);
this.error = '노트를 불러오는데 실패했습니다: ' + error.message;
this.notes = [];
} finally {
this.loading = false;
}
},
// 검색 디바운스
debounceSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.loadNotes();
}, 500);
},
// 노트 새로고침
async refreshNotes() {
await Promise.all([
this.loadStats(),
this.loadNotes()
]);
},
// 새 노트 생성
createNewNote() {
window.location.href = '/note-editor.html';
},
// 노트 보기 (뷰어 페이지로 이동)
viewNote(noteId) {
window.location.href = `/viewer.html?type=note&id=${noteId}`;
},
// 노트 편집
editNote(noteId) {
window.location.href = `/note-editor.html?id=${noteId}`;
},
// 노트 삭제
async deleteNote(note) {
if (!confirm(`"${note.title}" 노트를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await fetch(`/api/note-documents/${note.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
this.showNotification('노트가 삭제되었습니다', 'success');
await this.refreshNotes();
} else {
throw new Error('삭제 실패');
}
} catch (error) {
console.error('노트 삭제 실패:', error);
this.showNotification('노트 삭제에 실패했습니다: ' + error.message, 'error');
}
},
// 노트 타입 라벨
getNoteTypeLabel(type) {
const labels = {
'note': '일반',
'research': '연구',
'summary': '요약',
'idea': '아이디어',
'guide': '가이드',
'reference': '참고'
};
return labels[type] || type;
},
// 날짜 포맷팅
formatDate(dateString) {
if (!dateString) return '';
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: 'numeric',
month: 'short',
day: 'numeric'
});
}
},
// 알림 표시
showNotification(message, type = 'info') {
console.log(`${type.toUpperCase()}: ${message}`);
// 간단한 토스트 알림 생성
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// 3초 후 제거
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 3000);
},
// === 일괄 선택 관련 메서드 ===
// 노트 선택/해제
toggleNoteSelection(noteId) {
const index = this.selectedNotes.indexOf(noteId);
if (index > -1) {
this.selectedNotes.splice(index, 1);
} else {
this.selectedNotes.push(noteId);
}
},
// 선택 해제
clearSelection() {
this.selectedNotes = [];
this.bulkNotebookId = '';
},
// 선택된 노트들을 노트북에 할당
async assignToNotebook() {
if (!this.bulkNotebookId || this.selectedNotes.length === 0) {
this.showNotification('노트북을 선택하고 노트를 선택해주세요.', 'error');
return;
}
try {
// 각 노트를 업데이트
const updatePromises = this.selectedNotes.map(noteId =>
this.api.put(`/note-documents/${noteId}`, { notebook_id: this.bulkNotebookId })
);
await Promise.all(updatePromises);
this.showNotification(`${this.selectedNotes.length}개 노트가 노트북에 할당되었습니다.`, 'success');
// 선택 해제 및 새로고침
this.clearSelection();
await this.loadNotes();
} catch (error) {
console.error('노트북 할당 실패:', error);
this.showNotification('노트북 할당에 실패했습니다.', 'error');
}
},
// === 노트북 생성 관련 메서드 ===
// 노트북 생성 모달 닫기
closeCreateNotebookModal() {
this.showCreateNotebookModal = false;
this.newNotebookForm = {
name: '',
description: '',
color: '#3B82F6',
icon: 'book'
};
},
// 노트북 생성 및 노트 할당
async createNotebookAndAssign() {
if (!this.newNotebookForm.name.trim()) {
this.showNotification('노트북 이름을 입력해주세요.', 'error');
return;
}
this.creatingNotebook = true;
try {
// 1. 노트북 생성
const newNotebook = await this.api.post('/notebooks/', this.newNotebookForm);
console.log('📚 새 노트북 생성됨:', newNotebook.name);
// 2. 선택된 노트들이 있으면 할당
if (this.selectedNotes.length > 0) {
const updatePromises = this.selectedNotes.map(noteId =>
this.api.put(`/note-documents/${noteId}`, { notebook_id: newNotebook.id })
);
await Promise.all(updatePromises);
console.log(`📝 ${this.selectedNotes.length}개 노트가 새 노트북에 할당됨`);
}
this.showNotification(
`노트북 "${newNotebook.name}"이 생성되었습니다.${this.selectedNotes.length > 0 ? ` ${this.selectedNotes.length}개 노트가 할당되었습니다.` : ''}`,
'success'
);
// 3. 정리 및 새로고침
this.closeCreateNotebookModal();
this.clearSelection();
await this.loadNotebooks();
await this.loadNotes();
} catch (error) {
console.error('노트북 생성 실패:', error);
this.showNotification('노트북 생성에 실패했습니다.', 'error');
} finally {
this.creatingNotebook = false;
}
}
});
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
console.log('📄 Notes 페이지 로드됨');
});

View File

@@ -0,0 +1,92 @@
/**
* 간단한 테스트용 documentViewer
*/
window.documentViewer = () => ({
// 기본 상태
loading: false,
error: null,
// 네비게이션
navigation: null,
// 검색
searchQuery: '',
// 데이터
notes: [],
bookmarks: [],
documentLinks: [],
backlinks: [],
// UI 상태
activeFeatureMenu: null,
selectedHighlightColor: '#FFFF00',
// 모달 상태
showLinksModal: false,
showLinkModal: false,
showNotesModal: false,
showBookmarksModal: false,
showBacklinksModal: false,
// 폼 데이터
linkForm: {
target_document_id: '',
selected_text: '',
book_scope: 'same',
target_book_id: '',
link_type: 'document',
target_text: '',
description: ''
},
// 기타 데이터
availableBooks: [],
filteredDocuments: [],
// 초기화
init() {
console.log('🔧 간단한 documentViewer 로드됨');
this.documentId = new URLSearchParams(window.location.search).get('id');
console.log('📋 문서 ID:', this.documentId);
},
// 뒤로가기
goBack() {
console.log('🔙 뒤로가기 클릭됨');
const urlParams = new URLSearchParams(window.location.search);
const fromPage = urlParams.get('from');
if (fromPage === 'index') {
window.location.href = '/index.html';
} else if (fromPage === 'hierarchy') {
window.location.href = '/hierarchy.html';
} else {
window.location.href = '/index.html';
}
},
// 기본 함수들
toggleFeatureMenu(feature) {
console.log('🎯 기능 메뉴 토글:', feature);
this.activeFeatureMenu = this.activeFeatureMenu === feature ? null : feature;
},
searchInDocument() {
console.log('🔍 문서 검색:', this.searchQuery);
},
// 빈 함수들 (오류 방지용)
navigateToDocument() { console.log('네비게이션 함수 호출됨'); },
goToBookContents() { console.log('목차로 이동 함수 호출됨'); },
createHighlightWithColor() { console.log('하이라이트 생성 함수 호출됨'); },
resetTargetSelection() { console.log('타겟 선택 리셋 함수 호출됨'); },
loadDocumentsFromBook() { console.log('서적 문서 로드 함수 호출됨'); },
onTargetDocumentChange() { console.log('타겟 문서 변경 함수 호출됨'); },
openTargetDocumentSelector() { console.log('타겟 문서 선택기 열기 함수 호출됨'); },
saveDocumentLink() { console.log('문서 링크 저장 함수 호출됨'); },
closeLinkModal() { console.log('링크 모달 닫기 함수 호출됨'); },
getSelectedBookTitle() { return '테스트 서적'; }
});
console.log('✅ 테스트용 documentViewer 정의됨');

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@
<title>문서 뷰어 - Document Server</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
@@ -332,23 +331,34 @@
<!-- 링크 목록 -->
<template x-for="link in documentLinks" :key="link.id">
<div class="bg-gray-50 rounded-xl p-4 mb-4 hover:bg-gray-100 transition-colors">
<div class="flex items-start justify-between">
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
@click="navigateToLink(link)">
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="font-semibold text-gray-900 mb-2" x-text="link.target_document_title"></h4>
<p class="text-sm text-gray-600 mb-2" x-text="link.selected_text"></p>
<p class="text-sm text-gray-500" x-text="link.description || '설명 없음'"></p>
<p class="text-xs text-gray-400 mt-2" x-text="new Date(link.created_at).toLocaleDateString()"></p>
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div>
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
<div x-show="link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 bg-gray-100 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
</div>
<div x-show="!link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div>
</div>
<!-- 설명 -->
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div>
<!-- 링크 타입과 날짜 -->
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500"
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span>
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
</div>
</div>
<div class="ml-4 flex space-x-2">
<button @click="navigateToLink(link)"
class="px-3 py-1 bg-purple-500 text-white text-sm rounded-lg hover:bg-purple-600 transition-colors">
이동
</button>
<button @click="deleteDocumentLink(link.id)"
class="px-3 py-1 bg-red-500 text-white text-sm rounded-lg hover:bg-red-600 transition-colors">
삭제
</button>
<div class="ml-3">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</div>
</div>
</div>
@@ -701,8 +711,13 @@
<p class="text-sm text-orange-800 font-medium" x-text="backlink.source_document_title"></p>
</div>
<!-- 선택된 텍스트 -->
<p class="text-gray-800 mb-2 leading-relaxed" x-text="backlink.selected_text"></p>
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
<div x-show="backlink.selected_text" class="mb-2">
<p class="text-gray-800 leading-relaxed" x-text="backlink.selected_text"></p>
</div>
<div x-show="!backlink.selected_text" class="mb-2">
<p class="text-gray-600 italic">📄 문서 전체 링크</p>
</div>
<!-- 설명 -->
<p class="text-sm text-gray-600 mb-2" x-text="backlink.description || '설명 없음'"></p>
@@ -723,12 +738,70 @@
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2025012415"></script>
<script src="/static/js/viewer.js?v=2025012458"></script>
<script src="/static/js/api.js?v=2025012614"></script>
<script src="/static/js/viewer.js?v=2025012641"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
[x-cloak] { display: none !important; }
/* 백링크 강제 스타일 */
.backlink-highlight {
color: #EA580C !important;
background-color: rgba(234, 88, 12, 0.3) !important;
border: 3px solid #EA580C !important;
border-radius: 6px !important;
padding: 6px 8px !important;
font-weight: bold !important;
text-decoration: underline !important;
cursor: pointer !important;
box-shadow: 0 4px 8px rgba(234, 88, 12, 0.4) !important;
display: inline-block !important;
margin: 2px !important;
}
/* 하이라이트 스타일 개선 */
.highlight {
padding: 1px 2px;
border-radius: 2px;
cursor: pointer;
transition: all 0.2s ease;
}
.highlight:hover {
box-shadow: 0 0 4px rgba(0,0,0,0.3);
transform: scale(1.02);
}
.multi-highlight {
padding: 1px 2px;
border-radius: 2px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.multi-highlight:hover {
box-shadow: 0 0 6px rgba(0,0,0,0.4);
transform: scale(1.02);
}
.multi-highlight::after {
content: "🎨";
position: absolute;
top: -8px;
right: -8px;
font-size: 10px;
background: white;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* 기존 언어 전환 버튼 숨기기 */
.language-toggle-old,
button[onclick*="toggleLanguage"],

Binary file not shown.