🐛 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:
37
README.md
37
README.md
@@ -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
|
||||
- [ ] 문서 통계 및 분석
|
||||
- [ ] 모바일 반응형 최적화
|
||||
|
||||
81
backend/migrations/009_create_notes_system.sql
Normal file
81
backend/migrations/009_create_notes_system.sql
Normal 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;
|
||||
25
backend/migrations/010_create_notebooks.sql
Normal file
25
backend/migrations/010_create_notebooks.sql
Normal 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');
|
||||
48
backend/migrations/011_create_note_highlights_and_notes.sql
Normal file
48
backend/migrations/011_create_note_highlights_and_notes.sql
Normal 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();
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ class HighlightResponse(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/", response_model=HighlightResponse)
|
||||
async def create_highlight(
|
||||
highlight_data: CreateHighlightRequest,
|
||||
|
||||
103
backend/src/api/routes/note_highlights.py
Normal file
103
backend/src/api/routes/note_highlights.py
Normal 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"}
|
||||
128
backend/src/api/routes/note_notes.py
Normal file
128
backend/src/api/routes/note_notes.py
Normal 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"}
|
||||
270
backend/src/api/routes/notebooks.py
Normal file
270
backend/src/api/routes/notebooks.py
Normal 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"}
|
||||
@@ -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}"}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
148
backend/src/models/note_document.py
Normal file
148
backend/src/models/note_document.py
Normal 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]
|
||||
69
backend/src/models/note_highlight.py
Normal file
69
backend/src/models/note_highlight.py
Normal 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
|
||||
)
|
||||
59
backend/src/models/note_note.py
Normal file
59
backend/src/models/note_note.py
Normal 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
|
||||
)
|
||||
126
backend/src/models/notebook.py
Normal file
126
backend/src/models/notebook.py
Normal 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
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
202
frontend/note-editor.html
Normal 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
330
frontend/notebooks.html
Normal 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
423
frontend/notes.html
Normal 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>
|
||||
@@ -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 인스턴스
|
||||
|
||||
@@ -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 상태 동기화
|
||||
|
||||
333
frontend/static/js/note-editor.js
Normal file
333
frontend/static/js/note-editor.js
Normal 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;
|
||||
}
|
||||
});
|
||||
277
frontend/static/js/notebooks.js
Normal file
277
frontend/static/js/notebooks.js
Normal 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}¬ebook_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
404
frontend/static/js/notes.js
Normal 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 페이지 로드됨');
|
||||
});
|
||||
92
frontend/static/js/viewer-test.js
Normal file
92
frontend/static/js/viewer-test.js
Normal 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
@@ -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"],
|
||||
|
||||
BIN
uploads/pdfs/32acc37c-13f1-4e14-a601-7faefb57d8d5.pdf
Normal file
BIN
uploads/pdfs/32acc37c-13f1-4e14-a601-7faefb57d8d5.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user