Compare commits

...

52 Commits

Author SHA1 Message Date
Hyungi Ahn
b9ac1b2f22 Docker Compose 파일명 단순화: synology-optimized → synology로 변경, 스크립트 파일들도 업데이트 2025-09-04 12:21:05 +09:00
Hyungi Ahn
5854301c5d Docker Compose 파일 정리: 불필요한 파일 제거, 3개 파일로 단순화 2025-09-04 12:09:48 +09:00
Hyungi Ahn
4755d27eb9 프로덕션 배포 준비: 테스트 파일 정리, 개발용 데이터 제거, 정리 스크립트 추가 2025-09-04 12:08:59 +09:00
Hyungi Ahn
f6bbf54f55 Synology 볼륨 구성 수정: SSD=volume3, HDD=volume1로 경로 변경 2025-09-04 12:01:16 +09:00
Hyungi Ahn
141e66e52c README 업데이트: 할일관리 시스템, Synology 배포 가이드, 최신 기능 현황 반영 2025-09-04 11:41:12 +09:00
Hyungi Ahn
164dcd879d Synology DS1525+ 최적화 배포 환경 완성: 32GB RAM/SSD캐시 최적화, 자동 배포 스크립트, 모니터링 도구 2025-09-04 11:25:59 +09:00
Hyungi Ahn
f49edaf06b 코드 정리 및 UI 개선: 불필요한 파일 제거, 디버그 로그 정리, 프로덕션 설정 개선, 할일관리 헤더 개선 2025-09-04 11:21:15 +09:00
Hyungi Ahn
16eec06b7c TODO 일정 변경 문제 해결: active 상태 할일은 delay 엔드포인트 사용 2025-09-04 11:18:02 +09:00
Hyungi Ahn
e96f8b92f8 TODO 일정 변경 디버깅: 콘솔 로그 추가하여 문제 진단 2025-09-04 11:16:47 +09:00
Hyungi Ahn
a900574263 TODO 탭 일정변경 버튼을 지연으로 변경: 버튼 텍스트, 색상, 아이콘, 모달 제목 수정 2025-09-04 11:16:08 +09:00
Hyungi Ahn
217642b44d 8시간 초과 경고 개선: 그냥쓴다/변경한다/취소 버튼 추가 2025-09-04 11:09:22 +09:00
Hyungi Ahn
c5911467fc 일정 설정 오류 수정: formatRelativeTime 함수 추가, 기존 일정 수정 지원 2025-09-04 11:07:05 +09:00
Hyungi Ahn
ef2e20f7f5 할일관리 단순화: 복잡한 캘린더 제거, 8시간 초과 시 경고 알람 추가 2025-09-04 11:05:23 +09:00
Hyungi Ahn
87d0335bfd 메모 새로고침 문제 해결: 초기화 시 모든 할일 메모 로드 2025-09-04 11:01:35 +09:00
Hyungi Ahn
ca5e7525f9 Alpine.js 문제 해결: 메모 기능을 메인 컴포넌트로 통합 2025-09-04 10:58:52 +09:00
Hyungi Ahn
b5167b3569 할일관리 개선: 인라인 메모 기능, 캘린더 일정 확인, UI 개선 2025-09-04 10:55:54 +09:00
Hyungi Ahn
b68fd89f5d Pydantic v2 호환성 수정: regex -> pattern 변경 2025-09-04 10:43:08 +09:00
Hyungi Ahn
a4fd233ba1 할일관리 시스템 구현 완료
주요 기능:
- memos/트위터 스타일 할일 입력
- 5단계 워크플로우: draft → scheduled → active → completed/delayed
- 2시간 이상 작업 자동 분할 제안 (1분/30분/1시간 선택)
- 시작날짜 기반 자동 활성화
- 할일별 댓글/메모 기능
- 개인별 할일 관리

백엔드:
- TodoItem, TodoComment 모델 추가
- 완전한 REST API 구현
- 자동 상태 전환 로직
- 분할 기능 지원

프론트엔드:
- 직관적인 탭 기반 UI
- 실시간 상태 업데이트
- 모달 기반 상세 관리
- 반응형 디자인

데이터베이스:
- PostgreSQL 테이블 및 인덱스 생성
- 트리거 기반 자동 업데이트
2025-09-04 10:40:49 +09:00
Hyungi Ahn
f221a5611c 전체 페이지 헤더 여백 통일 및 z-index 충돌 해결
주요 변경사항:
- story-reader.html: pt-4 → pt-20
- pdf-manager.html: py-8 → pt-20 pb-8
- upload.html: py-8 → pt-20 pb-8
- index.html: py-8 → pt-20 pb-8
- search.html: py-8 → pt-20 pb-8
- memo-tree.html: pt-16 → pt-20
- notebooks.html: py-8 → pt-20 pb-8
- notes.html: py-8 → pt-20 pb-8

헤더(h-16, z-50)와의 충돌을 방지하기 위해 모든 페이지의 상단 여백을 pt-20(80px)으로 통일
2025-09-04 10:25:57 +09:00
Hyungi Ahn
43e7466195 스토리 뷰 페이지 헤더 z-index 충돌 문제 해결
- 메인 컨테이너 padding-top을 pt-4에서 pt-20으로 증가
- 드롭다운 z-index를 z-[60]으로 설정하여 헤더보다 높은 우선순위 부여
- 스토리 선택 드롭다운이 정상적으로 작동하도록 수정
- 디버깅용 코드 정리
2025-09-04 10:22:43 +09:00
Hyungi Ahn
3ba804276c Fix: 노트 링크 관련 모든 기능 완전 수정
주요 수정사항:
- 노트 간 링크 네비게이션 수정 (target_note_id 우선 사용)
- 노트 백링크 네비게이션 수정 (source_note_id 우선 사용)
- 노트 링크 삭제 API 분기 처리 (/note-links vs /document-links)
- 하이라이트 삭제 시 메모 캐시 무효화 추가
- 하이라이트 메모 삭제 API 엔드포인트 추가 (DELETE /highlight-notes/{note_id})
- URL 파싱 개선 (null/undefined ID 감지 및 오류 처리)
- 노트 링크 생성 응답에 source_content_type, target_content_type 추가
- 통합 툴팁에서 노트 링크 제목 표시 수정 (target_note_title 사용)
- 링크 삭제 버튼에서 null 참조 오류 수정

수정된 파일:
- frontend: viewer-core.js, link-manager.js, highlight-manager.js, api.js, cached-api.js
- backend: note_links.py, notes.py
- 브라우저 캐시 무효화: 버전 v=2025012623
2025-09-04 08:42:12 +09:00
Hyungi Ahn
0786bdc86d Fix: 하이라이트 메모 표시 오류 수정
- highlight-manager.js에서 showHighlightTooltip 함수 호출 시 배열 대신 단일 객체 전달하도록 수정
- 하이라이트 클릭 시 메모가 0개로 표시되던 문제 해결
- getOverlappingElements 함수에 디버깅 로그 추가
- 하이라이트 매니저 상태 확인 로그 추가
- 브라우저 캐시 무효화를 위한 버전 업데이트 (v=2025012617)
2025-09-04 07:48:43 +09:00
Hyungi Ahn
8f776a5281 Fix: DocumentLink 모델에서 target_document relationship 제거
- 외래키 제약 조건 제거로 인한 SQLAlchemy relationship 오류 해결
- target_document relationship 제거 (노트 ID 지원을 위해)
2025-09-03 19:02:15 +09:00
Hyungi Ahn
6c19af3731 Fix: 문서-노트 간 링크 지원을 위한 외래키 제약 조건 제거
- DocumentLink 모델에서 target_document_id 외래키 제약 조건 제거
- 노트 ID도 target_document_id에 저장할 수 있도록 수정
- 데이터베이스에서 fk_document_links_target_document_id_documents 제약 조건 제거
2025-09-03 19:00:14 +09:00
Hyungi Ahn
b8c1bee06a Fix: NoteDocument import 경로 수정
- document_links.py에서 상대 import 경로 수정
- ModuleNotFoundError 해결
2025-09-03 18:56:49 +09:00
Hyungi Ahn
d68bfd45b2 Fix: 링크 기능 디버깅 및 라우터 등록 수정
- document_links 라우터를 /api 경로에도 추가 등록 (링크 삭제 호환성)
- 노트 검색 실패 시 상세한 디버깅 로그 추가
- 존재하는 노트 목록 출력으로 문제 원인 파악
2025-09-03 18:48:13 +09:00
Hyungi Ahn
d74cb070ca Fix: 문서-노트 간 링크 생성 및 링크 삭제 기능 수정
- 문서에서 노트로의 링크 생성 지원 추가
- 대상이 문서가 아닌 경우 NoteDocument 테이블에서 검색
- 노트 권한 확인 로직 추가
- 링크 삭제 엔드포인트에 /document-links/{link_id} 경로 추가 (프론트엔드 호환성)
- 응답에서 노트 제목과 노트북 ID 지원
2025-09-03 18:46:11 +09:00
Hyungi Ahn
ec7d13ced7 Fix: Alpine.js 드롭다운 반응성 문제 해결
- 데이터 로드 후 Alpine.js DOM 업데이트가 제대로 되지 않는 문제 수정
- $nextTick과 setTimeout을 사용하여 강제 반응성 트리거
- matched_pdf_id 값을 일시적으로 변경했다가 복원하여 UI 업데이트 강제 실행
2025-09-03 18:38:50 +09:00
Hyungi Ahn
e983d01a83 Fix: PDF 매칭 드롭다운 값 바인딩 문제 해결
- Alpine.js에서 :value="null"이 문자열로 변환되는 문제 수정
- option value를 빈 문자열("")로 변경
- JavaScript에서 null 값을 빈 문자열로 변환하여 UI 바인딩 개선
- 저장 시 빈 문자열과 null 모두 처리하도록 수정
2025-09-03 18:35:50 +09:00
Hyungi Ahn
77c18d31a9 Fix: 서적 편집 페이지 PDF 매칭 UI 개선
- PDF 매칭 드롭다운에서 현재 선택된 PDF가 올바르게 표시되도록 수정
- null 값과 빈 문자열 처리 개선 (option value를 null로 변경)
- PDF 매칭된 문서는 녹색 배경과 상태 표시 점으로 시각적 구분
- JavaScript에서 빈 문자열을 null로 변환하여 일관성 유지
2025-09-03 18:32:58 +09:00
Hyungi Ahn
ca49ffec40 Fix: PDF 다운로드 시 캐시 문제 해결
- 문서 뷰어에서 PDF 다운로드 시 최신 문서 정보를 재로드
- 캐시된 matched_pdf_id가 null인 경우 데이터베이스에서 최신 정보 가져오기
- PDF 매칭 정보 업데이트 로그 추가
2025-09-03 18:31:35 +09:00
Hyungi Ahn
185db89585 Fix: 서적 편집 저장 시 문서 순서 업데이트 추가
- saveChanges() 함수에서 저장 전에 updateDisplayOrder() 호출
- 문서 순서가 올바르게 저장되도록 수정
2025-09-03 18:19:51 +09:00
Hyungi Ahn
8f12eb4f76 Fix: PDF 매칭 저장 기능 수정
- 백엔드 문서 업데이트 API에서 DocumentResponse 생성자 수정
- original_filename 필드 추가로 PDF 매칭 정보 제대로 반환
- uploader 필드 null 체크 추가
- 프론트엔드 저장 과정에 상세 디버깅 로그 추가
- PDF 매칭 정보와 순서 저장 과정 로그 추가
2025-09-03 18:10:42 +09:00
Hyungi Ahn
b5602cbf44 Debug: 서적 수정 버튼 디버깅 로그 추가
- openBookEditor() 함수에 상세 디버깅 로그 추가
- URL 파라미터 파싱 과정 로그 추가
- bookId 값과 타입 확인 로그 추가
- 버튼 클릭 이벤트 확인을 위한 로그 추가
2025-09-03 18:03:03 +09:00
Hyungi Ahn
0833b99e6f Fix: 메뉴 구조 개선 및 관리자 권한 수정
- 프로필 관리 vs 계정 관리 구분 명확화:
  - 프로필 관리: 개인 계정 설정 (모든 사용자)
  - 계정 관리: 전체 사용자 관리 (관리자만)
- 관리자 메뉴 디버깅 로그 추가
- admin@test.com 계정을 관리자로 업데이트
- 아이콘 변경: users → users-cog (계정 관리)
2025-09-03 17:59:10 +09:00
Hyungi Ahn
43e2614253 Feature: 관리자 메뉴 개선 및 새 관리 페이지 추가
- 프로필 드롭다운에서 중복 메뉴 제거 (프로필 관리 + 계정 설정 → 계정 설정)
- 관리자 전용 메뉴 추가:
  - 백업/복원 관리 페이지 (backup-restore.html)
  - 시스템 로그 페이지 (logs.html)
- 관리자 메뉴에 색상 구분 아이콘 적용
- account-settings.html 삭제 (profile.html로 통합)
2025-09-03 17:46:41 +09:00
Hyungi Ahn
62675e9bd5 UI: 프로필 드롭다운 메뉴 심플화
- 복잡한 그라디언트와 설명 텍스트 제거
- 더 작고 깔끔한 드롭다운 메뉴로 변경
- 아이콘과 텍스트만으로 심플하게 구성
- 불필요한 CSS 스타일 제거
2025-09-03 17:42:52 +09:00
Hyungi Ahn
6e01dbdeb3 Fix: 업로드 및 API 연결 문제 해결
- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정
- Nginx 프록시 설정에서 경로 중복 문제 해결
- 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정)
- 노트북 연결 기능 수정 (notebook_id 필드 추가)
- 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거)
- 헤더 UI 개선 및 고정 위치 설정
- 백업/복원 스크립트 추가
- PDF 미리보기 토큰 인증 지원
2025-09-03 15:58:10 +09:00
Hyungi Ahn
d4b10b16b1 🔧 PDF 404 오류 해결 및 로딩 상태 개선
🛠️ PDF 404 오류 해결:
- PDF 파일 존재 여부를 HEAD 요청으로 먼저 확인
- 파일이 존재할 때만 iframe src 설정
- 토큰을 URL 파라미터로 전달 (_token)
- 백엔드 PDF API에서 _token 파라미터 지원

📱 PDF 미리보기 로딩 개선:
- pdfLoading, pdfLoaded, pdfSrc 상태 추가
- 로딩 중 스피너 표시
- iframe 로드 완료/에러 이벤트 처리
- handlePdfError() 함수로 에러 처리 개선

🎯 사용자 경험 개선:
- PDF 로딩 상태 명확한 표시
- 에러 발생 시 적절한 메시지
- 불필요한 404 요청 방지
- 리소스 정리 개선

🔍 디버깅 개선:
- PDF 로드 과정 상세 로깅
- 에러 원인 명확한 표시
- 파일 존재 여부 사전 확인
2025-09-02 17:22:35 +09:00
Hyungi Ahn
2d25c04457 🔧 미리보기 인증 및 에러 처리 개선
🛠️ 401 Unauthorized 오류 해결:
- HTML 콘텐츠 API 호출을 api.get() 래퍼로 변경
- iframe src에 토큰 파라미터 추가
- 백엔드에서 _token 쿼리 파라미터 지원

🛠️ 404 Not Found 오류 해결:
- 문서 상세 정보를 먼저 로드하여 PDF/HTML 존재 여부 확인
- PDF/HTML 파일 존재 여부에 따른 조건부 렌더링
- 미리보기 타입 자동 감지 및 적절한 뷰어 선택

🎯 에러 처리 및 UX 개선:
- HTML 로드 실패 시 에러 메시지 표시
- 미리보기 불가능한 콘텐츠에 대한 fallback UI
- 문서 정보 로드 실패 시 기본 내용으로 fallback
- '원본에서 보기' 버튼으로 대안 제공

🔍 미리보기 로직 개선:
- 문서 타입별 적절한 미리보기 방식 자동 선택
- PDF 존재 시 PDF 뷰어, HTML 존재 시 HTML 뷰어
- 검색어 하이라이트 타이밍 최적화
- 로딩 상태 및 에러 상태 명확한 구분
2025-09-02 17:20:37 +09:00
Hyungi Ahn
5f9fe07317 🔧 Alpine.js 변수 오류 수정 및 PDF 미리보기 간소화
🐛 Alpine.js 오류 수정:
- PDF 관련 변수들 (pdfZoom, pdfLoading, pdfCurrentPage 등) 제거
- HTML 뷰어 변수들 유지 (htmlLoading, htmlRawMode, htmlSourceCode)
- 중복 변수 정의 제거

📱 PDF 미리보기 간소화:
- PDF.js Canvas 렌더링 → iframe 방식으로 변경
- 복잡한 줌/페이지 네비게이션 제거
- 브라우저 내장 PDF 뷰어 활용
- 401 Unauthorized 오류 해결을 위한 토큰 처리

🎯 성능 및 안정성 개선:
- PDF.js 워커 설정을 HTML에서 전역으로 처리
- 불필요한 JavaScript 함수들 제거
- 메모리 누수 방지를 위한 리소스 정리
- 에러 처리 간소화

 사용자 경험:
- 더 빠른 PDF 로딩
- 브라우저 기본 PDF 컨트롤 사용 가능
- Alpine.js 경고 메시지 제거
- 안정적인 미리보기 동작
2025-09-02 17:17:07 +09:00
Hyungi Ahn
960ee84356 🎨 데본씽크 스타일 통합 미리보기 완성
📱 PDF 미리보기 (PDF.js):
- 실제 PDF 렌더링 (Canvas 기반)
- 줌 인/아웃 (50% ~ 300%)
- 페이지 네비게이션 (이전/다음/직접입력)
- 고해상도 디스플레이 지원
- 검색어 위치로 뷰어 연동

📄 HTML 문서 미리보기:
- iframe 렌더링 뷰 / 소스 코드 뷰 토글
- 검색어 자동 하이라이트 (iframe 내부)
- 문법 하이라이트된 소스 코드 표시
- 안전한 sandbox 모드

📝 노트 문서 미리보기:
- 제목, 생성일시 표시
- 검색어 하이라이트
- 편집기에서 열기 버튼
- 깔끔한 카드 스타일 UI

🌳 메모 트리 노드 미리보기:
- 메모 제목과 내용 표시
- 트리 정보 (소속 트리명)
- 검색어 하이라이트
- 구조화된 레이아웃

🎯 UX 개선:
- 타입별 아이콘과 색상 구분
- ESC 키 / 배경 클릭으로 닫기
- 로딩 상태 표시
- 에러 처리 및 fallback
- 반응형 디자인
2025-09-02 17:14:09 +09:00
Hyungi Ahn
4b65d45584 📄 PDF 본문 검색 및 미리보기 완성
🔍 PDF/HTML 본문 검색 개선:
- PDF OCR 데이터 전체 텍스트 검색 (BeautifulSoup + PyPDF2)
- 서적 HTML 파일 본문 검색 지원
- 파일 타입 구분 (PDF/HTML/PDF직접추출)
- 검색어 매치 횟수 기반 관련성 점수
- 절대/상대 경로 처리 개선

📱 PDF 미리보기 기능:
- 검색 결과에서 PDF 직접 미리보기 (iframe)
- PDF에서 검색 버튼으로 페이지 이동
- 검색어 위치 기반 뷰어 연동
- PDF 로드 실패 시 fallback UI

🎯 백엔드 API 추가:
- GET /documents/{id}/pdf: PDF 파일 직접 제공
- GET /documents/{id}/search-in-content: 문서 내 검색
- 페이지별 검색 결과 및 컨텍스트 제공
- 권한 확인 및 에러 처리

🎨 프론트엔드 UX:
- PDF/HTML 타입별 배지 표시
- 검색 통계에 본문 검색 결과 포함
- 미리보기 모달에서 PDF 뷰어 통합
- 검색어 하이라이트 및 컨텍스트 표시
2025-09-02 17:09:32 +09:00
Hyungi Ahn
0afe6dcf65 🔧 검색 미리보기 기능 수정
- API 호출 방식 개선 (fetch 직접 사용)
- HTML 응답에서 텍스트 추출 로직 추가
- 미리보기 모달 UX 개선 (ESC 키, 클릭으로 닫기)
- 내용 표시 최적화 (스크롤, 워드랩)
- 에러 처리 강화 (fallback to original content)
2025-09-02 17:04:02 +09:00
Hyungi Ahn
4329a1c9a6 🔍 고급 통합 검색 시스템 완성
🎯 주요 기능:
- 하이라이트 메모 내용 별도 검색 (highlight_note 타입)
- PDF/HTML 본문 전체 텍스트 검색 (OCR 데이터 활용)
- 검색 결과 미리보기 모달 (전체 내용 로드)
- 메모 트리 노드 검색 지원
- 노트 문서 통합 검색

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

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

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

🔗 네비게이션 통합:
- 헤더에 '통합 검색' 링크 추가
- 페이지별 활성 상태 관리
2025-09-02 16:53:56 +09:00
Hyungi Ahn
97d60554a9 📝 README 업데이트: 메모 트리 시스템 완성 반영
- Phase 8에서 메모 트리 시스템을 완성된 것으로 표시
- 주요 기능들 상세 설명 추가:
  * 트리 구조 메모 생성/편집/삭제
  * Monaco 에디터 통합
  * 드래그 앤 드롭 노드 재배치
  * 정사 경로 설정
  * 다양한 노드 타입 지원
  * 실시간 시각적 피드백
2025-09-02 16:46:19 +09:00
Hyungi Ahn
c5d09ed948 메모 트리 시스템 드래그 앤 드롭 기능 완성
�� 주요 기능 추가:
- 노드 드래그 앤 드롭으로 부모-자식 관계 변경
- 드래그 중 시각적 피드백 (투명도, 크기 변화)
- 드롭 대상 하이라이트 효과
- 실시간 노드 위치 추적 및 이동

🔧 기술적 구현:
- startDragNode, handleNodeDrag, endNodeDrag 함수 구현
- data-node-id 속성으로 노드 식별
- document.elementsFromPoint로 드롭 대상 감지
- moveNodeToParent API 호출로 실제 데이터 업데이트

🎨 UI/UX 개선:
- 드래그 중 노드 스타일 변경 (opacity, scale, z-index)
- 드롭 대상 하이라이트 CSS (.drop-target-highlight)
- 토스트 알림 시스템 추가
- 성공/실패 피드백 제공

📱 사용자 경험:
- 직관적인 드래그 앤 드롭 인터페이스
- 실시간 시각적 피드백
- 자동 트리 구조 업데이트
- 에러 처리 및 사용자 알림
2025-09-02 16:45:31 +09:00
Hyungi Ahn
ea9f4dfaa9 🐛 노트북 선택창 필드명 불일치 수정
- note-editor.html: notebook.name → notebook.title 수정
- notes.html: 모든 notebook.name → notebook.title 수정
- note-editor.js: 디버깅 로그 추가하여 노트북 데이터 구조 확인

백엔드 API에서는 'title' 필드를 사용하는데 프론트엔드에서 'name' 필드를 참조하여
노트북 선택창이 빈칸으로 표시되는 문제 해결
2025-09-02 16:41:37 +09:00
Hyungi Ahn
0dc4e3523f 노트북 관리 UI 완성
🎯 주요 개선사항:
- 노트북 목록 조회/표시 기능 완성
- 노트북 생성/편집/삭제 모달 구현
- 토스트 알림 시스템 추가 (alert 대신)
- 노트북 통계 대시보드 표시
- 노트북별 노트 관리 및 빠른 노트 생성 기능
- URL 파라미터를 통한 노트북 자동 설정

🔧 기술적 개선:
- CSS line-clamp 클래스 추가
- 필드명 불일치 수정 (notebook.name → notebook.title)
- 삭제 확인 모달로 UX 개선
- 노트 에디터에서 노트북 자동 설정 지원

📱 UI/UX 개선:
- 노트북 카드 호버 효과 및 색상 테마
- 빈 노트북 상태 처리 및 안내
- 반응형 그리드 레이아웃
- 로딩 상태 및 에러 처리 개선
2025-09-02 16:38:47 +09:00
Hyungi Ahn
d01cdeb2f5 feat: 노트북-서적 간 양방향 링크/백링크 시스템 완성
 주요 기능
- 노트 ↔ 서적 문서 간 양방향 링크 생성 및 이동
- 링크 대상 타입 선택 UI (서적 문서/노트북 노트)
- 통합 백링크 시스템 (일반 문서에서 노트 백링크도 표시)
- 링크 목록 UI 개선 (상세 정보 표시, 타입 구분)

🔧 백엔드 개선
- NoteLink 모델 및 API 추가 (/note-documents/{id}/links, /note-documents/{id}/backlinks)
- 일반 문서 백링크 API에서 노트 링크도 함께 조회
- target_content_type, source_content_type 필드 추가
- 노트 문서 콘텐츠 API 추가 (/note-documents/{id}/content)

🎨 프론트엔드 개선
- text-selector.html에서 노트 문서 지원
- 링크 이동 시 contentType에 따른 올바른 URL 생성
- URL 파라미터 파싱 수정 (contentType 지원)
- 링크 타입 자동 추론 로직
- 링크 목록 UI 대폭 개선 (출발점/도착점 텍스트, 타입 배지 등)

🐛 버그 수정
- 서적 목록 로드 실패 문제 해결
- 노트에서 링크 생성 시 대상 문서 열기 문제 해결
- 더미 문서로 이동하는 문제 해결
- 캐시 관련 문제 해결
2025-09-02 16:22:03 +09:00
Hyungi Ahn
f711998ce9 feat: 겹침 메뉴에 각 기능별 액션 버튼 추가
- 하이라이트: '메모 보기' 버튼으로 하이라이트 툴팁 표시
- 링크: '문서로 이동' 버튼으로 바로 연결된 문서로 이동
- 백링크: '원본으로 이동' 버튼으로 바로 원본 문서로 이동
- 각 항목에 '상세보기' 버튼으로 상세 정보 툴팁 표시
- 메뉴 UI 개선: 크기 확대, 아이콘 추가, 레이아웃 개선
- 링크/백링크 툴팁을 메모처럼 상세하게 개선 (날짜, 아이콘, 그라데이션)
- 겹침 감지 로직 디버깅 및 안정화
2025-09-02 15:11:36 +09:00
Hyungi Ahn
397c63979d feat: 링크와 백링크 기능 개선 및 겹침 처리 구현
 주요 기능 추가:
- 링크 생성 후 즉시 렌더링 (캐시 무효화 수정)
- 하이라이트, 링크, 백링크 겹침 시 선택 팝업 메뉴
- 백링크 오프셋 기반 정확한 렌더링
- 링크 렌더링 디버깅 로직 강화

🔧 기술적 개선:
- 캐시 무효화 함수 수정 (invalidateRelatedCache 사용)
- 텍스트 오프셋 불일치 시 자동 검색 대체
- 겹치는 요소 감지 및 상호작용 처리
- 상세한 디버깅 로그 추가

🎨 UI/UX 개선:
- 겹침 메뉴에서 각 요소별 아이콘과 색상 구분
- 클릭된 요소 시각적 표시
- 툴팁과 메뉴 외부 클릭 처리

🐛 버그 수정:
- 신규 링크 생성 후 표시되지 않는 문제 해결
- 백링크 렌더링 오프셋 정확성 개선
- 캐시 관련 JavaScript 오류 수정
2025-09-02 13:42:01 +09:00
101 changed files with 14799 additions and 5281 deletions

223
QUICK-START.md Normal file
View File

@@ -0,0 +1,223 @@
# 🚀 빠른 시작 가이드
Document Server를 Synology DS1525+에 배포하는 가장 간단한 방법입니다.
## 📋 준비사항
- Synology DS1525+ (32GB RAM, SSD 캐시 활성화)
- Docker 패키지 설치 (Package Center에서 설치)
- SSH 접속 가능
- Git 설치 (선택사항)
## 🎯 한 번에 배포하기
### 방법 1: Git 클론 (권장) ⭐
```bash
# 1. NAS에 SSH 접속
ssh admin@your-nas-ip
# 2. 프로젝트 클론
cd /volume1/docker/
git clone https://git.hyungi.net/hyungi/document-server.git
cd document-server
# 3. 자동 배포 (환경 설정 + 배포)
./scripts/deploy-synology.sh
```
### 방법 2: 파일 업로드
```bash
# 1. 로컬에서 NAS로 파일 전송
scp -r ./document-server admin@your-nas-ip:/volume1/docker/
# 2. NAS에 SSH 접속
ssh admin@your-nas-ip
cd /volume1/docker/document-server
# 3. 자동 배포
./scripts/deploy-synology.sh
```
## ⚙️ 환경 설정 (자동)
배포 스크립트 실행 시 자동으로 환경 설정이 시작됩니다:
```
=== 🔧 Document Server 환경 변수 설정 ===
1. 데이터베이스 비밀번호
기본값: AbC123XyZ (자동생성)
입력: [엔터 = 기본값 사용]
2. JWT 시크릿 키 (보안용)
기본값: kL9mN2pQ... (자동생성)
입력: [엔터 = 기본값 사용]
3. 관리자 이메일
기본값: admin@document-server.local
입력: admin@mydomain.com
4. 관리자 비밀번호
기본값: MyPass123 (자동생성)
입력: [엔터 = 기본값 사용]
5. 도메인 이름 (외부 접속용)
기본값: localhost
입력: nas.mydomain.com
```
**💡 팁**: 대부분 엔터만 눌러도 안전한 기본값이 자동 설정됩니다!
## 🎉 배포 완료 후
### 접속 확인
```
🌐 웹 인터페이스: http://your-nas-ip:24100
📧 관리자 이메일: admin@document-server.local
🔑 관리자 비밀번호: (설정 시 표시된 비밀번호)
```
### 주요 기능 테스트
1. **할일관리**: `http://your-nas-ip:24100/todos.html`
2. **메모 트리**: `http://your-nas-ip:24100/memo-tree.html`
3. **노트북**: `http://your-nas-ip:24100/notebooks.html`
4. **문서 업로드**: 메인 페이지에서 HTML 파일 드래그&드롭
## 🔄 업데이트 방법
### Git 사용 (권장)
```bash
# NAS에 SSH 접속
ssh admin@your-nas-ip
cd /volume1/docker/document-server
# 자동 업데이트 (백업 + 업데이트 + 헬스체크)
./scripts/update-synology.sh
```
### 수동 업데이트
```bash
# 1. 코드 업데이트
git pull origin main
# 2. 컨테이너 재시작
docker-compose -f docker-compose.synology-optimized.yml restart
```
## 📊 모니터링
```bash
# 시스템 상태 확인
./scripts/monitor-synology.sh
# 실시간 모니터링 (5초 간격)
watch -n 5 './scripts/monitor-synology.sh'
# 로그 확인
docker-compose -f docker-compose.synology-optimized.yml logs -f
```
## 🚨 문제 해결
### 포트 충돌
```bash
# 포트 사용 확인
netstat -tuln | grep -E "(24100|24101|24102|24103)"
# 다른 포트 사용 시 .env.synology 파일 수정
nano .env.synology
# EXTERNAL_PORT=24200 (예시)
```
### 권한 문제
```bash
# 디렉토리 권한 수정
sudo chown -R 1000:1000 /volume1/docker/document-server/
sudo chown -R 1000:1000 /volume2/document-storage/
```
### 서비스 재시작
```bash
# 전체 재시작
docker-compose -f docker-compose.synology-optimized.yml restart
# 특정 서비스만 재시작
docker-compose -f docker-compose.synology-optimized.yml restart backend
```
### 롤백 (업데이트 실패 시)
```bash
# 이전 버전으로 롤백
./scripts/update-synology.sh rollback
```
## 💾 백업
### 자동 백업 설정
```bash
# Synology 작업 스케줄러에서 설정
# 제어판 > 작업 스케줄러 > 생성 > 사용자 정의 스크립트
# 매일 새벽 2시 백업
0 2 * * * /volume1/docker/document-server/backup.sh
```
### 수동 백업
```bash
# 즉시 백업 실행
/volume1/docker/document-server/backup.sh
# 백업 파일 확인
ls -la /volume2/document-storage/backups/
```
## 🔒 보안 설정
### 방화벽 (권장)
```bash
# Synology 제어판 > 보안 > 방화벽
# 규칙 추가: 포트 24100 허용
```
### SSL 인증서 (외부 접속 시)
```bash
# Let's Encrypt 인증서 발급
certbot certonly --webroot -w /volume2/document-storage/documents -d your-domain.com
```
## 📞 도움말
### 로그 수집 (문제 보고 시)
```bash
# 시스템 리포트 생성
./scripts/monitor-synology.sh > system-report.txt
# 로그 파일 위치
/volume1/docker/document-server/logs/
```
### 유용한 명령어
```bash
# Docker 상태 확인
docker ps
docker stats
# 디스크 사용량 확인
df -h /volume1 /volume2
# 메모리 사용량 확인
free -h
```
---
## 🎯 요약
1. **배포**: `./scripts/deploy-synology.sh` (한 번만)
2. **업데이트**: `./scripts/update-synology.sh` (필요시)
3. **모니터링**: `./scripts/monitor-synology.sh` (상태 확인)
4. **접속**: `http://your-nas-ip:24100`
**🎉 이제 Document Server를 사용할 준비가 완료되었습니다!**

279
README-DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,279 @@
# 🚀 Synology DS1525+ 배포 가이드
Document Server를 Synology DS1525+ NAS에 최적화하여 배포하는 가이드입니다.
## 🏗️ 하드웨어 사양
### Synology DS1525+ 최적화 구성
- **CPU**: AMD Ryzen R1600 (4코어/8스레드)
- **메모리**: 32GB DDR4 ECC
- **스토리지**: SSD 읽기/쓰기 캐시 활성화
- **볼륨 구성**:
- **Volume1 (SSD)**: 고성능 데이터 (데이터베이스, 캐시, 로그)
- **Volume2 (HDD)**: 대용량 저장소 (문서, 업로드, 백업)
## 📁 스토리지 전략
### SSD 볼륨 (/volume1) - 성능 최우선
```
/volume1/docker/document-server/
├── database/ # PostgreSQL 데이터 (8GB shared_buffers)
├── redis/ # Redis 캐시 (8GB maxmemory)
├── logs/ # 애플리케이션 로그
├── config/ # 설정 파일
├── nginx/
│ ├── conf.d/ # Nginx 설정
│ └── cache/ # Nginx 캐시 (2GB)
└── cache/ # 애플리케이션 캐시
```
### HDD 볼륨 (/volume2) - 대용량 저장
```
/volume2/document-storage/
├── uploads/ # 업로드된 파일 (HTML, PDF)
├── documents/ # 변환된 문서
├── thumbnails/ # 썸네일 이미지
├── backups/ # 자동 백업 파일
└── archives/ # 아카이브 데이터
```
## 🚀 배포 방법
### 1. 자동 배포 (권장)
```bash
# 저장소 클론
git clone <repository-url>
cd document-server
# 자동 배포 스크립트 실행
./scripts/deploy-synology.sh
```
### 2. 수동 배포
```bash
# 1. 디렉토리 생성
sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx/conf.d,nginx/cache,cache}
sudo mkdir -p /volume2/document-storage/{uploads,documents,thumbnails,backups,archives}
# 2. 권한 설정
sudo chown -R 1000:1000 /volume1/docker/document-server/
sudo chown -R 1000:1000 /volume2/document-storage/
# 3. 환경 변수 설정
cp .env.example .env.synology
# .env.synology 파일 편집
# 4. Docker Compose 실행
docker-compose -f docker-compose.synology-optimized.yml up -d
```
## ⚙️ 성능 최적화 설정
### PostgreSQL (32GB RAM 최적화)
```ini
# /volume1/docker/document-server/config/postgresql.synology.conf
shared_buffers = 8GB # RAM의 25%
effective_cache_size = 24GB # RAM의 75%
work_mem = 512MB # 복잡한 쿼리용
maintenance_work_mem = 4GB # 인덱스 구축용
max_worker_processes = 8 # 4코어/8스레드 최적화
max_parallel_workers_per_gather = 4
random_page_cost = 1.1 # SSD 최적화
effective_io_concurrency = 200 # SSD 동시 I/O
```
### Redis (대용량 메모리 활용)
```conf
maxmemory 8gb # 캐시 메모리 제한
maxmemory-policy allkeys-lru # LRU 정책
appendonly yes # 데이터 지속성
auto-aof-rewrite-percentage 100 # AOF 최적화
```
### Nginx (SSD 캐시 최적화)
```nginx
# 캐시 존 설정 (SSD에 저장)
proxy_cache_path /var/cache/nginx/documents
levels=1:2
keys_zone=documents:100m
max_size=2g
inactive=60m;
# Gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# 정적 파일 캐시
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
```
## 📊 모니터링
### 실시간 모니터링
```bash
# 시스템 리소스 및 서비스 상태 확인
./scripts/monitor-synology.sh
# 실시간 모니터링 (5초 간격)
watch -n 5 './scripts/monitor-synology.sh'
# Docker 컨테이너 상태
docker-compose -f docker-compose.synology-optimized.yml ps
# 실시간 로그
docker-compose -f docker-compose.synology-optimized.yml logs -f
# 리소스 사용량
docker stats
```
### 주요 메트릭
- **CPU 사용률**: 평상시 < 30%, 피크 < 70%
- **메모리 사용률**: < 80% (32GB 중 25GB 이하)
- **디스크 I/O**: SSD 캐시 효과로 응답 시간 < 100ms
- **네트워크**: 기가비트 이더넷 활용
## 💾 백업 및 복구
### 자동 백업 설정
```bash
# Synology 작업 스케줄러에서 설정
# 매일 새벽 2시 실행
0 2 * * * /volume1/docker/document-server/backup.sh
```
### 백업 내용
- **데이터베이스**: PostgreSQL 덤프 (매일)
- **설정 파일**: 압축 아카이브 (매일)
- **문서 파일**: 증분 백업 (주간)
- **보관 정책**: 7일간 보관 후 자동 삭제
### 복구 방법
```bash
# 데이터베이스 복구
docker exec document-server-db psql -U docuser -d document_db < backup_file.sql
# 설정 파일 복구
tar -xzf config_backup_YYYYMMDD_HHMMSS.tar.gz -C /volume1/docker/document-server/
```
## 🔧 유지보수
### 정기 작업
1. **주간**: 로그 파일 정리 및 압축
2. **월간**: 데이터베이스 VACUUM 및 REINDEX
3. **분기**: 전체 시스템 백업 및 복구 테스트
4. **연간**: 하드웨어 점검 및 업그레이드 계획
### 로그 관리
```bash
# 로그 로테이션 설정
/volume1/docker/document-server/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 644 1000 1000
}
```
### 성능 튜닝
```bash
# PostgreSQL 통계 확인
docker exec document-server-db psql -U docuser -d document_db -c "SELECT * FROM pg_stat_activity;"
# Redis 메모리 사용량 확인
docker exec document-server-redis redis-cli info memory
# Nginx 캐시 효율성 확인
docker exec document-server-nginx nginx -T
```
## 🚨 트러블슈팅
### 일반적인 문제
#### 1. 메모리 부족
```bash
# 증상: 서비스 응답 지연, OOM 킬
# 해결: PostgreSQL/Redis 메모리 설정 조정
shared_buffers = 6GB # 8GB에서 감소
maxmemory 6gb # 8GB에서 감소
```
#### 2. 디스크 공간 부족
```bash
# SSD 공간 확보
docker system prune -a
find /volume1/docker/document-server/logs -name "*.log" -mtime +7 -delete
# HDD 공간 확보
find /volume2/document-storage/backups -name "*.sql" -mtime +30 -delete
```
#### 3. 네트워크 연결 문제
```bash
# 포트 확인
netstat -tuln | grep -E "(24100|24101|24102|24103)"
# 방화벽 설정 확인
iptables -L | grep -E "(24100|24101|24102|24103)"
```
### 로그 위치
- **애플리케이션**: `/volume1/docker/document-server/logs/`
- **Nginx**: `/volume1/docker/document-server/logs/nginx/`
- **PostgreSQL**: `docker logs document-server-db`
- **Redis**: `docker logs document-server-redis`
## 📈 성능 벤치마크
### 예상 성능 (DS1525+ 32GB)
- **동시 사용자**: 50-100명
- **문서 처리**: 1000+ 문서
- **응답 시간**: < 200ms (평균)
- **업로드 속도**: 100MB/s (기가비트 네트워크)
- **검색 속도**: < 100ms (인덱스 기반)
### 확장성
- **수직 확장**: RAM 64GB까지 지원
- **수평 확장**: 로드 밸런서 + 다중 백엔드
- **스토리지**: 추가 볼륨 마운트 가능
## 🔒 보안 설정
### 네트워크 보안
```bash
# 방화벽 규칙 (필요한 포트만 개방)
iptables -A INPUT -p tcp --dport 24100 -j ACCEPT # Nginx
iptables -A INPUT -p tcp --dport 22 -j ACCEPT # SSH
iptables -A INPUT -j DROP # 기본 차단
```
### 데이터 보안
- **암호화**: 데이터베이스 및 Redis 암호 설정
- **백업 암호화**: GPG를 이용한 백업 파일 암호화
- **접근 제어**: 사용자별 권한 관리
- **SSL/TLS**: Let's Encrypt 인증서 적용
## 📞 지원 및 문의
### 문제 보고
1. **로그 수집**: `./scripts/monitor-synology.sh > system-report.txt`
2. **환경 정보**: Docker 버전, 시스템 사양
3. **재현 단계**: 문제 발생 과정 상세 기록
### 업데이트
```bash
# 코드 업데이트
git pull origin main
# 컨테이너 재빌드
docker-compose -f docker-compose.synology-optimized.yml build --no-cache
docker-compose -f docker-compose.synology-optimized.yml up -d
```

242
README.md
View File

@@ -103,7 +103,8 @@ PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적
### 인프라 & 배포 ### 인프라 & 배포
- **컨테이너**: Docker 24+ & Docker Compose - **컨테이너**: Docker 24+ & Docker Compose
- **배포 환경**: Mac Mini / Synology NAS - **배포 환경**: Synology DS1525+ (32GB RAM, SSD 캐싱)
- **보조 배포 환경**: Mac Mini (개발/테스트)
- **프로세스 관리**: Docker (컨테이너 오케스트레이션) - **프로세스 관리**: Docker (컨테이너 오케스트레이션)
- **로그 관리**: Python logging + 파일 로테이션 - **로그 관리**: Python logging + 파일 로테이션
- **모니터링**: 기본 헬스체크 (향후 Prometheus + Grafana) - **모니터링**: 기본 헬스체크 (향후 Prometheus + Grafana)
@@ -231,20 +232,36 @@ notes (
- [x] API 오류 처리 및 사용자 피드백 - [x] API 오류 처리 및 사용자 피드백
- [x] 실시간 문서 목록 새로고침 - [x] 실시간 문서 목록 새로고침
### Phase 7: 최우선 개선사항 (진행 중) 🔥 ### Phase 7: 최우선 개선사항
- [ ] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합 - [x] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합
- [ ] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원 - [x] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원
- [ ] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리 - [x] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리
- [ ] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보 - [x] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보
- [x] **노트북-서적 링크 시스템**: 양방향 링크/백링크 완전 구현
### Phase 8: 향후 개선사항 (예정) ### Phase 8: 미완성 핵심 기능 (우선순위) 🚧
- [x] **노트 편집기**: 노트 생성/편집 UI 완성 (`/note-editor.html`) ✅
- [x] **노트북 관리 API**: 노트북 CRUD 백엔드 완성 ✅
- [x] **노트북 관리 UI**: 프론트엔드 CRUD 기능 완성 (`/notebooks.html`) ✅
- 노트북 목록 조회/표시, 생성/편집/삭제 모달
- 토스트 알림 시스템, 통계 대시보드
- 노트북별 노트 관리 및 빠른 노트 생성
- [x] **메모 트리 시스템**: 계층적 메모 구조 및 관리 (`/memo-tree.html`) ✅
- 트리 구조 메모 생성/편집/삭제, Monaco 에디터 통합
- 드래그 앤 드롭으로 노드 재배치, 정사 경로 설정
- 다양한 노드 타입 (메모, 폴더, 챕터, 캐릭터, 플롯)
- 실시간 시각적 피드백 및 토스트 알림
- [ ] **고급 검색**: 문서/노트/메모 통합 검색 필터링
- [ ] **사용자 관리**: 다중 사용자 지원 및 권한 관리
### Phase 9: 관리 및 최적화 (예정)
- [ ] 관리자 대시보드 UI - [ ] 관리자 대시보드 UI
- [ ] 문서 통계 및 분석 - [ ] 문서 통계 및 분석
- [ ] 모바일 반응형 최적화 - [ ] 모바일 반응형 최적화
- [ ] 고급 검색 필터
- [ ] 문서 버전 관리 - [ ] 문서 버전 관리
- [ ] 성능 최적화 및 캐싱
## 현재 상태 (2025-08-21) ## 현재 상태 (2025-01-26)
### ✅ 완료된 기능 ### ✅ 완료된 기능
- **완전한 백엔드 API**: FastAPI + SQLAlchemy + PostgreSQL - **완전한 백엔드 API**: FastAPI + SQLAlchemy + PostgreSQL
@@ -255,12 +272,19 @@ notes (
- **책갈피**: 페이지 북마크 및 빠른 이동 - **책갈피**: 페이지 북마크 및 빠른 이동
- **통합 검색**: 문서 내용 + 메모 통합 검색 - **통합 검색**: 문서 내용 + 메모 통합 검색
- **실시간 UI**: 업로드 후 즉시 목록 새로고침 - **실시간 UI**: 업로드 후 즉시 목록 새로고침
- **할일관리 시스템**: 검토필요/TODO/완료된일 3단계 워크플로우
- **메모 트리**: 계층적 메모 구조 및 Monaco 에디터
- **노트북 시스템**: 노트 문서 그룹화 및 관리
- **모바일 최적화**: 햅틱 피드백, 풀투리프레시, 반응형 디자인
### 🚀 테스트 가능한 기능 ### 🚀 테스트 가능한 기능
1. **로그인**: `admin@test.com` / `admin123` 1. **로그인**: `admin@test.com` / `admin123`
2. **문서 업로드**: HTML 파일 드래그&드롭 또는 선택 2. **문서 업로드**: HTML 파일 드래그&드롭 또는 선택
3. **문서 뷰어**: 업로드된 문서 클릭하여 뷰어 페이지 이동 3. **문서 뷰어**: 업로드된 문서 클릭하여 뷰어 페이지 이동
4. **태그 관리**: 업로드 시 태그 추가, 목록에서 태그별 필터링 4. **태그 관리**: 업로드 시 태그 추가, 목록에서 태그별 필터링
5. **할일관리**: `/todos.html` - 검토필요 → TODO → 완료된일 워크플로우
6. **메모 트리**: `/memo-tree.html` - 계층적 메모 작성 및 관리
7. **노트북**: `/notebooks.html` - 노트 문서 그룹화 및 편집
### 🔧 실행 중인 서비스 ### 🔧 실행 중인 서비스
- **프론트엔드**: http://localhost:24100 - **프론트엔드**: http://localhost:24100
@@ -285,8 +309,206 @@ docker-compose -f docker-compose.dev.yml up
### 프로덕션 환경 ### 프로덕션 환경
```bash ```bash
# 프로덕션 배포 # 일반 프로덕션 배포
docker-compose -f docker-compose.prod.yml up -d docker-compose -f docker-compose.prod.yml up -d
# Synology DS1525+ 최적화 배포 (권장)
./scripts/deploy-synology.sh
```
### 📋 Synology NAS 배포
DS1525+ (32GB RAM, SSD 캐시) 환경에 최적화된 배포 가이드는 [README-DEPLOYMENT.md](README-DEPLOYMENT.md)를 참조하세요.
**주요 특징:**
- 32GB RAM 최적화 (PostgreSQL 8GB, Redis 8GB)
- SSD/HDD 하이브리드 스토리지 전략
- 자동 배포 스크립트 및 모니터링 도구
- 성능 최적화된 설정 (Nginx 캐시, DB 튜닝)
## 🏢 Synology DS1525+ 최적화 배포
### 하드웨어 사양
- **모델**: Synology DS1525+ (5-Bay NAS)
- **CPU**: AMD Ryzen R1600 (4코어/8스레드)
- **메모리**: 32GB DDR4 ECC
- **스토리지**: SSD 읽기/쓰기 캐싱 활성화
- **네트워크**: 기가비트 이더넷
### 스토리지 최적화 전략
#### SSD 배치 (고성능 요구)
```bash
# 시스템 및 고빈도 액세스 데이터
/volume1/docker/document-server/
├── database/ # PostgreSQL 데이터 (SSD)
├── redis/ # Redis 캐시 (SSD)
├── logs/ # 애플리케이션 로그 (SSD)
└── config/ # 설정 파일 (SSD)
```
#### HDD 배치 (대용량 저장)
```bash
# 대용량 파일 저장소
/volume2/document-storage/
├── documents/ # HTML 문서 파일 (HDD)
├── uploads/ # 업로드된 원본 파일 (HDD)
├── backups/ # 데이터베이스 백업 (HDD)
└── archives/ # 아카이브 파일 (HDD)
```
### Docker Compose 최적화 설정
#### 볼륨 매핑 (docker-compose.synology.yml)
```yaml
version: '3.8'
services:
database:
volumes:
# SSD: 데이터베이스 성능 최적화
- /volume1/docker/document-server/database:/var/lib/postgresql/data
redis:
volumes:
# SSD: 캐시 성능 최적화
- /volume1/docker/document-server/redis:/data
backend:
volumes:
# SSD: 로그 및 설정
- /volume1/docker/document-server/logs:/app/logs
- /volume1/docker/document-server/config:/app/config
# HDD: 대용량 파일 저장
- /volume2/document-storage/uploads:/app/uploads
- /volume2/document-storage/documents:/app/documents
nginx:
volumes:
# SSD: 설정 및 캐시
- /volume1/docker/document-server/nginx:/etc/nginx/conf.d
# HDD: 정적 파일 서빙
- /volume2/document-storage/documents:/usr/share/nginx/html/documents:ro
```
### 시놀로지 환경 배포 명령어
```bash
# 1. 디렉토리 생성
sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx}
sudo mkdir -p /volume2/document-storage/{documents,uploads,backups,archives}
# 2. 권한 설정
sudo chown -R 1000:1000 /volume1/docker/document-server/
sudo chown -R 1000:1000 /volume2/document-storage/
# 3. 시놀로지 최적화 배포
docker-compose -f docker-compose.synology.yml up -d
# 4. 서비스 상태 확인
docker-compose -f docker-compose.synology.yml ps
```
### 성능 최적화 설정
#### PostgreSQL 튜닝 (32GB RAM 환경)
```ini
# postgresql.conf
shared_buffers = 8GB # RAM의 25%
effective_cache_size = 24GB # RAM의 75%
work_mem = 256MB # 복잡한 쿼리용
maintenance_work_mem = 2GB # 인덱스 구축용
checkpoint_completion_target = 0.9 # SSD 최적화
wal_buffers = 64MB # WAL 버퍼
random_page_cost = 1.1 # SSD 환경 최적화
```
#### Redis 설정 (캐싱 최적화)
```conf
# redis.conf
maxmemory 4gb # 캐시 메모리 제한
maxmemory-policy allkeys-lru # LRU 정책
save 900 1 # 자동 저장 설정
save 300 10
save 60 10000
```
### 백업 전략
#### 자동 백업 스크립트
```bash
#!/bin/bash
# /volume1/docker/document-server/scripts/backup.sh
BACKUP_DIR="/volume2/document-storage/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# 데이터베이스 백업
docker-compose -f docker-compose.synology.yml exec -T database \
pg_dump -U postgres document_server > "$BACKUP_DIR/db_backup_$DATE.sql"
# 설정 파일 백업
tar -czf "$BACKUP_DIR/config_backup_$DATE.tar.gz" \
/volume1/docker/document-server/config/
# 7일 이상 된 백업 파일 삭제
find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete
echo "Backup completed: $DATE"
```
#### 시놀로지 작업 스케줄러 설정
```bash
# 매일 새벽 2시 자동 백업
# 제어판 > 작업 스케줄러 > 생성 > 사용자 정의 스크립트
0 2 * * * /volume1/docker/document-server/scripts/backup.sh
```
### 모니터링 및 유지보수
#### 리소스 모니터링
```bash
# 컨테이너 리소스 사용량 확인
docker stats
# 디스크 사용량 확인
df -h /volume1 /volume2
# 시놀로지 시스템 상태
cat /proc/meminfo | grep -E "MemTotal|MemAvailable"
```
#### 로그 로테이션 설정
```bash
# /etc/logrotate.d/document-server
/volume1/docker/document-server/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 644 1000 1000
postrotate
docker-compose -f docker-compose.synology.yml restart backend
endscript
}
```
### 네트워크 최적화
#### 포트 포워딩 설정
- **외부 포트**: 24100 (HTTPS 리버스 프록시 권장)
- **내부 포트**: 24100 (Nginx)
- **방화벽**: 필요한 포트만 개방
#### SSL/TLS 설정 (Let's Encrypt)
```bash
# Certbot을 통한 SSL 인증서 자동 갱신
docker run --rm -v /volume1/docker/document-server/ssl:/etc/letsencrypt \
certbot/certbot certonly --webroot \
-w /volume2/document-storage/documents \
-d your-domain.com
``` ```
## API 엔드포인트 (예상) ## API 엔드포인트 (예상)

View File

@@ -1,139 +0,0 @@
# Viewer.js 리팩토링 계획
## 📊 현재 상황
- **파일 크기**: 3,712줄 (너무 큼!)
- **주요 문제**: 유지보수 어려움, 기능 추가 시 복잡도 증가
- **목표**: 모듈화를 통한 코드 분리 및 관리성 향상
## 🏗️ 분리 구조
```
frontend/static/js/viewer/
├── core/
│ ├── viewer-core.js # 메인 Alpine.js 컴포넌트
│ └── document-loader.js # 문서/노트 로딩
├── features/
│ ├── highlight-manager.js # 하이라이트/메모 관리
│ ├── link-manager.js # 문서링크/백링크 관리
│ ├── bookmark-manager.js # 북마크 관리
│ └── ui-manager.js # 모달/패널/검색 UI
└── utils/
├── text-utils.js # 텍스트 선택/조작 유틸
└── dom-utils.js # DOM 조작 유틸
```
## 📦 모듈별 책임
### 1. 📄 DocumentLoader (`core/document-loader.js`)
**책임**: 문서/노트 로딩 및 네비게이션
- `loadDocument()` - HTML 문서 로딩
- `loadNote()` - 노트 문서 로딩
- `loadNavigation()` - 네비게이션 정보 로딩
- `checkForTextHighlight()` - URL 파라미터 기반 하이라이트
### 2. 🎨 HighlightManager (`features/highlight-manager.js`)
**책임**: 하이라이트 및 메모 관리
- `loadHighlights()` - 하이라이트 데이터 로딩
- `loadNotes()` - 메모 데이터 로딩
- `renderHighlights()` - 하이라이트 렌더링
- `createHighlightWithColor()` - 하이라이트 생성
- `saveNote()` - 메모 저장
- `deleteNote()` - 메모 삭제
### 3. 🔗 LinkManager (`features/link-manager.js`)
**책임**: 문서링크 및 백링크 관리
- `loadDocumentLinks()` - 문서링크 로딩
- `loadBacklinks()` - 백링크 로딩
- `renderDocumentLinks()` - 문서링크 렌더링
- `renderBacklinkHighlights()` - 백링크 하이라이트 렌더링
- `createLink()` - 링크 생성
- `navigateToLink()` - 링크 네비게이션
### 4. 📌 BookmarkManager (`features/bookmark-manager.js`)
**책임**: 북마크 관리
- `loadBookmarks()` - 북마크 데이터 로딩
- `saveBookmark()` - 북마크 저장
- `deleteBookmark()` - 북마크 삭제
- `navigateToBookmark()` - 북마크로 이동
### 5. 🎛️ UIManager (`features/ui-manager.js`)
**책임**: 모달, 패널, 검색 UI 관리
- `toggleFeatureMenu()` - 기능 메뉴 토글
- `showModal()` / `hideModal()` - 모달 관리
- `handleSearch()` - 검색 기능
- `toggleLanguage()` - 언어 전환
- `setupTextSelectionMode()` - 텍스트 선택 모드
### 6. 🔧 ViewerCore (`core/viewer-core.js`)
**책임**: Alpine.js 컴포넌트 및 모듈 통합
- Alpine.js 상태 관리
- 모듈 간 통신 조정
- 초기화 및 라이프사이클 관리
- 전역 이벤트 처리
### 7. 🛠️ Utils
**책임**: 공통 유틸리티 함수
- `text-utils.js`: 텍스트 선택, 범위 조작
- `dom-utils.js`: DOM 조작, 요소 검색
## 🔄 연결관계도
```mermaid
graph TD
A[ViewerCore] --> B[DocumentLoader]
A --> C[HighlightManager]
A --> D[LinkManager]
A --> E[BookmarkManager]
A --> F[UIManager]
B --> G[text-utils]
C --> G
C --> H[dom-utils]
D --> G
D --> H
E --> G
F --> H
C -.-> D[하이라이트↔링크 연동]
D -.-> C[백링크↔하이라이트 연동]
```
## 📋 마이그레이션 순서
1. **📄 DocumentLoader** (가장 독립적)
2. **🎨 HighlightManager** (백링크와 연관성 적음)
3. **📌 BookmarkManager** (독립적 기능)
4. **🎛️ UIManager** (UI 관련 기능)
5. **🔗 LinkManager** (백링크 개선 예정이므로 마지막)
6. **🔧 ViewerCore** (모든 모듈 통합)
## 🔍 현재 코드 의존성 분석
### 핵심 함수들:
- **초기화**: `init()`, `loadDocumentData()`
- **문서 로딩**: `loadDocument()`, `loadNote()`, `loadNavigation()`
- **하이라이트**: `renderHighlights()`, `createHighlight()`, `handleTextSelection()`
- **메모**: `saveNote()`, `deleteNote()`, `createMemoForHighlight()`
- **북마크**: `addBookmark()`, `saveBookmark()`, `deleteBookmark()`
- **링크**: `renderDocumentLinks()`, `renderBacklinkHighlights()`, `loadBacklinks()`
- **UI**: `toggleLanguage()`, `searchInDocument()`, `goBack()`
### 상호 의존성:
1. **하이라이트 ↔ 메모**: `createMemoForHighlight()` 연결
2. **하이라이트 ↔ 링크**: `renderBacklinkHighlights()` 연결
3. **문서로딩 → 모든 기능**: 초기화 후 모든 기능 활성화
4. **UI → 모든 기능**: 모달/패널에서 모든 기능 호출
## ⚠️ 주의사항
- **점진적 마이그레이션**: 한 번에 하나씩 분리
- **기능 테스트**: 각 단계마다 기능 동작 확인
- **의존성 관리**: 모듈 간 순환 참조 방지
- **Alpine.js 호환성**: 기존 템플릿과의 호환성 유지
## 🎯 기대 효과
- **유지보수성 향상**: 기능별 코드 분리
- **개발 효율성**: 병렬 개발 가능
- **테스트 용이성**: 모듈별 단위 테스트
- **확장성**: 새 기능 추가 시 영향 범위 최소화

View File

@@ -0,0 +1,58 @@
-- 할일관리 시스템 테이블 생성
-- 할일 아이템 테이블
CREATE TABLE IF NOT EXISTS todo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 기본 정보
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
-- 시간 관리
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
start_date TIMESTAMP WITH TIME ZONE,
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
completed_at TIMESTAMP WITH TIME ZONE,
delayed_until TIMESTAMP WITH TIME ZONE,
-- 분할 관리
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
split_order INTEGER,
-- 인덱스
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
);
-- 할일 댓글 테이블
CREATE TABLE IF NOT EXISTS todo_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_todo_comments_updated_at
BEFORE UPDATE ON todo_comments
FOR EACH ROW
EXECUTE FUNCTION update_todo_comments_updated_at();

View File

@@ -0,0 +1,75 @@
-- 노트 링크 테이블 생성
-- 노트 문서 간 또는 노트-문서 간 링크를 관리하는 테이블
CREATE TABLE IF NOT EXISTS note_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 링크 출발점 (노트 또는 문서 중 하나)
source_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
source_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 링크 도착점 (노트 또는 문서 중 하나)
target_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
target_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 출발점 텍스트 정보
selected_text TEXT NOT NULL,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
-- 도착점 텍스트 정보 (선택사항)
target_text TEXT,
target_start_offset INTEGER,
target_end_offset INTEGER,
-- 링크 메타데이터
link_text VARCHAR(500),
description TEXT,
link_type VARCHAR(20) DEFAULT 'note' NOT NULL,
-- 생성자 및 시간 정보
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE,
-- 제약 조건
CONSTRAINT note_links_source_check CHECK (
(source_note_id IS NOT NULL AND source_document_id IS NULL) OR
(source_note_id IS NULL AND source_document_id IS NOT NULL)
),
CONSTRAINT note_links_target_check CHECK (
(target_note_id IS NOT NULL AND target_document_id IS NULL) OR
(target_note_id IS NULL AND target_document_id IS NOT NULL)
)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_note_links_source_note ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_source_document ON note_links(source_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_note ON note_links(target_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_document ON note_links(target_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_created_by ON note_links(created_by);
CREATE INDEX IF NOT EXISTS idx_note_links_created_at ON note_links(created_at);
-- updated_at 자동 업데이트 트리거
CREATE OR REPLACE FUNCTION update_note_links_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_note_links_updated_at
BEFORE UPDATE ON note_links
FOR EACH ROW
EXECUTE FUNCTION update_note_links_updated_at();
-- 코멘트 추가
COMMENT ON TABLE note_links IS '노트 문서 간 링크 관리 테이블';
COMMENT ON COLUMN note_links.source_note_id IS '출발점 노트 ID (노트에서 시작하는 링크)';
COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서에서 시작하는 링크)';
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';

View File

@@ -23,6 +23,8 @@ python-dotenv = "^1.0.0"
httpx = "^0.25.0" httpx = "^0.25.0"
aiofiles = "^23.2.0" aiofiles = "^23.2.0"
jinja2 = "^3.1.0" jinja2 = "^3.1.0"
beautifulsoup4 = "^4.13.0"
pypdf2 = "^3.0.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.4.0" pytest = "^7.4.0"

View File

@@ -1,7 +1,7 @@
""" """
API 의존성 API 의존성
""" """
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -12,8 +12,8 @@ from ..core.security import verify_token, get_user_id_from_token
from ..models.user import User from ..models.user import User
# HTTP Bearer 토큰 스키마 # HTTP Bearer 토큰 스키마 (선택적)
security = HTTPBearer() security = HTTPBearer(auto_error=False)
async def get_current_user( async def get_current_user(
@@ -86,3 +86,64 @@ async def get_optional_current_user(
return await get_current_user(credentials, db) return await get_current_user(credentials, db)
except HTTPException: except HTTPException:
return None return None
async def get_current_user_with_token_param(
_token: Optional[str] = Query(None),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
print(f"🔍 토큰 인증 시작 - URL 파라미터: {_token[:50] if _token else 'None'}...")
print(f"🔍 Authorization 헤더: {credentials.credentials[:50] if credentials else 'None'}...")
token = None
# URL 파라미터에서 토큰 확인
if _token:
token = _token
print("✅ URL 파라미터에서 토큰 사용")
# Authorization 헤더에서 토큰 확인
elif credentials:
token = credentials.credentials
print("✅ Authorization 헤더에서 토큰 사용")
if not token:
print("❌ 토큰이 제공되지 않음")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No authentication token provided"
)
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(token)
print(f"✅ 토큰에서 사용자 ID 추출: {user_id}")
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
print(f"❌ 사용자를 찾을 수 없음: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
print(f"❌ 비활성 사용자: {user.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
print(f"✅ 사용자 인증 성공: {user.email}")
return user
except Exception as e:
print(f"🚫 토큰 인증 실패: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)

View File

@@ -46,8 +46,11 @@ async def login(
detail="Inactive user" detail="Inactive user"
) )
# 토큰 생성 # 사용자별 세션 타임아웃을 적용한 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)}) access_token = create_access_token(
data={"sub": str(user.id)},
timeout_minutes=user.session_timeout_minutes
)
refresh_token = create_refresh_token(data={"sub": str(user.id)}) refresh_token = create_refresh_token(data={"sub": str(user.id)})
# 마지막 로그인 시간 업데이트 # 마지막 로그인 시간 업데이트

View File

@@ -33,7 +33,7 @@ async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
document_count=document_count document_count=document_count
) )
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
async def create_book( async def create_book(
book_data: CreateBookRequest, book_data: CreateBookRequest,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
@@ -58,7 +58,7 @@ async def create_book(
await db.refresh(new_book) await db.refresh(new_book)
return await _get_book_response(db, new_book) return await _get_book_response(db, new_book)
@router.get("/", response_model=List[BookResponse]) @router.get("", response_model=List[BookResponse])
async def get_books( async def get_books(
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,

View File

@@ -64,6 +64,7 @@ class DocumentLinkResponse(BaseModel):
# 대상 문서 정보 # 대상 문서 정보
target_document_title: str target_document_title: str
target_document_book_id: Optional[str] target_document_book_id: Optional[str]
target_content_type: Optional[str] = "document" # "document" 또는 "note"
class Config: class Config:
from_attributes = True from_attributes = True
@@ -115,29 +116,55 @@ async def create_document_link(
detail="Access denied to source document" detail="Access denied to source document"
) )
# 대상 문서 확인 # 대상 문서 또는 노트 확인
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id)) result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none() target_doc = result.scalar_one_or_none()
target_note = None
if not target_doc: if not target_doc:
raise HTTPException( # 문서에서 찾지 못하면 노트에서 찾기
status_code=status.HTTP_404_NOT_FOUND, print(f"🔍 문서에서 찾지 못함, 노트에서 검색: {link_data.target_document_id}")
detail="Target document not found" from ...models.note_document import NoteDocument
) result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id))
target_note = result.scalar_one_or_none()
# 대상 문서 권한 확인 if target_note:
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin: print(f"✅ 노트 찾음: {target_note.title}")
raise HTTPException( else:
status_code=status.HTTP_403_FORBIDDEN, print(f"❌ 노트도 찾지 못함: {link_data.target_document_id}")
detail="Access denied to target document" # 디버깅: 실제 존재하는 노트들 확인
) all_notes_result = await db.execute(select(NoteDocument).limit(5))
all_notes = all_notes_result.scalars().all()
print(f"🔍 존재하는 노트 예시 (최대 5개):")
for note in all_notes:
print(f" - ID: {note.id}, 제목: {note.title}")
# HTML 문서만 링크 가능 raise HTTPException(
if not target_doc.html_path: status_code=status.HTTP_404_NOT_FOUND,
raise HTTPException( detail="Target document or note not found"
status_code=status.HTTP_400_BAD_REQUEST, )
detail="Can only link to HTML documents"
) # 대상 문서/노트 권한 확인
if target_doc:
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target document"
)
# HTML 문서만 링크 가능
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
elif target_note:
# 노트 권한 확인 (노트는 기본적으로 생성자만 접근 가능)
if target_note.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target note"
)
# 링크 생성 # 링크 생성
new_link = DocumentLink( new_link = DocumentLink(
@@ -160,7 +187,9 @@ async def create_document_link(
await db.commit() await db.commit()
await db.refresh(new_link) await db.refresh(new_link)
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_doc.title}") target_title = target_doc.title if target_doc else target_note.title
target_type = "document" if target_doc else "note"
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_title} ({target_type})")
print(f" - 링크 타입: {new_link.link_type}") print(f" - 링크 타입: {new_link.link_type}")
print(f" - 선택된 텍스트: {new_link.selected_text}") print(f" - 선택된 텍스트: {new_link.selected_text}")
print(f" - 대상 텍스트: {new_link.target_text}") print(f" - 대상 텍스트: {new_link.target_text}")
@@ -184,8 +213,8 @@ async def create_document_link(
link_type=new_link.link_type, link_type=new_link.link_type,
created_at=new_link.created_at.isoformat(), created_at=new_link.created_at.isoformat(),
updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None, updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None,
target_document_title=target_doc.title, target_document_title=target_title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_id else None)
) )
@@ -213,19 +242,51 @@ async def get_document_links(
detail="Access denied" detail="Access denied"
) )
# 링크 조회 (JOIN으로 대상 문서 정보도 함께) # 모든 링크 조회 (문서→문서 + 문서→노트)
result = await db.execute( result = await db.execute(
select(DocumentLink, Document) select(DocumentLink)
.join(Document, DocumentLink.target_document_id == Document.id)
.where(DocumentLink.source_document_id == document_id) .where(DocumentLink.source_document_id == document_id)
.order_by(DocumentLink.start_offset.asc()) .order_by(DocumentLink.start_offset.asc())
) )
links_with_targets = result.all() all_links = result.scalars().all()
print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견")
# 응답 데이터 구성 # 응답 데이터 구성
response_links = [] response_links = []
for link, target_doc in links_with_targets: for link in all_links:
print(f"🔗 링크 처리 중: {link.id} -> {link.target_document_id}")
# 대상이 문서인지 노트인지 확인
target_doc = None
target_note = None
# 먼저 Document 테이블에서 찾기
doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id))
target_doc = doc_result.scalar_one_or_none()
if target_doc:
print(f"✅ 대상 문서 찾음: {target_doc.title}")
target_title = target_doc.title
target_book_id = str(target_doc.book_id) if target_doc.book_id else None
target_content_type = "document"
else:
# Document에서 찾지 못하면 NoteDocument에서 찾기
from ...models.note_document import NoteDocument
note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id))
target_note = note_result.scalar_one_or_none()
if target_note:
print(f"✅ 대상 노트 찾음: {target_note.title}")
target_title = f"📝 {target_note.title}" # 노트임을 표시
target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None
target_content_type = "note"
else:
print(f"❌ 대상을 찾을 수 없음: {link.target_document_id}")
target_title = "Unknown Target"
target_book_id = None
target_content_type = "document" # 기본값
response_links.append(DocumentLinkResponse( response_links.append(DocumentLinkResponse(
id=str(link.id), id=str(link.id),
source_document_id=str(link.source_document_id), source_document_id=str(link.source_document_id),
@@ -242,9 +303,10 @@ async def get_document_links(
target_start_offset=getattr(link, 'target_start_offset', None), target_start_offset=getattr(link, 'target_start_offset', None),
target_end_offset=getattr(link, 'target_end_offset', None), target_end_offset=getattr(link, 'target_end_offset', None),
link_type=getattr(link, 'link_type', 'document'), link_type=getattr(link, 'link_type', 'document'),
# 대상 문서 정보 # 대상 문서/노트 정보 추가
target_document_title=target_doc.title, target_document_title=target_title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None target_document_book_id=target_book_id,
target_content_type=target_content_type
)) ))
return response_links return response_links
@@ -384,6 +446,7 @@ async def update_document_link(
@router.delete("/links/{link_id}") @router.delete("/links/{link_id}")
@router.delete("/document-links/{link_id}") # 프론트엔드 호환성을 위한 추가 경로
async def delete_document_link( async def delete_document_link(
link_id: str, link_id: str,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
@@ -419,6 +482,7 @@ class BacklinkResponse(BaseModel):
source_document_id: str source_document_id: str
source_document_title: str source_document_title: str
source_document_book_id: Optional[str] source_document_book_id: Optional[str]
source_content_type: Optional[str] = "document" # "document" or "note"
target_document_id: str target_document_id: str
target_document_title: str target_document_title: str
selected_text: str # 소스 문서에서 선택한 텍스트 selected_text: str # 소스 문서에서 선택한 텍스트
@@ -467,8 +531,12 @@ async def get_document_backlinks(
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크) # 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
from ...models import Book from ...models import Book
from ...models.note_link import NoteLink
from ...models.note_document import NoteDocument
from ...models.notebook import Notebook
query = select(DocumentLink, Document, Book).join( # 1. 일반 문서에서 오는 백링크 (DocumentLink)
doc_query = select(DocumentLink, Document, Book).join(
Document, DocumentLink.source_document_id == Document.id Document, DocumentLink.source_document_id == Document.id
).outerjoin(Book, Document.book_id == Book.id).where( ).outerjoin(Book, Document.book_id == Book.id).where(
and_( and_(
@@ -478,12 +546,13 @@ async def get_document_backlinks(
) )
).order_by(DocumentLink.created_at.desc()) ).order_by(DocumentLink.created_at.desc())
result = await db.execute(query) doc_result = await db.execute(doc_query)
backlinks = [] backlinks = []
print(f"🔍 백링크 쿼리 실행 완료") print(f"🔍 문서 백링크 쿼리 실행 완료")
for link, source_doc, book in result.fetchall(): # 일반 문서 백링크 처리
for link, source_doc, book in doc_result.fetchall():
print(f"📋 백링크 발견: {source_doc.title} -> {document.title}") print(f"📋 백링크 발견: {source_doc.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}") print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}") print(f" - 타겟 텍스트 (target_text): {link.target_text}")
@@ -495,6 +564,7 @@ async def get_document_backlinks(
source_document_id=str(link.source_document_id), source_document_id=str(link.source_document_id),
source_document_title=source_doc.title, source_document_title=source_doc.title,
source_document_book_id=str(book.id) if book else None, source_document_book_id=str(book.id) if book else None,
source_content_type="document", # 일반 문서
target_document_id=str(link.target_document_id), target_document_id=str(link.target_document_id),
target_document_title=document.title, target_document_title=document.title,
selected_text=link.selected_text, # 소스 문서에서 선택한 텍스트 (참고용) selected_text=link.selected_text, # 소스 문서에서 선택한 텍스트 (참고용)
@@ -509,7 +579,57 @@ async def get_document_backlinks(
created_at=link.created_at.isoformat() created_at=link.created_at.isoformat()
)) ))
print(f"✅ 총 {len(backlinks)}개의 백링크 반환") # 2. 노트에서 오는 백링크 (NoteLink) - 동기 쿼리 사용
try:
from ...core.database import get_sync_db
sync_db = next(get_sync_db())
# 노트에서 이 문서를 대상으로 하는 링크들 조회
note_links = sync_db.query(NoteLink).join(
NoteDocument, NoteLink.source_note_id == NoteDocument.id
).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter(
NoteLink.target_document_id == document_id
).all()
print(f"🔍 노트 백링크 쿼리 실행 완료: {len(note_links)}개 발견")
# 노트 백링크 처리
for link in note_links:
source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None
if source_note:
print(f"📋 노트 백링크 발견: {source_note.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_note_id), # 노트 ID를 문서 ID로 사용
source_document_title=f"📝 {source_note.title}", # 노트임을 표시
source_document_book_id=str(notebook.id) if notebook else None,
source_content_type="note", # 노트 문서
target_document_id=str(link.target_document_id) if link.target_document_id else document_id,
target_document_title=document.title,
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
target_text=link.target_text,
target_start_offset=link.target_start_offset,
target_end_offset=link.target_end_offset,
created_at=link.created_at.isoformat() if link.created_at else None
))
sync_db.close()
except Exception as e:
print(f"❌ 노트 백링크 조회 실패: {e}")
print(f"✅ 총 {len(backlinks)}개의 백링크 반환 (문서 + 노트)")
return backlinks return backlinks

View File

@@ -1,7 +1,7 @@
""" """
문서 관리 API 라우터 문서 관리 API 라우터
""" """
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_, update from sqlalchemy import select, delete, and_, or_, update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -17,7 +17,7 @@ from ...core.config import settings
from ...models.user import User from ...models.user import User
from ...models.document import Document, Tag from ...models.document import Document, Tag
from ...models.book import Book from ...models.book import Book
from ..dependencies import get_current_active_user, get_current_admin_user from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
@@ -468,7 +468,8 @@ async def get_document(
@router.get("/{document_id}/content") @router.get("/{document_id}/content")
async def get_document_content( async def get_document_content(
document_id: str, document_id: str,
current_user: User = Depends(get_current_active_user), _token: Optional[str] = Query(None),
current_user: User = Depends(get_current_user_with_token_param),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""문서 HTML 콘텐츠 조회""" """문서 HTML 콘텐츠 조회"""
@@ -507,6 +508,207 @@ async def get_document_content(
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}") raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
@router.get("/{document_id}/pdf")
async def get_document_pdf(
document_id: str,
_token: Optional[str] = Query(None),
current_user: User = Depends(get_current_user_with_token_param),
db: AsyncSession = Depends(get_db)
):
"""문서 PDF 파일 조회"""
print(f"🔍 PDF 요청 - 문서 ID: {document_id}")
print(f"🔍 토큰 파라미터: {_token[:50] if _token else 'None'}...")
print(f"🔍 현재 사용자: {current_user.email if current_user else 'None'}")
try:
doc_uuid = UUID(document_id)
except ValueError:
print(f"❌ 잘못된 문서 ID 형식: {document_id}")
raise HTTPException(status_code=400, detail="Invalid document ID format")
# 문서 조회
query = select(Document).where(Document.id == doc_uuid)
result = await db.execute(query)
document = result.scalar_one_or_none()
if not document:
print(f"❌ 문서를 찾을 수 없음: {document_id}")
raise HTTPException(status_code=404, detail="Document not found")
print(f"📄 문서 정보: {document.title}")
print(f"🔐 문서 권한: is_public={document.is_public}, uploaded_by={document.uploaded_by}")
print(f"👤 사용자 권한: is_admin={current_user.is_admin}, user_id={current_user.id}")
# 권한 확인
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
print(f"❌ 접근 권한 없음 - 관리자: {current_user.is_admin}, 공개: {document.is_public}, 소유자: {document.uploaded_by == current_user.id}")
raise HTTPException(status_code=403, detail="Access denied")
# PDF 파일 확인
if not document.pdf_path:
print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}")
raise HTTPException(status_code=404, detail="PDF file not found for this document")
# PDF 파일 경로 처리
import os
from fastapi.responses import FileResponse
if document.pdf_path.startswith('/'):
file_path = document.pdf_path
else:
# PDF 파일은 /app/uploads에 저장됨
file_path = os.path.join("/app", document.pdf_path)
print(f"🔍 PDF 파일 경로 확인: {file_path}")
print(f"📁 데이터베이스 PDF 경로: {document.pdf_path}")
if not os.path.exists(file_path):
print(f"🚫 PDF 파일이 디스크에 없음: {file_path}")
# 디렉토리 내용 확인
dir_path = os.path.dirname(file_path)
if os.path.exists(dir_path):
files = os.listdir(dir_path)
print(f"📂 디렉토리 내용: {files[:10]}")
else:
print(f"📂 디렉토리도 없음: {dir_path}")
raise HTTPException(status_code=404, detail="PDF file not found on disk")
# PDF 인라인 표시를 위한 헤더 설정
from fastapi.responses import FileResponse
response = FileResponse(
path=file_path,
media_type='application/pdf',
filename=f"{document.title}.pdf"
)
# 브라우저에서 인라인으로 표시하도록 설정 (다운로드 방지)
response.headers["Content-Disposition"] = f"inline; filename=\"{document.title}.pdf\""
response.headers["X-Frame-Options"] = "SAMEORIGIN" # iframe 허용
return response
@router.get("/{document_id}/search-in-content")
async def search_in_document_content(
document_id: str,
q: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서 내에서 텍스트 검색 및 페이지 위치 반환"""
try:
doc_uuid = UUID(document_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid document ID format")
# 문서 조회
query = select(Document).where(Document.id == doc_uuid)
result = await db.execute(query)
document = result.scalar_one_or_none()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# 권한 확인
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
search_results = []
# HTML 파일에서 검색 (OCR 결과)
if document.html_path:
try:
import os
from bs4 import BeautifulSoup
import re
# 절대 경로 처리
if document.html_path.startswith('/'):
html_file_path = document.html_path
else:
html_file_path = os.path.join("/app/data/documents", document.html_path)
if os.path.exists(html_file_path):
with open(html_file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# HTML에서 페이지별로 검색
soup = BeautifulSoup(html_content, 'html.parser')
# 페이지 구분자 찾기 (OCR 결과에서 페이지 정보)
pages = soup.find_all(['div', 'section'], class_=re.compile(r'page|Page'))
if not pages:
# 페이지 구분이 없으면 전체 텍스트에서 검색
text_content = soup.get_text()
matches = []
start = 0
while True:
pos = text_content.lower().find(q.lower(), start)
if pos == -1:
break
# 컨텍스트 추출
context_start = max(0, pos - 100)
context_end = min(len(text_content), pos + len(q) + 100)
context = text_content[context_start:context_end]
matches.append({
"page": 1,
"position": pos,
"context": context,
"match_text": text_content[pos:pos + len(q)]
})
start = pos + 1
if len(matches) >= 10: # 최대 10개 결과
break
search_results.extend(matches)
else:
# 페이지별로 검색
for page_num, page_elem in enumerate(pages, 1):
page_text = page_elem.get_text()
matches = []
start = 0
while True:
pos = page_text.lower().find(q.lower(), start)
if pos == -1:
break
# 컨텍스트 추출
context_start = max(0, pos - 100)
context_end = min(len(page_text), pos + len(q) + 100)
context = page_text[context_start:context_end]
matches.append({
"page": page_num,
"position": pos,
"context": context,
"match_text": page_text[pos:pos + len(q)]
})
start = pos + 1
if len(matches) >= 5: # 페이지당 최대 5개
break
search_results.extend(matches)
except Exception as e:
print(f"HTML 검색 오류: {e}")
return {
"document_id": document_id,
"query": q,
"total_matches": len(search_results),
"matches": search_results[:20], # 최대 20개 결과
"has_pdf": bool(document.pdf_path),
"has_html": bool(document.html_path)
}
class UpdateDocumentRequest(BaseModel): class UpdateDocumentRequest(BaseModel):
"""문서 업데이트 요청""" """문서 업데이트 요청"""
title: Optional[str] = None title: Optional[str] = None
@@ -620,13 +822,14 @@ async def update_document(
created_at=document.created_at, created_at=document.created_at,
updated_at=document.updated_at, updated_at=document.updated_at,
document_date=document.document_date, document_date=document.document_date,
uploader_name=document.uploader.full_name or document.uploader.email, uploader_name=document.uploader.full_name or document.uploader.email if document.uploader else "Unknown",
tags=[tag.name for tag in document.tags], tags=[tag.name for tag in document.tags],
book_id=str(document.book.id) if document.book else None, book_id=str(document.book.id) if document.book else None,
book_title=document.book.title if document.book else None, book_title=document.book.title if document.book else None,
book_author=document.book.author if document.book else None, book_author=document.book.author if document.book else None,
sort_order=document.sort_order, sort_order=document.sort_order,
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None,
original_filename=document.original_filename
) )

View File

@@ -0,0 +1,271 @@
"""
노트 문서 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, asc
from typing import List, Optional
import html
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_document import (
NoteDocument,
NoteDocumentCreate,
NoteDocumentUpdate,
NoteDocumentResponse,
NoteDocumentListItem,
NoteStats
)
from ...models.notebook import Notebook
router = APIRouter()
def calculate_reading_time(content: str) -> int:
"""HTML 내용에서 예상 읽기 시간 계산 (분)"""
if not content:
return 0
# HTML 태그 제거
text_content = html.unescape(content)
# 간단한 HTML 태그 제거 (정확하지 않지만 대략적인 계산용)
import re
text_content = re.sub(r'<[^>]+>', '', text_content)
# 단어 수 계산 (한국어 + 영어)
words = len(text_content.split())
korean_chars = len([c for c in text_content if '\uac00' <= c <= '\ud7af'])
# 대략적인 읽기 속도: 영어 200단어/분, 한국어 300자/분
english_time = words / 200
korean_time = korean_chars / 300
return max(1, int(english_time + korean_time))
@router.get("/", response_model=List[NoteDocumentListItem])
def get_note_documents(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None),
note_type: Optional[str] = Query(None),
published_only: bool = Query(False),
notebook_id: Optional[str] = Query(None),
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|word_count)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 문서 목록 조회"""
query = db.query(NoteDocument)
# 필터링
if search:
search_term = f"%{search}%"
query = query.filter(
(NoteDocument.title.ilike(search_term)) |
(NoteDocument.content.ilike(search_term))
)
if note_type:
query = query.filter(NoteDocument.note_type == note_type)
if published_only:
query = query.filter(NoteDocument.is_published == True)
if notebook_id:
query = query.filter(NoteDocument.notebook_id == notebook_id)
# 정렬
if sort_by == 'title':
query = query.order_by(asc(NoteDocument.title) if order == 'asc' else desc(NoteDocument.title))
elif sort_by == 'created_at':
query = query.order_by(asc(NoteDocument.created_at) if order == 'asc' else desc(NoteDocument.created_at))
elif sort_by == 'word_count':
query = query.order_by(asc(NoteDocument.word_count) if order == 'asc' else desc(NoteDocument.word_count))
else:
query = query.order_by(desc(NoteDocument.updated_at))
# 페이지네이션
notes = query.offset(skip).limit(limit).all()
# 자식 노트 개수 계산
result = []
for note in notes:
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 result
@router.get("/stats", response_model=NoteStats)
def get_note_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 통계 정보"""
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}
# 총 단어 수와 읽기 시간
total_words = db.query(func.sum(NoteDocument.word_count)).scalar() or 0
total_reading_time = db.query(func.sum(NoteDocument.reading_time)).scalar() or 0
# 최근 노트들
recent_notes_query = db.query(NoteDocument).order_by(
desc(NoteDocument.updated_at)
).limit(5).all()
recent_notes = [NoteDocumentListItem.from_orm(note) for note in recent_notes_query]
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
)
@router.get("/{note_id}", response_model=NoteDocumentResponse)
def get_note_document(
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")
return NoteDocumentResponse.from_orm(note)
@router.post("/", response_model=NoteDocumentResponse)
def create_note_document(
note_data: NoteDocumentCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트 문서 생성"""
# 단어 수 및 읽기 시간 계산
word_count = len(note_data.content or '') if note_data.content else 0
reading_time = calculate_reading_time(note_data.content or '')
note = NoteDocument(
title=note_data.title,
content=note_data.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,
notebook_id=note_data.notebook_id,
created_by=current_user.email,
word_count=word_count,
reading_time=reading_time
)
db.add(note)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.put("/{note_id}", response_model=NoteDocumentResponse)
def update_note_document(
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.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트할 필드만 적용
update_data = note_data.dict(exclude_unset=True)
# 내용이 변경되면 단어 수와 읽기 시간 재계산
if 'content' in update_data:
update_data['word_count'] = len(update_data['content'] or '')
update_data['reading_time'] = calculate_reading_time(update_data['content'] or '')
for field, value in update_data.items():
setattr(note, field, value)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.delete("/{note_id}")
def delete_note_document(
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")
# 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 자식 노트들이 있는지 확인
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
if child_count > 0:
raise HTTPException(
status_code=400,
detail=f"Cannot delete note with {child_count} child notes"
)
db.delete(note)
db.commit()
return {"message": "Note deleted successfully"}
@router.get("/{note_id}/content")
def get_note_document_content(
note_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_sync_db)
):
"""노트 문서의 HTML 콘텐츠만 반환"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note document not found")
return note.content or ""

View File

@@ -0,0 +1,291 @@
"""
노트 문서 링크 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import List, Optional
from pydantic import BaseModel
import uuid
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_document import NoteDocument
from ...models.document import Document
from ...models.note_link import NoteLink
router = APIRouter()
# Pydantic 모델들
class NoteLinkCreate(BaseModel):
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = "note"
class NoteLinkUpdate(BaseModel):
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = None
class NoteLinkResponse(BaseModel):
id: str
source_note_id: Optional[str] = None
source_document_id: Optional[str] = None
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
target_content_type: Optional[str] = None # "document" or "note"
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: str
created_at: str
updated_at: Optional[str] = None
# 추가 정보
target_note_title: Optional[str] = None
target_document_title: Optional[str] = None
source_note_title: Optional[str] = None
source_document_title: Optional[str] = None
class Config:
from_attributes = True
@router.get("/note-documents/{note_id}/links", response_model=List[NoteLinkResponse])
def get_note_links(
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")
# 노트에서 나가는 링크들 조회
links = db.query(NoteLink).filter(
NoteLink.source_note_id == note_id
).all()
result = []
for link in links:
link_data = {
"id": str(link.id),
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
"selected_text": link.selected_text,
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"link_text": link.link_text,
"description": link.description,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset,
"link_type": link.link_type,
"created_at": link.created_at.isoformat() if link.created_at else None,
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
}
# 대상 제목 및 타입 추가
if link.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == link.target_note_id).first()
if target_note:
link_data["target_note_title"] = target_note.title
link_data["target_content_type"] = "note"
elif link.target_document_id:
target_doc = db.query(Document).filter(Document.id == link.target_document_id).first()
if target_doc:
link_data["target_document_title"] = target_doc.title
link_data["target_content_type"] = "document"
result.append(NoteLinkResponse(**link_data))
return result
@router.get("/note-documents/{note_id}/backlinks", response_model=List[NoteLinkResponse])
def get_note_backlinks(
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")
# 노트로 들어오는 백링크들 조회
backlinks = db.query(NoteLink).filter(
NoteLink.target_note_id == note_id
).all()
result = []
for link in backlinks:
link_data = {
"id": str(link.id),
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
"selected_text": link.selected_text,
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"link_text": link.link_text,
"description": link.description,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset,
"link_type": link.link_type,
"created_at": link.created_at.isoformat() if link.created_at else None,
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
}
# 출발지 제목 추가
if link.source_note_id:
source_note = db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
if source_note:
link_data["source_note_title"] = source_note.title
elif link.source_document_id:
source_doc = db.query(Document).filter(Document.id == link.source_document_id).first()
if source_doc:
link_data["source_document_title"] = source_doc.title
result.append(NoteLinkResponse(**link_data))
return result
@router.post("/note-documents/{note_id}/links", response_model=NoteLinkResponse)
def create_note_link(
note_id: str,
link_data: NoteLinkCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트에서 다른 노트/문서로의 링크 생성"""
# 출발지 노트 존재 확인
source_note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not source_note:
raise HTTPException(status_code=404, detail="Source note not found")
# 대상 확인 (노트 또는 문서 중 하나는 반드시 있어야 함)
if not link_data.target_note_id and not link_data.target_document_id:
raise HTTPException(status_code=400, detail="Either target_note_id or target_document_id is required")
if link_data.target_note_id and link_data.target_document_id:
raise HTTPException(status_code=400, detail="Cannot specify both target_note_id and target_document_id")
# 대상 존재 확인
if link_data.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == link_data.target_note_id).first()
if not target_note:
raise HTTPException(status_code=404, detail="Target note not found")
if link_data.target_document_id:
target_doc = db.query(Document).filter(Document.id == link_data.target_document_id).first()
if not target_doc:
raise HTTPException(status_code=404, detail="Target document not found")
# 링크 생성
note_link = NoteLink(
source_note_id=note_id,
target_note_id=link_data.target_note_id,
target_document_id=link_data.target_document_id,
selected_text=link_data.selected_text,
start_offset=link_data.start_offset,
end_offset=link_data.end_offset,
link_text=link_data.link_text,
description=link_data.description,
target_text=link_data.target_text,
target_start_offset=link_data.target_start_offset,
target_end_offset=link_data.target_end_offset,
link_type=link_data.link_type or "note",
created_by=current_user.id
)
db.add(note_link)
db.commit()
db.refresh(note_link)
# 응답 데이터 구성
response_data = {
"id": str(note_link.id),
"source_note_id": str(note_link.source_note_id) if note_link.source_note_id else None,
"source_document_id": str(note_link.source_document_id) if note_link.source_document_id else None,
"target_note_id": str(note_link.target_note_id) if note_link.target_note_id else None,
"target_document_id": str(note_link.target_document_id) if note_link.target_document_id else None,
"selected_text": note_link.selected_text,
"start_offset": note_link.start_offset,
"end_offset": note_link.end_offset,
"link_text": note_link.link_text,
"description": note_link.description,
"target_text": note_link.target_text,
"target_start_offset": note_link.target_start_offset,
"target_end_offset": note_link.target_end_offset,
"link_type": note_link.link_type,
"created_at": note_link.created_at.isoformat() if note_link.created_at else None,
"updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None,
}
# 소스 및 타겟 타입 설정
response_data["source_content_type"] = "note" # 노트에서 출발하는 링크
if note_link.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == note_link.target_note_id).first()
if target_note:
response_data["target_note_title"] = target_note.title
response_data["target_content_type"] = "note"
elif note_link.target_document_id:
target_doc = db.query(Document).filter(Document.id == note_link.target_document_id).first()
if target_doc:
response_data["target_document_title"] = target_doc.title
response_data["target_content_type"] = "document"
return NoteLinkResponse(**response_data)
@router.delete("/note-links/{link_id}")
def delete_note_link(
link_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 링크 삭제"""
link = db.query(NoteLink).filter(NoteLink.id == link_id).first()
if not link:
raise HTTPException(status_code=404, detail="Link not found")
# 권한 확인 (링크 생성자 또는 관리자만 삭제 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(link)
db.commit()
return {"message": "Link deleted successfully"}

View File

@@ -33,7 +33,7 @@ router = APIRouter()
# 용어 정의: 하이라이트에 달리는 짧은 코멘트 # 용어 정의: 하이라이트에 달리는 짧은 코멘트
@router.post("/") @router.post("/")
async def create_note( def create_note(
note_data: dict, note_data: dict,
db: Session = Depends(get_sync_db), db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
@@ -65,6 +65,62 @@ async def create_note(
return note return note
@router.put("/{note_id}")
def update_note(
note_id: str,
note_data: dict,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 업데이트"""
from ...models.note import Note
from ...models.highlight import Highlight
# 메모 존재 및 소유권 확인
note = db.query(Note).join(Highlight).filter(
Note.id == note_id,
Highlight.user_id == current_user.id
).first()
if not note:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
# 메모 업데이트
if 'content' in note_data:
note.content = note_data['content']
if 'tags' in note_data:
note.tags = note_data['tags']
note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return note
@router.delete("/{note_id}")
def delete_highlight_note(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 삭제"""
from ...models.note import Note
from ...models.highlight import Highlight
note = db.query(Note).join(Highlight).filter(
Note.id == note_id,
Highlight.user_id == current_user.id
).first()
if not note:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
db.delete(note)
db.commit()
return {"message": "메모가 삭제되었습니다"}
@router.get("/document/{document_id}") @router.get("/document/{document_id}")
async def get_document_notes( async def get_document_notes(
document_id: str, document_id: str,
@@ -118,7 +174,7 @@ def calculate_word_count(content: str) -> int:
return korean_chars + english_words return korean_chars + english_words
@router.get("/", response_model=List[NoteDocumentListItem]) @router.get("/")
def get_notes( def get_notes(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
@@ -128,10 +184,34 @@ def get_notes(
published_only: bool = Query(False), published_only: bool = Query(False),
parent_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None),
notebook_id: Optional[str] = Query(None), # 노트북 필터 notebook_id: Optional[str] = Query(None), # 노트북 필터
document_id: Optional[str] = Query(None), # 하이라이트 메모 조회용
note_document_id: Optional[str] = Query(None), # 노트 문서의 하이라이트 메모 조회용
db: Session = Depends(get_sync_db), db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""노트 목록 조회""" """노트 목록 조회 또는 하이라이트 메모 조회"""
# 하이라이트 메모 조회 요청인 경우
if document_id or note_document_id:
from ...models.note import Note
from ...models.highlight import Highlight
if 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()
else:
# 노트 문서의 하이라이트 메모 조회 (note_document_id)
# 노트 하이라이트 모델이 있다면 사용, 없다면 빈 리스트 반환
notes = []
return notes
# 일반 노트 문서 목록 조회
# 동기 SQLAlchemy 스타일 # 동기 SQLAlchemy 스타일
query = db.query(NoteDocument) query = db.query(NoteDocument)

View File

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

View File

@@ -0,0 +1,104 @@
"""
시스템 초기 설정 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel, EmailStr
from typing import Optional
from ...core.database import get_db
from ...core.security import get_password_hash
from ...models.user import User
router = APIRouter()
class InitialSetupRequest(BaseModel):
"""초기 설정 요청"""
admin_email: EmailStr
admin_password: str
admin_full_name: Optional[str] = None
class SetupStatusResponse(BaseModel):
"""설정 상태 응답"""
is_setup_required: bool
has_admin_user: bool
total_users: int
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status(db: AsyncSession = Depends(get_db)):
"""시스템 설정 상태 확인"""
# 전체 사용자 수 조회
total_users_result = await db.execute(select(func.count(User.id)))
total_users = total_users_result.scalar()
# 관리자 사용자 존재 여부 확인
admin_result = await db.execute(
select(User).where(User.role == "root")
)
has_admin_user = admin_result.scalar_one_or_none() is not None
return SetupStatusResponse(
is_setup_required=total_users == 0 or not has_admin_user,
has_admin_user=has_admin_user,
total_users=total_users
)
@router.post("/initialize")
async def initialize_system(
setup_data: InitialSetupRequest,
db: AsyncSession = Depends(get_db)
):
"""시스템 초기 설정 (root 계정 생성)"""
# 이미 설정된 시스템인지 확인
existing_admin = await db.execute(
select(User).where(User.role == "root")
)
if existing_admin.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="System is already initialized"
)
# 이메일 중복 확인
existing_user = await db.execute(
select(User).where(User.email == setup_data.admin_email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Root 관리자 계정 생성
hashed_password = get_password_hash(setup_data.admin_password)
admin_user = User(
email=setup_data.admin_email,
hashed_password=hashed_password,
full_name=setup_data.admin_full_name or "시스템 관리자",
is_active=True,
is_admin=True,
role="root",
can_manage_books=True,
can_manage_notes=True,
can_manage_novels=True
)
db.add(admin_user)
await db.commit()
await db.refresh(admin_user)
return {
"message": "System initialized successfully",
"admin_user": {
"id": str(admin_user.id),
"email": admin_user.email,
"full_name": admin_user.full_name,
"role": admin_user.role
}
}

View File

@@ -0,0 +1,663 @@
"""
할일관리 시스템 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.todo import TodoItem, TodoComment
from ...schemas.todo import (
TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit,
TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate,
TodoCommentResponse, TodoStats, TodoDashboard
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/todos", tags=["todos"])
# ============================================================================
# 할일 아이템 관리
# ============================================================================
@router.post("/", response_model=TodoItemResponse)
async def create_todo_item(
todo_data: TodoItemCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 할일 생성 (draft 상태)"""
try:
new_todo = TodoItem(
user_id=current_user.id,
content=todo_data.content,
status="draft"
)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
# 응답 데이터 구성
response_data = TodoItemResponse(
id=new_todo.id,
user_id=new_todo.user_id,
content=new_todo.content,
status=new_todo.status,
created_at=new_todo.created_at,
start_date=new_todo.start_date,
estimated_minutes=new_todo.estimated_minutes,
completed_at=new_todo.completed_at,
delayed_until=new_todo.delayed_until,
parent_id=new_todo.parent_id,
split_order=new_todo.split_order,
comment_count=0
)
return response_data
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo item: {str(e)}"
)
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
async def schedule_todo_item(
todo_id: UUID,
schedule_data: TodoItemSchedule,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 일정 설정 (draft -> scheduled)"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 2시간 이상인 경우 분할 제안
if schedule_data.estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tasks longer than 2 hours should be split into smaller tasks"
)
# 일정 설정
todo_item.start_date = schedule_data.start_date
todo_item.estimated_minutes = schedule_data.estimated_minutes
todo_item.status = "scheduled"
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in schedule_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to schedule todo item: {str(e)}"
)
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
async def split_todo_item(
todo_id: UUID,
split_data: TodoItemSplit,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 분할"""
try:
# 원본 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
original_todo = result.scalar_one_or_none()
if not original_todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 분할된 할일들 생성
subtasks = []
for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
if estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Subtask {i+1} is longer than 2 hours"
)
subtask = TodoItem(
user_id=current_user.id,
content=subtask_content,
status="draft",
parent_id=original_todo.id,
split_order=i + 1
)
db.add(subtask)
subtasks.append(subtask)
# 원본 할일 상태 변경 (분할됨 표시)
original_todo.status = "split"
await db.commit()
# 응답 데이터 구성
response_data = []
for subtask in subtasks:
await db.refresh(subtask)
response_data.append(TodoItemResponse(
id=subtask.id,
user_id=subtask.user_id,
content=subtask.content,
status=subtask.status,
created_at=subtask.created_at,
start_date=subtask.start_date,
estimated_minutes=subtask.estimated_minutes,
completed_at=subtask.completed_at,
delayed_until=subtask.delayed_until,
parent_id=subtask.parent_id,
split_order=subtask.split_order,
comment_count=0
))
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in split_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to split todo item: {str(e)}"
)
@router.get("/", response_model=List[TodoItemResponse])
async def get_todo_items(
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 목록 조회"""
try:
query = select(TodoItem).where(TodoItem.user_id == current_user.id)
if status:
query = query.where(TodoItem.status == status)
query = query.order_by(TodoItem.created_at.desc())
result = await db.execute(query)
todo_items = result.scalars().all()
# 각 할일의 댓글 수 계산
response_data = []
for todo_item in todo_items:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_todo_items: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo items: {str(e)}"
)
@router.get("/active", response_model=List[TodoItemResponse])
async def get_active_todos(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""오늘 활성화된 할일들 조회"""
try:
now = datetime.utcnow()
# scheduled 상태이면서 시작일이 지난 것들을 active로 변경
update_result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "scheduled",
TodoItem.start_date <= now
)
)
)
scheduled_items = update_result.scalars().all()
for item in scheduled_items:
item.status = "active"
if scheduled_items:
await db.commit()
# active 상태인 할일들 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
).order_by(TodoItem.start_date.asc())
)
active_todos = result.scalars().all()
# 응답 데이터 구성
response_data = []
for todo_item in active_todos:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_active_todos: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get active todos: {str(e)}"
)
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
async def complete_todo_item(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 완료"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "completed"
todo_item.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in complete_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete todo item: {str(e)}"
)
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
async def delay_todo_item(
todo_id: UUID,
delay_data: TodoItemDelay,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 지연"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "delayed"
todo_item.delayed_until = delay_data.delayed_until
todo_item.start_date = delay_data.delayed_until # 새로운 시작일로 업데이트
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delay_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delay todo item: {str(e)}"
)
# ============================================================================
# 댓글 관리
# ============================================================================
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
async def create_todo_comment(
todo_id: UUID,
comment_data: TodoCommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일에 댓글 추가"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
new_comment = TodoComment(
todo_item_id=todo_id,
user_id=current_user.id,
content=comment_data.content
)
db.add(new_comment)
await db.commit()
await db.refresh(new_comment)
return TodoCommentResponse(
id=new_comment.id,
todo_item_id=new_comment.todo_item_id,
user_id=new_comment.user_id,
content=new_comment.content,
created_at=new_comment.created_at,
updated_at=new_comment.updated_at
)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_comment: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo comment: {str(e)}"
)
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
async def get_todo_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 댓글 목록 조회"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 조회
result = await db.execute(
select(TodoComment).where(TodoComment.todo_item_id == todo_id)
.order_by(TodoComment.created_at.asc())
)
comments = result.scalars().all()
return [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in comments
]
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo comments: {str(e)}"
)
@router.get("/{todo_id}", response_model=TodoItemWithComments)
async def get_todo_item_with_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""댓글이 포함된 할일 상세 조회"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).options(selectinload(TodoItem.comments))
.where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 데이터 구성
comments = [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in todo_item.comments
]
return TodoItemWithComments(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=len(comments),
comments=comments
)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_item_with_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo item with comments: {str(e)}"
)

View File

@@ -1,92 +1,276 @@
""" """
사용자 관리 API 라우터 사용자 관리 API
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete from sqlalchemy import select, update, delete
from typing import List from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
from ...core.database import get_db from ...core.database import get_db
from ...core.security import get_password_hash, verify_password
from ...models.user import User from ...models.user import User
from ...schemas.auth import UserInfo
from ..dependencies import get_current_active_user, get_current_admin_user from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: str = None
theme: str = None
language: str = None
timezone: str = None
class UpdateUserRequest(BaseModel):
"""사용자 정보 업데이트 요청 (관리자용)"""
full_name: str = None
is_active: bool = None
is_admin: bool = None
router = APIRouter() router = APIRouter()
@router.get("/profile", response_model=UserInfo) class UserResponse(BaseModel):
async def get_profile( """사용자 응답"""
id: str
email: str
full_name: Optional[str]
is_active: bool
is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str
language: str
timezone: str
created_at: datetime
updated_at: Optional[datetime]
last_login: Optional[datetime]
class Config:
from_attributes = True
class CreateUserRequest(BaseModel):
"""사용자 생성 요청"""
email: EmailStr
password: str
full_name: Optional[str] = None
role: str = "user"
can_manage_books: bool = True
can_manage_notes: bool = True
can_manage_novels: bool = True
session_timeout_minutes: int = 5
class UpdateUserRequest(BaseModel):
"""사용자 업데이트 요청"""
full_name: Optional[str] = None
is_active: Optional[bool] = None
role: Optional[str] = None
can_manage_books: Optional[bool] = None
can_manage_notes: Optional[bool] = None
can_manage_novels: Optional[bool] = None
session_timeout_minutes: Optional[int] = None
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: Optional[str] = None
theme: Optional[str] = None
language: Optional[str] = None
timezone: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""비밀번호 변경 요청"""
current_password: str
new_password: str
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""현재 사용자 프로필 조회""" """현재 사용자 프로필 조회"""
return UserInfo.from_orm(current_user) return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.put("/profile", response_model=UserInfo) @router.put("/me", response_model=UserResponse)
async def update_profile( async def update_current_user_profile(
profile_data: UpdateProfileRequest, profile_data: UpdateProfileRequest,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""프로필 업데이트""" """현재 사용자 프로필 업데이트"""
update_data = {} update_fields = profile_data.model_dump(exclude_unset=True)
if profile_data.full_name is not None: for field, value in update_fields.items():
update_data["full_name"] = profile_data.full_name setattr(current_user, field, value)
if profile_data.theme is not None:
update_data["theme"] = profile_data.theme
if profile_data.language is not None:
update_data["language"] = profile_data.language
if profile_data.timezone is not None:
update_data["timezone"] = profile_data.timezone
if update_data: current_user.updated_at = datetime.utcnow()
await db.execute( await db.commit()
update(User) await db.refresh(current_user)
.where(User.id == current_user.id)
.values(**update_data) return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.post("/me/change-password")
async def change_current_user_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""현재 사용자 비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
) )
await db.commit()
await db.refresh(current_user)
return UserInfo.from_orm(current_user) # 새 비밀번호 설정
current_user.hashed_password = get_password_hash(password_data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
return {"message": "Password changed successfully"}
@router.get("/", response_model=List[UserInfo]) @router.get("/", response_model=List[UserResponse])
async def list_users( async def list_users(
admin_user: User = Depends(get_current_admin_user), skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""사용자 목록 조회 (관리자 전용)""" """사용자 목록 조회 (관리자 전용)"""
result = await db.execute(select(User).order_by(User.created_at.desc())) result = await db.execute(
select(User)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
)
users = result.scalars().all() users = result.scalars().all()
return [UserInfo.from_orm(user) for user in users]
return [
UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
for user in users
]
@router.get("/{user_id}", response_model=UserInfo) @router.post("/", response_model=UserResponse)
async def get_user( async def create_user(
user_id: str, user_data: CreateUserRequest,
admin_user: User = Depends(get_current_admin_user), current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""특정 사용자 조회 (관리자 전용)""" """사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
existing_user = await db.execute(
select(User).where(User.email == user_data.email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 권한 확인 (root만 admin/root 계정 생성 가능)
if user_data.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can create admin accounts"
)
# 사용자 생성
hashed_password = get_password_hash(user_data.password)
new_user = User(
email=user_data.email,
hashed_password=hashed_password,
full_name=user_data.full_name,
is_active=True,
is_admin=user_data.role in ["admin", "root"],
role=user_data.role,
can_manage_books=user_data.can_manage_books,
can_manage_notes=user_data.can_manage_notes,
can_manage_novels=user_data.can_manage_novels,
session_timeout_minutes=user_data.session_timeout_minutes
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserResponse(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_admin=new_user.is_admin,
role=new_user.role,
can_manage_books=new_user.can_manage_books,
can_manage_notes=new_user.can_manage_notes,
can_manage_novels=new_user.can_manage_novels,
session_timeout_minutes=new_user.session_timeout_minutes,
theme=new_user.theme,
language=new_user.language,
timezone=new_user.timezone,
created_at=new_user.created_at,
updated_at=new_user.updated_at,
last_login=new_user.last_login
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 상세 조회 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -96,18 +280,34 @@ async def get_user(
detail="User not found" detail="User not found"
) )
return UserInfo.from_orm(user) return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.put("/{user_id}", response_model=UserInfo) @router.put("/{user_id}", response_model=UserResponse)
async def update_user( async def update_user(
user_id: str, user_id: str,
user_data: UpdateUserRequest, user_data: UpdateUserRequest,
admin_user: User = Depends(get_current_admin_user), current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""사용자 정보 업데이트 (관리자 전용)""" """사용자 정보 업데이트 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -117,42 +317,55 @@ async def update_user(
detail="User not found" detail="User not found"
) )
# 자기 자신의 관리자 권한은 제거할 수 없음 # 권한 확인 (root만 admin/root 계정 수정 가능)
if user.id == admin_user.id and user_data.is_admin is False: if user.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot remove admin privileges from yourself" detail="Only root users can modify admin accounts"
) )
# 업데이트할 데이터 준비 # 업데이트할 필드들 적용
update_data = {} update_fields = user_data.model_dump(exclude_unset=True)
if user_data.full_name is not None:
update_data["full_name"] = user_data.full_name
if user_data.is_active is not None:
update_data["is_active"] = user_data.is_active
if user_data.is_admin is not None:
update_data["is_admin"] = user_data.is_admin
if update_data: for field, value in update_fields.items():
await db.execute( if field == "role":
update(User) # 역할 변경 시 is_admin도 함께 업데이트
.where(User.id == user_id) setattr(user, field, value)
.values(**update_data) user.is_admin = value in ["admin", "root"]
) else:
await db.commit() setattr(user, field, value)
await db.refresh(user)
return UserInfo.from_orm(user) user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.delete("/{user_id}") @router.delete("/{user_id}")
async def delete_user( async def delete_user(
user_id: str, user_id: str,
admin_user: User = Depends(get_current_admin_user), current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""사용자 삭제 (관리자 전용)""" """사용자 삭제 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -162,14 +375,27 @@ async def delete_user(
detail="User not found" detail="User not found"
) )
# 자기 자신 삭제할 수 없음 # 자기 자신 삭제 방지
if user.id == admin_user.id: if user.id == current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete yourself" detail="Cannot delete your own account"
)
# root 계정 삭제 방지
if user.role == "root":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete root account"
)
# 권한 확인 (root만 admin 계정 삭제 가능)
if user.role == "admin" and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can delete admin accounts"
) )
# 사용자 삭제
await db.execute(delete(User).where(User.id == user_id)) await db.execute(delete(User).where(User.id == user_id))
await db.commit() await db.commit()

View File

@@ -28,6 +28,7 @@ class Settings(BaseSettings):
# CORS 설정 # CORS 설정
ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"] ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
ALLOWED_ORIGINS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
# 파일 업로드 설정 # 파일 업로드 설정
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"

View File

@@ -24,11 +24,18 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str:
"""액세스 토큰 생성""" """액세스 토큰 생성"""
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
elif timeout_minutes is not None:
if timeout_minutes == 0:
# 무제한 토큰 (1년으로 설정)
expire = datetime.utcnow() + timedelta(days=365)
else:
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
else: else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)

View File

@@ -9,8 +9,8 @@ import uvicorn
from .core.config import settings from .core.config import settings
from .core.database import init_db from .core.database import init_db
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 import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
from .api.routes.notes import router as note_documents_router from .api.routes import note_documents, note_links
@asynccontextmanager @asynccontextmanager
@@ -30,12 +30,12 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# CORS 설정 (개발용 - 더 관대한 설정) # CORS 설정
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 개발용으로 모든 오리진 허용 allow_origins=settings.ALLOWED_ORIGINS if hasattr(settings, 'ALLOWED_ORIGINS') else ["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -43,21 +43,26 @@ app.add_middleware(
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# API 라우터 등록 # API 라우터 등록
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
app.include_router(auth.router, prefix="/api/auth", tags=["인증"]) app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
app.include_router(users.router, prefix="/api/users", tags=["사용자"]) app.include_router(users.router, prefix="/api/users", tags=["사용자"])
app.include_router(documents.router, prefix="/api/documents", tags=["문서"]) app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"]) app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"])
app.include_router(notes.router, prefix="/api/notes", tags=["메모"]) app.include_router(notes.router, prefix="/api/highlight-notes", tags=["하이라이트 메모"])
app.include_router(books.router, prefix="/api/books", tags=["서적"]) app.include_router(books.router, prefix="/api/books", tags=["서적"])
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"]) app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]) app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"]) app.include_router(search.router, prefix="/api/search", tags=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"]) app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"]) app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
app.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"]) # 링크 삭제를 위한 추가 라우터 (document-links 경로 지원)
app.include_router(document_links.router, prefix="/api", tags=["문서 링크 (호환성)"])
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"]) app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"]) app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"]) app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
app.include_router(todos.router, prefix="/api", tags=["할일관리"])
@app.get("/") @app.get("/")

View File

@@ -12,6 +12,8 @@ from .note_document import NoteDocument
from .notebook import Notebook from .notebook import Notebook
from .note_highlight import NoteHighlight from .note_highlight import NoteHighlight
from .note_note import NoteNote from .note_note import NoteNote
from .note_link import NoteLink
from .memo_tree import MemoTree, MemoNode, MemoTreeShare
__all__ = [ __all__ = [
"User", "User",
@@ -25,5 +27,9 @@ __all__ = [
"NoteDocument", "NoteDocument",
"Notebook", "Notebook",
"NoteHighlight", "NoteHighlight",
"NoteNote" "NoteNote",
"NoteLink",
"MemoTree",
"MemoNode",
"MemoTreeShare"
] ]

View File

@@ -19,8 +19,8 @@ class DocumentLink(Base):
# 링크가 생성된 문서 (출발점) # 링크가 생성된 문서 (출발점)
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True) source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
# 링크 대상 문서 (도착점) # 링크 대상 문서 또는 노트 (도착점) - 외래키 제약 조건 제거하여 노트 ID도 허용
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True) target_document_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 출발점 텍스트 정보 (기존) # 출발점 텍스트 정보 (기존)
selected_text = Column(Text, nullable=False) # 선택된 텍스트 selected_text = Column(Text, nullable=False) # 선택된 텍스트
@@ -44,9 +44,9 @@ class DocumentLink(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계 # 관계 - target_document는 외래키 제약 조건이 없으므로 relationship 제거
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links") source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links")
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_links") # target_document relationship 제거 (노트 ID도 포함할 수 있으므로)
creator = relationship("User", backref="created_links") creator = relationship("User", backref="created_links")
def __repr__(self): def __repr__(self):

View File

@@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel):
tags: List[str] = Field(default=[]) tags: List[str] = Field(default=[])
is_published: bool = Field(default=False) is_published: bool = Field(default=False)
parent_note_id: Optional[str] = None parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: int = Field(default=0) sort_order: int = Field(default=0)
class NoteDocumentCreate(NoteDocumentBase): class NoteDocumentCreate(NoteDocumentBase):
@@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel):
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
is_published: Optional[bool] = None is_published: Optional[bool] = None
parent_note_id: Optional[str] = None parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
class NoteDocumentResponse(NoteDocumentBase): class NoteDocumentResponse(NoteDocumentBase):
@@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase):
'tags': obj.tags or [], 'tags': obj.tags or [],
'is_published': obj.is_published, 'is_published': obj.is_published,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'notebook_id': str(obj.notebook_id) if obj.notebook_id else None,
'sort_order': obj.sort_order, 'sort_order': obj.sort_order,
'markdown_content': obj.markdown_content, 'markdown_content': obj.markdown_content,
'created_at': obj.created_at, 'created_at': obj.created_at,

View File

@@ -0,0 +1,58 @@
"""
노트 문서 링크 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class NoteLink(Base):
"""노트 문서 링크 테이블"""
__tablename__ = "note_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 링크가 생성된 노트 (출발점) - 노트 문서 또는 일반 문서 가능
source_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
# 링크 대상 노트 (도착점) - 노트 문서 또는 일반 문서 가능
target_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
# 출발점 텍스트 정보
selected_text = Column(Text, nullable=False) # 선택된 텍스트
start_offset = Column(Integer, nullable=False) # 시작 위치
end_offset = Column(Integer, nullable=False) # 끝 위치
# 도착점 텍스트 정보
target_text = Column(Text, nullable=True) # 대상에서 선택된 텍스트
target_start_offset = Column(Integer, nullable=True) # 대상에서 시작 위치
target_end_offset = Column(Integer, nullable=True) # 대상에서 끝 위치
# 링크 메타데이터
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트
description = Column(Text, nullable=True) # 링크 설명
# 링크 타입
link_type = Column(String(20), default="note", nullable=False) # "note", "document", "text_fragment"
# 생성자 정보
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계 설정
source_note = relationship("NoteDocument", foreign_keys=[source_note_id], backref="outgoing_note_links")
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_note_links")
target_note = relationship("NoteDocument", foreign_keys=[target_note_id], backref="incoming_note_links")
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_note_links")
creator = relationship("User", backref="created_note_links")
def __repr__(self):
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"

View File

@@ -0,0 +1,63 @@
"""
할일관리 시스템 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class TodoItem(Base):
"""할일 아이템"""
__tablename__ = "todo_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# 기본 정보
content = Column(Text, nullable=False) # 할일 내용
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
# 시간 관리
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
completed_at = Column(DateTime(timezone=True), nullable=True)
delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 경우 새로운 시작일
# 분할 관리
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
split_order = Column(Integer, nullable=True) # 분할 순서
# 관계
user = relationship("User", back_populates="todo_items")
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
# 자기 참조 관계 (분할된 할일들)
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
def __repr__(self):
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
class TodoComment(Base):
"""할일 댓글/메모"""
__tablename__ = "todo_comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
todo_item = relationship("TodoItem", back_populates="comments")
user = relationship("User")
def __repr__(self):
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -1,7 +1,7 @@
""" """
사용자 모델 사용자 모델
""" """
from sqlalchemy import Column, String, Boolean, DateTime, Text from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -21,6 +21,17 @@ class User(Base):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
# 권한 시스템 (서적관리, 노트관리, 소설관리)
can_manage_books = Column(Boolean, default=True) # 서적 관리 권한
can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한
can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한
# 사용자 역할 (root, admin, user)
role = Column(String(20), default="user") # root, admin, user
# 세션 타임아웃 설정 (분 단위, 0 = 무제한)
session_timeout_minutes = Column(Integer, default=5) # 기본 5분
# 메타데이터 # 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -34,6 +45,7 @@ class User(Base):
# 관계 (lazy loading을 위해 문자열로 참조) # 관계 (lazy loading을 위해 문자열로 참조)
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic") memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic") memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
def __repr__(self): def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>" return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -33,10 +33,16 @@ class UserInfo(BaseModel):
full_name: Optional[str] = None full_name: Optional[str] = None
is_active: bool is_active: bool
is_admin: bool is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str theme: str
language: str language: str
timezone: str timezone: str
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None
last_login: Optional[datetime] = None last_login: Optional[datetime] = None
class Config: class Config:

108
backend/src/schemas/todo.py Normal file
View File

@@ -0,0 +1,108 @@
"""
할일관리 시스템 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
class TodoCommentBase(BaseModel):
content: str = Field(..., min_length=1, max_length=1000)
class TodoCommentCreate(TodoCommentBase):
pass
class TodoCommentUpdate(BaseModel):
content: Optional[str] = Field(None, min_length=1, max_length=1000)
class TodoCommentResponse(TodoCommentBase):
id: UUID
todo_item_id: UUID
user_id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TodoItemBase(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TodoItemCreate(TodoItemBase):
"""초기 할일 생성 (draft 상태)"""
pass
class TodoItemSchedule(BaseModel):
"""할일 일정 설정"""
start_date: datetime
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
class TodoItemUpdate(BaseModel):
"""할일 수정"""
content: Optional[str] = Field(None, min_length=1, max_length=2000)
status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$")
start_date: Optional[datetime] = None
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
delayed_until: Optional[datetime] = None
class TodoItemDelay(BaseModel):
"""할일 지연"""
delayed_until: datetime
class TodoItemSplit(BaseModel):
"""할일 분할"""
subtasks: List[str] = Field(..., min_items=2, max_items=10)
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
class TodoItemResponse(TodoItemBase):
id: UUID
user_id: UUID
status: str
created_at: datetime
start_date: Optional[datetime]
estimated_minutes: Optional[int]
completed_at: Optional[datetime]
delayed_until: Optional[datetime]
parent_id: Optional[UUID]
split_order: Optional[int]
# 댓글 수
comment_count: int = 0
class Config:
from_attributes = True
class TodoItemWithComments(TodoItemResponse):
"""댓글이 포함된 할일 응답"""
comments: List[TodoCommentResponse] = []
class TodoStats(BaseModel):
"""할일 통계"""
total_count: int
draft_count: int
scheduled_count: int
active_count: int
completed_count: int
delayed_count: int
completion_rate: float # 완료율 (%)
class TodoDashboard(BaseModel):
"""할일 대시보드"""
stats: TodoStats
today_todos: List[TodoItemResponse]
overdue_todos: List[TodoItemResponse]
upcoming_todos: List[TodoItemResponse]

View File

@@ -1,504 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>압력용기 설계 매뉴얼 - Pressure Vessel Design Manual</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.language-toggle {
position: fixed;
top: 20px;
right: 20px;
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.language-toggle:hover {
background: #0056b3;
}
.content {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
text-align: center;
margin-bottom: 40px;
font-size: 2.5em;
}
h2 {
color: #34495e;
margin-top: 30px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 10px;
}
h3 {
color: #7f8c8d;
margin-top: 20px;
margin-bottom: 15px;
}
.publisher-info {
text-align: center;
margin: 30px 0;
font-style: italic;
}
.toc {
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.toc-item {
margin: 5px 0;
padding: 5px 0;
}
.procedure {
margin-left: 30px;
font-size: 0.9em;
color: #666;
}
.chapter {
font-weight: bold;
margin-top: 15px;
margin-bottom: 10px;
}
.page-number {
float: right;
color: #999;
}
.copyright {
background: #e9ecef;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
font-size: 0.9em;
}
.preface {
text-align: justify;
line-height: 1.8;
}
/* 한국어 스타일 */
[lang="ko"] {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
</head>
<body>
<button class="language-toggle" onclick="toggleLanguage()">🌐 한국어/English</button>
<div class="content">
<!-- 영어 버전 -->
<div id="english-content">
<h1>Pressure Vessel Design Manual</h1>
<h2>Fourth Edition</h2>
<div class="publisher-info">
<p><strong>Dennis R. Moss<br>Michael Basic</strong></p>
<p>AMSTERDAM • BOSTON • HEIDELBERG • LONDON • NEW YORK • OXFORD<br>
PARIS • SAN DIEGO • SAN FRANCISCO • SINGAPORE • SYDNEY • TOKYO</p>
<p>Butterworth-Heinemann is an imprint of Elsevier</p>
</div>
<div class="copyright">
<h3>Copyright Information</h3>
<p>Fourth edition 2013<br>
Copyright © 2013 Elsevier Inc. All rights reserved</p>
<p>No part of this publication may be reproduced, stored in a retrieval system or transmitted in any form or by any means electronic, mechanical, photocopying, recording or otherwise without the prior written permission of the publisher</p>
<p>ISBN: 978-0-12-387000-1</p>
</div>
<h2>Contents</h2>
<div class="toc">
<div class="chapter">Preface to the 4th Edition <span class="page-number">ix</span></div>
<div class="chapter">1: General Topics <span class="page-number">1</span></div>
<div class="procedure">Design Philosophy <span class="page-number">1</span></div>
<div class="procedure">Stress Analysis <span class="page-number">2</span></div>
<div class="procedure">Stress/Failure Theories <span class="page-number">3</span></div>
<div class="procedure">Failures in Pressure Vessels <span class="page-number">7</span></div>
<div class="procedure">Loadings <span class="page-number">8</span></div>
<div class="procedure">Stress <span class="page-number">10</span></div>
<div class="procedure">Thermal Stresses <span class="page-number">13</span></div>
<div class="procedure">Discontinuity Stresses <span class="page-number">14</span></div>
<div class="procedure">Fatigue Analysis for Cyclic Service <span class="page-number">15</span></div>
<div class="procedure">Creep <span class="page-number">24</span></div>
<div class="procedure">Cryogenic Applications <span class="page-number">32</span></div>
<div class="procedure">Service Considerations <span class="page-number">34</span></div>
<div class="procedure">Miscellaneous Design Considerations <span class="page-number">35</span></div>
<div class="procedure">Items to be Included in a User's Design Specification (UDS) for ASME VIII-2 Vessels <span class="page-number">35</span></div>
<div class="procedure">References <span class="page-number">36</span></div>
<div class="chapter">2: General Design <span class="page-number">37</span></div>
<div class="procedure">Procedure 2-1: General Vessel Formulas <span class="page-number">38</span></div>
<div class="procedure">Procedure 2-2: External Pressure Design <span class="page-number">42</span></div>
<div class="procedure">Procedure 2-3: Properties of Stiffening Rings <span class="page-number">51</span></div>
<div class="procedure">Procedure 2-4: Code Case 2286 <span class="page-number">54</span></div>
<div class="procedure">Procedure 2-5: Design of Cones <span class="page-number">58</span></div>
<div class="procedure">Procedure 2-6: Design of Toriconical Transitions <span class="page-number">67</span></div>
<div class="procedure">Procedure 2-7: Stresses in Heads Due to Internal Pressure <span class="page-number">70</span></div>
<div class="procedure">Procedure 2-8: Design of Intermediate Heads <span class="page-number">74</span></div>
<div class="procedure">Procedure 2-9: Design of Flat Heads <span class="page-number">76</span></div>
<div class="procedure">Procedure 2-10: Design of Large Openings in Flat Heads <span class="page-number">81</span></div>
<div class="procedure">Procedure 2-11: Calculate MAP, MAWP, and Test Pressures <span class="page-number">83</span></div>
<div class="procedure">Procedure 2-12: Nozzle Reinforcement <span class="page-number">85</span></div>
<div class="procedure">Procedure 2-13: Find or Revise the Center of Gravity of a Vessel <span class="page-number">90</span></div>
<div class="procedure">Procedure 2-14: Minimum Design Metal Temperature (MDMT) <span class="page-number">90</span></div>
<div class="procedure">Procedure 2-15: Buckling of Thin Wall Cylindrical Shells <span class="page-number">95</span></div>
<div class="procedure">Procedure 2-16: Optimum Vessel Proportions <span class="page-number">96</span></div>
<div class="procedure">Procedure 2-17: Estimating Weights of Vessels and Vessel Components <span class="page-number">102</span></div>
<div class="procedure">Procedure 2-18: Design of Jacketed Vessels <span class="page-number">124</span></div>
<div class="procedure">Procedure 2-19: Forming Strains/Fiber Elongation <span class="page-number">134</span></div>
<div class="procedure">References <span class="page-number">138</span></div>
<div class="chapter">3: Flange Design <span class="page-number">139</span></div>
<div class="procedure">Introduction <span class="page-number">140</span></div>
<div class="procedure">Procedure 3-1: Design of Flanges <span class="page-number">148</span></div>
<div class="procedure">Procedure 3-2: Design of Spherically Dished Covers <span class="page-number">165</span></div>
<div class="procedure">Procedure 3-3: Design of Blind Flanges with Openings <span class="page-number">167</span></div>
<div class="procedure">Procedure 3-4: Bolt Torque Required for Sealing Flanges <span class="page-number">169</span></div>
<div class="procedure">Procedure 3-5: Design of Studding Outlets <span class="page-number">172</span></div>
<div class="procedure">Procedure 3-6: Reinforcement for Studding Outlets <span class="page-number">175</span></div>
<div class="procedure">Procedure 3-7: Studding Flanges <span class="page-number">176</span></div>
<div class="procedure">Procedure 3-8: Design of Elliptical, Internal Manways <span class="page-number">181</span></div>
<div class="procedure">Procedure 3-9: Through Nozzles <span class="page-number">182</span></div>
<div class="procedure">References <span class="page-number">183</span></div>
<div class="chapter">4: Design of Vessel Supports <span class="page-number">185</span></div>
<div class="procedure">Introduction: Support Structures <span class="page-number">186</span></div>
<div class="procedure">Procedure 4-1: Wind Design Per ASCE <span class="page-number">189</span></div>
<div class="procedure">Procedure 4-2: Seismic Design - General <span class="page-number">199</span></div>
<div class="procedure">Procedure 4-3: Seismic Design for Vessels <span class="page-number">204</span></div>
<div class="procedure">Procedure 4-4: Seismic Design - Vessel on Unbraced Legs <span class="page-number">208</span></div>
<div class="procedure">Procedure 4-5: Seismic Design - Vessel on Braced Legs <span class="page-number">217</span></div>
<div class="procedure">Procedure 4-6: Seismic Design - Vessel on Rings <span class="page-number">223</span></div>
<div class="procedure">Procedure 4-7: Seismic Design - Vessel on Lugs <span class="page-number">229</span></div>
<div class="procedure">Procedure 4-8: Seismic Design - Vessel on Skirt <span class="page-number">239</span></div>
<div class="procedure">Procedure 4-9: Seismic Design - Vessel on Conical Skirt <span class="page-number">248</span></div>
<div class="procedure">Procedure 4-10: Design of Horizontal Vessel on Saddles <span class="page-number">253</span></div>
<div class="procedure">Procedure 4-11: Design of Saddle Supports for Large Vessels <span class="page-number">267</span></div>
<div class="procedure">Procedure 4-12: Design of Base Plates for Legs <span class="page-number">275</span></div>
<div class="procedure">Procedure 4-13: Design of Lug Supports <span class="page-number">278</span></div>
<div class="procedure">Procedure 4-14: Design of Base Details for Vertical Vessels-Shifted Neutral Axis Method <span class="page-number">281</span></div>
<div class="procedure">Procedure 4-15: Design of Base Details for Vertical Vessels - Centered Neutral Axis Method <span class="page-number">291</span></div>
<div class="procedure">Procedure 4-16: Design of Anchor Bolts for Vertical Vessels <span class="page-number">293</span></div>
<div class="procedure">Procedure 4-17: Properties of Concrete <span class="page-number">295</span></div>
<div class="procedure">References <span class="page-number">296</span></div>
<div class="chapter">5: Vessel Internals <span class="page-number">297</span></div>
<div class="procedure">Procedure 5-1: Design of Internal Support Beds <span class="page-number">298</span></div>
<div class="procedure">Procedure 5-2: Design of Lattice Beams <span class="page-number">310</span></div>
<div class="procedure">Procedure 5-3: Shell Stresses due to Loadings at Support Beam Locations <span class="page-number">316</span></div>
<div class="procedure">Procedure 5-4: Design of Support Blocks <span class="page-number">319</span></div>
<div class="procedure">Procedure 5-5: Hub Rings used for Bed Supports <span class="page-number">321</span></div>
<div class="procedure">Procedure 5-6: Design of Pipe Coils for Heat Transfer <span class="page-number">326</span></div>
<div class="procedure">Procedure 5-7: Agitators/Mixers for Vessels and Tanks <span class="page-number">345</span></div>
<div class="procedure">Procedure 5-8: Design of Internal Pipe Distributors <span class="page-number">353</span></div>
<div class="procedure">Procedure 5-9: Design of Trays <span class="page-number">366</span></div>
<div class="procedure">Procedure 5-10: Flow Over Weirs <span class="page-number">375</span></div>
<div class="procedure">Procedure 5-11: Design of Demisters <span class="page-number">376</span></div>
<div class="procedure">Procedure 5-12: Design of Baffles <span class="page-number">381</span></div>
<div class="procedure">Procedure 5-13: Design of Impingement Plates <span class="page-number">391</span></div>
<div class="procedure">References <span class="page-number">392</span></div>
<div class="chapter">6: Special Designs <span class="page-number">393</span></div>
<div class="procedure">Procedure 6-1: Design of Large-Diameter Nozzle Openings <span class="page-number">394</span></div>
<div class="procedure">Large Openings—Membrane and Bending Analysis <span class="page-number">397</span></div>
<div class="procedure">Procedure 6-2: Tower Deflection <span class="page-number">397</span></div>
<div class="procedure">Procedure 6-3: Design of Ring Girders <span class="page-number">401</span></div>
<div class="procedure">Procedure 6-4: Design of Vessels with Refractory Linings <span class="page-number">406</span></div>
<div class="procedure">Procedure 6-5: Vibration of Tall Towers and Stacks <span class="page-number">418</span></div>
<div class="procedure">Procedure 6-6: Underground Tanks & Vessels <span class="page-number">428</span></div>
<div class="procedure">Procedure 6-7: Local Thin Area (LTA) <span class="page-number">432</span></div>
<div class="procedure">References <span class="page-number">433</span></div>
<div class="chapter">7: Local Loads <span class="page-number">435</span></div>
<div class="procedure">Procedure 7-1: Stresses in Circular Rings <span class="page-number">437</span></div>
<div class="procedure">Procedure 7-2: Design of Partial Ring Stiffeners <span class="page-number">446</span></div>
<div class="procedure">Procedure 7-3: Attachment Parameters <span class="page-number">448</span></div>
<div class="procedure">Procedure 7-4: Stresses in Cylindrical Shells from External Local Loads <span class="page-number">449</span></div>
<div class="procedure">Procedure 7-5: Stresses in Spherical Shells from External Local Loads <span class="page-number">465</span></div>
<div class="procedure">References <span class="page-number">472</span></div>
<div class="chapter">8: High Pressure Vessels <span class="page-number">473</span></div>
<div class="procedure">1.0. General <span class="page-number">474</span></div>
<div class="procedure">2.0. Shell Design <span class="page-number">496</span></div>
<div class="procedure">3.0. Design of Closures <span class="page-number">502</span></div>
<div class="procedure">4.0. Nozzles <span class="page-number">551</span></div>
<div class="procedure">5.0. References <span class="page-number">556</span></div>
<div class="chapter">9: Related Equipment <span class="page-number">557</span></div>
<div class="procedure">Procedure 9-1: Design of Davits <span class="page-number">558</span></div>
<div class="procedure">Procedure 9-2: Design of Circular Platforms <span class="page-number">563</span></div>
<div class="procedure">Procedure 9-3: Design of Square and Rectangular Platforms <span class="page-number">571</span></div>
<div class="procedure">Procedure 9-4: Design of Pipe Supports <span class="page-number">576</span></div>
<div class="procedure">Procedure 9-5: Shear Loads in Bolted Connections <span class="page-number">584</span></div>
<div class="procedure">Procedure 9-6: Design of Bins and Elevated Tanks <span class="page-number">586</span></div>
<div class="procedure">Procedure 9-7: Field-Fabricated Spheres <span class="page-number">594</span></div>
<div class="procedure">References <span class="page-number">630</span></div>
<div class="chapter">10: Transportation and Erection of Pressure Vessels <span class="page-number">631</span></div>
<div class="procedure">Procedure 10-1: Transportation of Pressure Vessels <span class="page-number">632</span></div>
<div class="procedure">Procedure 10-2: Erection of Pressure Vessels <span class="page-number">660</span></div>
<div class="procedure">Procedure 10-3: Lifting Attachments and Terminology <span class="page-number">666</span></div>
<div class="procedure">Procedure 10-4: Lifting Loads and Forces <span class="page-number">675</span></div>
<div class="procedure">Procedure 10-5: Design of Tail Beams, Lugs, and Base Ring Details <span class="page-number">681</span></div>
<div class="procedure">Procedure 10-6: Design of Top Head and Cone Lifting Lugs <span class="page-number">691</span></div>
<div class="procedure">Procedure 10-7: Design of Flange Lugs <span class="page-number">695</span></div>
<div class="procedure">Procedure 10-8: Design of Trunnions <span class="page-number">706</span></div>
<div class="procedure">Procedure 10-9: Local Loads in Shell Due to Erection Forces <span class="page-number">710</span></div>
<div class="procedure">Procedure 10-10: Miscellaneous <span class="page-number">713</span></div>
<div class="chapter">11: Materials <span class="page-number">719</span></div>
<div class="procedure">11.1. Types of Materials <span class="page-number">720</span></div>
<div class="procedure">11.2. Properties of Materials <span class="page-number">723</span></div>
<div class="procedure">11.3. Bolting <span class="page-number">728</span></div>
<div class="procedure">11.4. Testing & Examination <span class="page-number">732</span></div>
<div class="procedure">11.5. Heat Treatment <span class="page-number">738</span></div>
<div class="chapter">Appendices <span class="page-number">743</span></div>
<div class="chapter">Index <span class="page-number">803</span></div>
</div>
<h2>Preface to the 4th Edition</h2>
<div class="preface">
<p>When I started the Pressure Vessel Design Manual 35 years ago, I had no idea where it would lead. The first edition alone took 10 years to publish. It began when I first started working for a small vessel shop in Los Angeles in 1972. I could not believe how little information was available to engineers and designers in our industry at that time. I began collecting and researching everything I could get my hands on. As I collected more and more, I began writing procedures around various topics. After a while I had a pretty substantial collection and someone suggested that it might make a good book.</p>
<p>However I was constantly revising them and didn't think any of them were complete enough to publish. After a while I began trying to perfect them so that they could be published. This is the point at which the effort changed from a hobby to a vocation. My goal was to provide as complete a collection of equations, data and procedures for the design of pressure vessels that I could assemble. I never thought of myself as an author in this regard... but only the editor. I was not developing equations or methods, but only collecting and collating them. The presentation of the materials was then, and still is, the focus of my efforts. As stated all along "The author makes no claim to originality, other than that of format."</p>
<p>My target audience was always the person in the shop who was ultimately responsible for the designs they manufactured. I have seen all my goals for the PVDM exceeded in every way possible. Through my work with Fluor, I have had the opportunity to travel to 40 countries and have visited 60 vessel shops. In the past 10 years, I have not visited a shop that was not using the PVDM. This has been my reward. This book is now, and always has been, dedicated to the end user. Thank you.</p>
<p>The PVDM is a "designers" manual foremost, and not an engineering textbook. The procedures are streamlined to provide a weight, size or thickness. For the most part, wherever possible, it avoids the derivation of equations or the theoretical background. I have always sought out the simplest and most direct solutions.</p>
<p>If I have an interest in seeing this book continuing, then it must be done under the direction of a new, younger and very talented person.</p>
<p>Finally, I would like to offer my warmest, heartfelt thanks to all of you that have made comments, contributions, sent me literature, or encouraged me over the past 35 years. It is immensely rewarding to have watched the book evolve over the years. This book would not have been possible without you!</p>
<p style="text-align: right;">Dennis R. Moss</p>
</div>
</div>
<!-- 한국어 버전 -->
<div id="korean-content" style="display: none;">
<h1>압력용기 설계 매뉴얼</h1>
<h2>제4판</h2>
<div class="publisher-info">
<p><strong>Dennis R. Moss<br>Michael Basic</strong></p>
<p>암스테르담 • 보스턴 • 하이델베르크 • 런던 • 뉴욕 • 옥스포드<br>
파리 • 샌디에이고 • 샌프란시스코 • 싱가포르 • 시드니 • 도쿄</p>
<p>Butterworth-Heinemann은 Elsevier의 임프린트입니다</p>
</div>
<div class="copyright">
<h3>저작권 정보</h3>
<p>제4판 2013<br>
Copyright © 2013 Elsevier Inc. 모든 권리 보유</p>
<p>이 출판물의 어떤 부분도 출판사의 사전 서면 허가 없이 전자적, 기계적, 복사, 녹음 또는 기타 어떤 형태나 수단으로도 복제, 저장 또는 전송될 수 없습니다.</p>
<p>ISBN: 978-0-12-387000-1</p>
</div>
<h2>목차</h2>
<div class="toc">
<div class="chapter">제4판 서문 <span class="page-number">ix</span></div>
<div class="chapter">1: 일반 주제 <span class="page-number">1</span></div>
<div class="procedure">설계 철학 <span class="page-number">1</span></div>
<div class="procedure">응력 분석 <span class="page-number">2</span></div>
<div class="procedure">응력/파손 이론 <span class="page-number">3</span></div>
<div class="procedure">압력용기의 파손 <span class="page-number">7</span></div>
<div class="procedure">하중 <span class="page-number">8</span></div>
<div class="procedure">응력 <span class="page-number">10</span></div>
<div class="procedure">열응력 <span class="page-number">13</span></div>
<div class="procedure">불연속 응력 <span class="page-number">14</span></div>
<div class="procedure">주기적 서비스를 위한 피로 분석 <span class="page-number">15</span></div>
<div class="procedure">크리프 <span class="page-number">24</span></div>
<div class="procedure">극저온 응용 <span class="page-number">32</span></div>
<div class="procedure">서비스 고려사항 <span class="page-number">34</span></div>
<div class="procedure">기타 설계 고려사항 <span class="page-number">35</span></div>
<div class="procedure">ASME VIII-2 용기에 대한 사용자 설계 사양(UDS)에 포함될 항목 <span class="page-number">35</span></div>
<div class="procedure">참고문헌 <span class="page-number">36</span></div>
<div class="chapter">2: 일반 설계 <span class="page-number">37</span></div>
<div class="procedure">절차 2-1: 일반 용기 공식 <span class="page-number">38</span></div>
<div class="procedure">절차 2-2: 외압 설계 <span class="page-number">42</span></div>
<div class="procedure">절차 2-3: 보강링의 특성 <span class="page-number">51</span></div>
<div class="procedure">절차 2-4: 코드 케이스 2286 <span class="page-number">54</span></div>
<div class="procedure">절차 2-5: 원뿔 설계 <span class="page-number">58</span></div>
<div class="procedure">절차 2-6: 토리코니컬 변환부 설계 <span class="page-number">67</span></div>
<div class="procedure">절차 2-7: 내압으로 인한 헤드의 응력 <span class="page-number">70</span></div>
<div class="procedure">절차 2-8: 중간 헤드 설계 <span class="page-number">74</span></div>
<div class="procedure">절차 2-9: 평판 헤드 설계 <span class="page-number">76</span></div>
<div class="procedure">절차 2-10: 평판 헤드의 대형 개구부 설계 <span class="page-number">81</span></div>
<div class="procedure">절차 2-11: MAP, MAWP 및 시험 압력 계산 <span class="page-number">83</span></div>
<div class="procedure">절차 2-12: 노즐 보강 <span class="page-number">85</span></div>
<div class="procedure">절차 2-13: 용기의 무게중심 찾기 또는 수정 <span class="page-number">90</span></div>
<div class="procedure">절차 2-14: 최소 설계 금속 온도 (MDMT) <span class="page-number">90</span></div>
<div class="procedure">절차 2-15: 얇은 벽 원통형 쉘의 좌굴 <span class="page-number">95</span></div>
<div class="procedure">절차 2-16: 최적 용기 비율 <span class="page-number">96</span></div>
<div class="procedure">절차 2-17: 용기 및 용기 구성요소의 중량 추정 <span class="page-number">102</span></div>
<div class="procedure">절차 2-18: 재킷 용기 설계 <span class="page-number">124</span></div>
<div class="procedure">절차 2-19: 성형 변형률/섬유 신장 <span class="page-number">134</span></div>
<div class="procedure">참고문헌 <span class="page-number">138</span></div>
<div class="chapter">3: 플랜지 설계 <span class="page-number">139</span></div>
<div class="procedure">소개 <span class="page-number">140</span></div>
<div class="procedure">절차 3-1: 플랜지 설계 <span class="page-number">148</span></div>
<div class="procedure">절차 3-2: 구형 디시 커버 설계 <span class="page-number">165</span></div>
<div class="procedure">절차 3-3: 개구부가 있는 블라인드 플랜지 설계 <span class="page-number">167</span></div>
<div class="procedure">절차 3-4: 플랜지 밀봉에 필요한 볼트 토크 <span class="page-number">169</span></div>
<div class="procedure">절차 3-5: 스터딩 아웃렛 설계 <span class="page-number">172</span></div>
<div class="procedure">절차 3-6: 스터딩 아웃렛 보강 <span class="page-number">175</span></div>
<div class="procedure">절차 3-7: 스터딩 플랜지 <span class="page-number">176</span></div>
<div class="procedure">절차 3-8: 타원형 내부 맨웨이 설계 <span class="page-number">181</span></div>
<div class="procedure">절차 3-9: 관통 노즐 <span class="page-number">182</span></div>
<div class="procedure">참고문헌 <span class="page-number">183</span></div>
<div class="chapter">4: 용기 지지대 설계 <span class="page-number">185</span></div>
<div class="procedure">소개: 지지 구조물 <span class="page-number">186</span></div>
<div class="procedure">절차 4-1: ASCE에 따른 풍하중 설계 <span class="page-number">189</span></div>
<div class="procedure">절차 4-2: 내진 설계 - 일반 <span class="page-number">199</span></div>
<div class="procedure">절차 4-3: 용기의 내진 설계 <span class="page-number">204</span></div>
<div class="procedure">절차 4-4: 내진 설계 - 비보강 다리 위의 용기 <span class="page-number">208</span></div>
<div class="procedure">절차 4-5: 내진 설계 - 보강 다리 위의 용기 <span class="page-number">217</span></div>
<div class="procedure">절차 4-6: 내진 설계 - 링 위의 용기 <span class="page-number">223</span></div>
<div class="procedure">절차 4-7: 내진 설계 - 러그 위의 용기 <span class="page-number">229</span></div>
<div class="procedure">절차 4-8: 내진 설계 - 스커트 위의 용기 <span class="page-number">239</span></div>
<div class="procedure">절차 4-9: 내진 설계 - 원뿔형 스커트 위의 용기 <span class="page-number">248</span></div>
<div class="procedure">절차 4-10: 새들 위의 수평 용기 설계 <span class="page-number">253</span></div>
<div class="procedure">절차 4-11: 대형 용기용 새들 지지대 설계 <span class="page-number">267</span></div>
<div class="procedure">절차 4-12: 다리용 베이스 플레이트 설계 <span class="page-number">275</span></div>
<div class="procedure">절차 4-13: 러그 지지대 설계 <span class="page-number">278</span></div>
<div class="procedure">절차 4-14: 수직 용기용 베이스 상세 설계 - 이동된 중립축 방법 <span class="page-number">281</span></div>
<div class="procedure">절차 4-15: 수직 용기용 베이스 상세 설계 - 중심 중립축 방법 <span class="page-number">291</span></div>
<div class="procedure">절차 4-16: 수직 용기용 앵커 볼트 설계 <span class="page-number">293</span></div>
<div class="procedure">절차 4-17: 콘크리트의 특성 <span class="page-number">295</span></div>
<div class="procedure">참고문헌 <span class="page-number">296</span></div>
<div class="chapter">5: 용기 내부 구조물 <span class="page-number">297</span></div>
<div class="procedure">절차 5-1: 내부 지지 베드 설계 <span class="page-number">298</span></div>
<div class="procedure">절차 5-2: 격자 빔 설계 <span class="page-number">310</span></div>
<div class="procedure">절차 5-3: 지지 빔 위치에서의 하중으로 인한 쉘 응력 <span class="page-number">316</span></div>
<div class="procedure">절차 5-4: 지지 블록 설계 <span class="page-number">319</span></div>
<div class="procedure">절차 5-5: 베드 지지대용 허브 링 <span class="page-number">321</span></div>
<div class="procedure">절차 5-6: 열전달용 파이프 코일 설계 <span class="page-number">326</span></div>
<div class="procedure">절차 5-7: 용기 및 탱크용 교반기/믹서 <span class="page-number">345</span></div>
<div class="procedure">절차 5-8: 내부 파이프 분배기 설계 <span class="page-number">353</span></div>
<div class="procedure">절차 5-9: 트레이 설계 <span class="page-number">366</span></div>
<div class="procedure">절차 5-10: 위어를 통한 유동 <span class="page-number">375</span></div>
<div class="procedure">절차 5-11: 디미스터 설계 <span class="page-number">376</span></div>
<div class="procedure">절차 5-12: 배플 설계 <span class="page-number">381</span></div>
<div class="procedure">절차 5-13: 충돌 플레이트 설계 <span class="page-number">391</span></div>
<div class="procedure">참고문헌 <span class="page-number">392</span></div>
<div class="chapter">6: 특수 설계 <span class="page-number">393</span></div>
<div class="procedure">절차 6-1: 대직경 노즐 개구부 설계 <span class="page-number">394</span></div>
<div class="procedure">대형 개구부—막 및 굽힘 분석 <span class="page-number">397</span></div>
<div class="procedure">절차 6-2: 타워 처짐 <span class="page-number">397</span></div>
<div class="procedure">절차 6-3: 링 거더 설계 <span class="page-number">401</span></div>
<div class="procedure">절차 6-4: 내화 라이닝이 있는 용기 설계 <span class="page-number">406</span></div>
<div class="procedure">절차 6-5: 높은 타워 및 스택의 진동 <span class="page-number">418</span></div>
<div class="procedure">절차 6-6: 지하 탱크 및 용기 <span class="page-number">428</span></div>
<div class="procedure">절차 6-7: 국부 얇은 영역 (LTA) <span class="page-number">432</span></div>
<div class="procedure">참고문헌 <span class="page-number">433</span></div>
<div class="chapter">7: 국부 하중 <span class="page-number">435</span></div>
<div class="procedure">절차 7-1: 원형 링의 응력 <span class="page-number">437</span></div>
<div class="procedure">절차 7-2: 부분 링 보강재 설계 <span class="page-number">446</span></div>
<div class="procedure">절차 7-3: 부착 매개변수 <span class="page-number">448</span></div>
<div class="procedure">절차 7-4: 외부 국부 하중으로 인한 원통형 쉘의 응력 <span class="page-number">449</span></div>
<div class="procedure">절차 7-5: 외부 국부 하중으로 인한 구형 쉘의 응력 <span class="page-number">465</span></div>
<div class="procedure">참고문헌 <span class="page-number">472</span></div>
<div class="chapter">8: 고압 용기 <span class="page-number">473</span></div>
<div class="procedure">1.0. 일반사항 <span class="page-number">474</span></div>
<div class="procedure">2.0. 쉘 설계 <span class="page-number">496</span></div>
<div class="procedure">3.0. 클로저 설계 <span class="page-number">502</span></div>
<div class="procedure">4.0. 노즐 <span class="page-number">551</span></div>
<div class="procedure">5.0. 참고문헌 <span class="page-number">556</span></div>
<div class="chapter">9: 관련 장비 <span class="page-number">557</span></div>
<div class="procedure">절차 9-1: 데이빗 설계 <span class="page-number">558</span></div>
<div class="procedure">절차 9-2: 원형 플랫폼 설계 <span class="page-number">563</span></div>
<div class="procedure">절차 9-3: 정사각형 및 직사각형 플랫폼 설계 <span class="page-number">571</span></div>
<div class="procedure">절차 9-4: 파이프 지지대 설계 <span class="page-number">576</span></div>
<div class="procedure">절차 9-5: 볼트 연결부의 전단 하중 <span class="page-number">584</span></div>
<div class="procedure">절차 9-6: 빈 및 고가 탱크 설계 <span class="page-number">586</span></div>
<div class="procedure">절차 9-7: 현장 제작 구체 <span class="page-number">594</span></div>
<div class="procedure">참고문헌 <span class="page-number">630</span></div>
<div class="chapter">10: 압력용기의 운송 및 설치 <span class="page-number">631</span></div>
<div class="procedure">절차 10-1: 압력용기의 운송 <span class="page-number">632</span></div>
<div class="procedure">절차 10-2: 압력용기의 설치 <span class="page-number">660</span></div>
<div class="procedure">절차 10-3: 리프팅 부착물 및 용어 <span class="page-number">666</span></div>
<div class="procedure">절차 10-4: 리프팅 하중 및 힘 <span class="page-number">675</span></div>
<div class="procedure">절차 10-5: 테일 빔, 러그 및 베이스 링 상세 설계 <span class="page-number">681</span></div>
<div class="procedure">절차 10-6: 상부 헤드 및 원뿔 리프팅 러그 설계 <span class="page-number">691</span></div>
<div class="procedure">절차 10-7: 플랜지 러그 설계 <span class="page-number">695</span></div>
<div class="procedure">절차 10-8: 트러니언 설계 <span class="page-number">706</span></div>
<div class="procedure">절차 10-9: 설치 하중으로 인한 쉘의 국부 하중 <span class="page-number">710</span></div>
<div class="procedure">절차 10-10: 기타 <span class="page-number">713</span></div>
<div class="chapter">11: 재료 <span class="page-number">719</span></div>
<div class="procedure">11.1. 재료의 종류 <span class="page-number">720</span></div>
<div class="procedure">11.2. 재료의 특성 <span class="page-number">723</span></div>
<div class="procedure">11.3. 볼팅 <span class="page-number">728</span></div>
<div class="procedure">11.4. 시험 및 검사 <span class="page-number">732</span></div>
<div class="procedure">11.5. 열처리 <span class="page-number">738</span></div>
<div class="chapter">부록 <span class="page-number">743</span></div>
<div class="chapter">색인 <span class="page-number">803</span></div>
</div>
<h2>제4판 서문</h2>
<div class="preface">
<p>35년 전 압력용기 설계 매뉴얼을 시작했을 때, 이것이 어디로 이어질지 전혀 알지 못했습니다. 첫 번째 판만 해도 출판하는 데 10년이 걸렸습니다. 1972년 로스앤젤레스의 작은 용기 제작소에서 일하기 시작했을 때 시작되었습니다. 그 당시 우리 업계의 엔지니어와 설계자들이 이용할 수 있는 정보가 얼마나 적은지 믿을 수 없었습니다. 저는 손에 넣을 수 있는 모든 것을 수집하고 연구하기 시작했습니다. 점점 더 많이 수집하면서 다양한 주제에 대한 절차를 작성하기 시작했습니다. 얼마 후 꽤 상당한 컬렉션을 갖추게 되었고, 누군가가 이것이 좋은 책이 될 수 있을 것이라고 제안했습니다.</p>
<p>그러나 저는 계속해서 수정하고 있었고 그 어느 것도 출판할 만큼 완전하다고 생각하지 않았습니다. 얼마 후 출판할 수 있도록 완벽하게 만들려고 노력하기 시작했습니다. 이것이 취미에서 직업으로 바뀐 시점이었습니다. 제 목표는 제가 수집할 수 있는 압력용기 설계를 위한 방정식, 데이터 및 절차의 가능한 한 완전한 컬렉션을 제공하는 것이었습니다. 저는 이 점에서 결코 자신을 저자로 생각하지 않았습니다... 단지 편집자일 뿐입니다. 저는 방정식이나 방법을 개발하는 것이 아니라 단지 수집하고 정리하는 것이었습니다. 자료의 프레젠테이션은 그때도 지금도 제 노력의 초점입니다. 항상 말해왔듯이 "저자는 형식 이외에는 독창성을 주장하지 않습니다."</p>
<p>제 목표 독자는 항상 제조하는 설계에 대해 궁극적으로 책임을 지는 작업장의 사람이었습니다. 저는 PVDM에 대한 제 모든 목표가 모든 면에서 초과 달성되는 것을 보았습니다. Fluor에서의 작업을 통해 40개국을 여행하고 60개의 용기 제작소를 방문할 기회를 가졌습니다. 지난 10년 동안 PVDM을 사용하지 않는 제작소를 방문한 적이 없습니다. 이것이 제 보상이었습니다. 이 책은 지금도, 그리고 항상 최종 사용자에게 헌정되었습니다. 감사합니다.</p>
<p>PVDM은 무엇보다도 "설계자"를 위한 매뉴얼이며, 공학 교과서가 아닙니다. 절차는 중량, 크기 또는 두께를 제공하도록 간소화되었습니다. 대부분의 경우 가능한 한 방정식의 유도나 이론적 배경을 피합니다. 저는 항상 가장 간단하고 직접적인 해결책을 찾아왔습니다.</p>
<p>이 책이 계속되기를 바란다면, 새롭고 젊고 매우 재능 있는 사람의 지시에 따라 이루어져야 합니다.</p>
<p>마지막으로, 지난 35년 동안 의견을 주시고, 기여해 주시고, 문헌을 보내주시고, 격려해 주신 모든 분들께 따뜻한 마음을 담아 진심으로 감사드립니다. 수년에 걸쳐 책이 발전하는 것을 지켜보는 것은 엄청나게 보람 있는 일이었습니다. 여러분이 없었다면 이 책은 불가능했을 것입니다!</p>
<p style="text-align: right;">Dennis R. Moss</p>
</div>
</div>
</div>
<script>
function toggleLanguage() {
const englishContent = document.getElementById('english-content');
const koreanContent = document.getElementById('korean-content');
if (englishContent.style.display === 'none') {
englishContent.style.display = 'block';
koreanContent.style.display = 'none';
} else {
englishContent.style.display = 'none';
koreanContent.style.display = 'block';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
# PostgreSQL 설정 - Synology DS1525+ 최적화 (32GB RAM)
# /volume1/docker/document-server/config/postgresql.conf
# 메모리 설정 (32GB RAM 환경)
shared_buffers = 8GB # RAM의 25% (8GB)
effective_cache_size = 24GB # RAM의 75% (24GB)
work_mem = 256MB # 복잡한 쿼리용 (정렬, 해시 조인)
maintenance_work_mem = 2GB # 인덱스 구축, VACUUM용
# 체크포인트 설정 (SSD 최적화)
checkpoint_completion_target = 0.9 # 체크포인트 분산 (SSD 수명 연장)
checkpoint_timeout = 15min # 체크포인트 간격
max_wal_size = 4GB # WAL 파일 최대 크기
min_wal_size = 1GB # WAL 파일 최소 크기
# WAL 설정
wal_buffers = 64MB # WAL 버퍼 크기
wal_writer_delay = 200ms # WAL 쓰기 지연
commit_delay = 0 # 커밋 지연 (SSD에서는 0)
# 비용 기반 최적화 (SSD 환경)
random_page_cost = 1.1 # SSD는 랜덤 액세스가 빠름
seq_page_cost = 1.0 # 순차 액세스 기준값
cpu_tuple_cost = 0.01 # CPU 튜플 처리 비용
cpu_index_tuple_cost = 0.005 # 인덱스 튜플 처리 비용
cpu_operator_cost = 0.0025 # 연산자 처리 비용
# 연결 설정
max_connections = 200 # 최대 연결 수
superuser_reserved_connections = 3 # 슈퍼유저 예약 연결
# 쿼리 플래너 설정
default_statistics_target = 100 # 통계 정확도
constraint_exclusion = partition # 파티션 제약 조건 최적화
enable_partitionwise_join = on # 파티션별 조인 최적화
enable_partitionwise_aggregate = on # 파티션별 집계 최적화
# 백그라운드 작업자 설정
max_worker_processes = 8 # 최대 워커 프로세스 (CPU 코어 수)
max_parallel_workers_per_gather = 4 # 병렬 쿼리 워커
max_parallel_workers = 8 # 전체 병렬 워커
max_parallel_maintenance_workers = 4 # 병렬 유지보수 워커
# 자동 VACUUM 설정
autovacuum = on # 자동 VACUUM 활성화
autovacuum_max_workers = 3 # VACUUM 워커 수
autovacuum_naptime = 1min # VACUUM 실행 간격
autovacuum_vacuum_threshold = 50 # VACUUM 임계값
autovacuum_analyze_threshold = 50 # ANALYZE 임계값
autovacuum_vacuum_scale_factor = 0.2 # VACUUM 스케일 팩터
autovacuum_analyze_scale_factor = 0.1 # ANALYZE 스케일 팩터
# 로깅 설정
log_destination = 'stderr' # 로그 출력 대상
logging_collector = off # Docker 환경에서는 off
log_min_messages = warning # 최소 로그 레벨
log_min_error_statement = error # 에러 문장 로그
log_min_duration_statement = 1000 # 1초 이상 쿼리 로깅
log_checkpoints = on # 체크포인트 로깅
log_connections = off # 연결 로깅 (성능상 off)
log_disconnections = off # 연결 해제 로깅 (성능상 off)
log_lock_waits = on # 락 대기 로깅
log_temp_files = 10MB # 임시 파일 로깅 (10MB 이상)
# 전문 검색 설정
default_text_search_config = 'pg_catalog.english'
# 시간대 설정
timezone = 'Asia/Seoul'
log_timezone = 'Asia/Seoul'
# 문자 인코딩
lc_messages = 'C'
lc_monetary = 'C'
lc_numeric = 'C'
lc_time = 'C'
# 기타 성능 설정
effective_io_concurrency = 200 # SSD 동시 I/O (SSD는 높게)
maintenance_io_concurrency = 10 # 유지보수 I/O 동시성
wal_compression = on # WAL 압축 (디스크 절약)
full_page_writes = on # 전체 페이지 쓰기 (안정성)
# JIT 컴파일 설정 (PostgreSQL 11+)
jit = on # JIT 컴파일 활성화
jit_above_cost = 100000 # JIT 활성화 비용 임계값
jit_inline_above_cost = 500000 # 인라인 JIT 비용 임계값
jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값
# 확장 모듈 설정
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈

178
docker-compose.synology.yml Normal file
View File

@@ -0,0 +1,178 @@
version: '3.8'
services:
# PostgreSQL 데이터베이스 (SSD 최적화 - 32GB RAM 활용)
database:
image: postgres:15-alpine
container_name: document-server-db
restart: unless-stopped
environment:
POSTGRES_DB: document_db
POSTGRES_USER: docuser
POSTGRES_PASSWORD: ${DB_PASSWORD:-docpass}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
# SSD: 데이터베이스 (성능 최우선)
- /volume3/docker/document-server/database:/var/lib/postgresql/data
- /volume3/docker/document-server/config/postgresql.synology.conf:/etc/postgresql/postgresql.conf:ro
- ./database/init:/docker-entrypoint-initdb.d:ro
ports:
- "24101:5432"
command: >
postgres
-c config_file=/etc/postgresql/postgresql.conf
-c shared_buffers=8GB
-c effective_cache_size=24GB
-c work_mem=512MB
-c maintenance_work_mem=4GB
-c checkpoint_completion_target=0.9
-c wal_buffers=128MB
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c max_worker_processes=8
-c max_parallel_workers_per_gather=4
-c max_parallel_workers=8
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docuser -d document_db"]
interval: 30s
timeout: 10s
retries: 3
networks:
- document-network
deploy:
resources:
limits:
memory: 10G
reservations:
memory: 2G
# Redis 캐시 (SSD 최적화 - 대용량 메모리 활용)
redis:
image: redis:7-alpine
container_name: document-server-redis
restart: unless-stopped
volumes:
# SSD: Redis 데이터 (빠른 캐시)
- /volume3/docker/document-server/redis:/data
ports:
- "24103:6379"
command: >
redis-server
--maxmemory 8gb
--maxmemory-policy allkeys-lru
--save 900 1
--save 300 10
--save 60 10000
--appendonly yes
--appendfsync everysec
--auto-aof-rewrite-percentage 100
--auto-aof-rewrite-min-size 64mb
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
networks:
- document-network
deploy:
resources:
limits:
memory: 10G
reservations:
memory: 1G
# FastAPI 백엔드 (SSD에서 실행, HDD 스토리지 연결)
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: document-server-backend
restart: unless-stopped
environment:
- DATABASE_URL=postgresql+asyncpg://docuser:${DB_PASSWORD:-docpass}@database:5432/document_db
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- DEBUG=false
- ALLOWED_ORIGINS=http://localhost:24100,https://${DOMAIN_NAME:-localhost}
- UPLOAD_DIR=/app/uploads
- MAX_FILE_SIZE=500000000
volumes:
# SSD: 애플리케이션 로그 및 설정 (빠른 액세스)
- /volume3/docker/document-server/logs:/app/logs
- /volume3/docker/document-server/config:/app/config
- /volume3/docker/document-server/cache:/app/cache
# HDD: 대용량 파일 저장소 (비용 효율적)
- /volume1/document-storage/uploads:/app/uploads
- /volume1/document-storage/documents:/app/documents
- /volume1/document-storage/thumbnails:/app/thumbnails
- /volume1/document-storage/backups:/app/backups
ports:
- "24102:8000"
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- document-network
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 512M
# Nginx 웹서버 (SSD 캐시, HDD 스토리지)
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: document-server-nginx
restart: unless-stopped
volumes:
# SSD: Nginx 설정, 로그, 캐시 (성능 최적화)
- /volume3/docker/document-server/nginx/conf.d:/etc/nginx/conf.d
- /volume3/docker/document-server/nginx/cache:/var/cache/nginx
- /volume3/docker/document-server/logs/nginx:/var/log/nginx
# SSD: 프론트엔드 정적 파일 (빠른 서빙)
- ./frontend:/usr/share/nginx/html:ro
# HDD: 대용량 문서 파일 (읽기 전용)
- /volume1/document-storage/uploads:/usr/share/nginx/html/uploads:ro
- /volume1/document-storage/documents:/usr/share/nginx/html/documents:ro
ports:
- "24100:80"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
networks:
- document-network
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 128M
networks:
document-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# 볼륨 정의는 제거 (직접 경로 매핑 사용)

View File

@@ -0,0 +1,317 @@
<!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.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="backupRestoreApp()">
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">백업/복원 관리</h1>
<p class="text-gray-600">시스템 데이터를 백업하고 복원할 수 있습니다.</p>
</div>
<!-- 백업 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-download text-blue-500 mr-3"></i>
데이터 백업
</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 전체 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">전체 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스와 업로드된 파일을 모두 백업합니다.</p>
<button @click="createBackup('full')"
:disabled="loading"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-database mr-2"></i>
<span x-show="!loading">전체 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
<!-- 데이터베이스만 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">데이터베이스 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스만 백업합니다 (파일 제외).</p>
<button @click="createBackup('db')"
:disabled="loading"
class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-table mr-2"></i>
<span x-show="!loading">DB 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
</div>
<!-- 백업 상태 -->
<div x-show="backupStatus" class="mt-4 p-4 rounded-lg" :class="backupStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="backupStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="backupStatus.message"></span>
</div>
</div>
</div>
</div>
<!-- 백업 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-history text-green-500 mr-3"></i>
백업 목록
</h2>
</div>
<div class="p-6">
<div x-show="backups.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>백업 파일이 없습니다.</p>
</div>
<div x-show="backups.length > 0" class="space-y-4">
<template x-for="backup in backups" :key="backup.id">
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-archive text-blue-600"></i>
</div>
<div>
<h4 class="font-medium text-gray-900" x-text="backup.name"></h4>
<p class="text-sm text-gray-500">
<span x-text="backup.type === 'full' ? '전체 백업' : 'DB 백업'"></span>
<span x-text="backup.size"></span>
<span x-text="backup.date"></span>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadBackup(backup)"
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fas fa-download mr-1"></i>
다운로드
</button>
<button @click="deleteBackup(backup)"
class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 복원 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-upload text-orange-500 mr-3"></i>
데이터 복원
</h2>
</div>
<div class="p-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i>
<span class="text-yellow-800 font-medium">주의사항</span>
</div>
<p class="text-yellow-700 text-sm mt-2">
복원 작업은 현재 데이터를 완전히 덮어씁니다. 복원 전에 반드시 현재 데이터를 백업하세요.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">백업 파일 선택</label>
<input type="file"
@change="handleFileSelect"
accept=".sql,.tar.gz,.zip"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<div x-show="selectedFile">
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-gray-900 mb-2">선택된 파일</h4>
<p class="text-sm text-gray-600" x-text="selectedFile?.name"></p>
<p class="text-sm text-gray-500" x-text="selectedFile ? formatFileSize(selectedFile.size) : ''"></p>
</div>
</div>
<button @click="restoreBackup()"
:disabled="!selectedFile || loading"
class="w-full bg-orange-600 text-white px-4 py-3 rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium">
<i class="fas fa-upload mr-2"></i>
<span x-show="!loading">복원 실행</span>
<span x-show="loading">복원 중...</span>
</button>
</div>
<!-- 복원 상태 -->
<div x-show="restoreStatus" class="mt-4 p-4 rounded-lg" :class="restoreStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="restoreStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="restoreStatus.message"></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function backupRestoreApp() {
return {
loading: false,
backups: [],
selectedFile: null,
backupStatus: null,
restoreStatus: null,
init() {
this.loadBackups();
},
async loadBackups() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.backups = await window.api.getBackups();
// 임시 데모 데이터
this.backups = [
{
id: '1',
name: 'backup_2025_09_03_full.tar.gz',
type: 'full',
size: '125.4 MB',
date: '2025년 9월 3일 15:30'
},
{
id: '2',
name: 'backup_2025_09_02_db.sql',
type: 'db',
size: '2.1 MB',
date: '2025년 9월 2일 10:15'
}
];
} catch (error) {
console.error('백업 목록 로드 실패:', error);
}
},
async createBackup(type) {
this.loading = true;
this.backupStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const result = await window.api.createBackup(type);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 3000));
this.backupStatus = {
type: 'success',
message: `${type === 'full' ? '전체' : 'DB'} 백업이 성공적으로 생성되었습니다.`
};
this.loadBackups();
} catch (error) {
this.backupStatus = {
type: 'error',
message: '백업 생성 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
downloadBackup(backup) {
// 실제 구현에서는 백엔드에서 파일 다운로드
alert(`${backup.name} 다운로드를 시작합니다.`);
},
async deleteBackup(backup) {
if (!confirm(`${backup.name}을(를) 삭제하시겠습니까?`)) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.deleteBackup(backup.id);
this.backups = this.backups.filter(b => b.id !== backup.id);
alert('백업이 삭제되었습니다.');
} catch (error) {
alert('백업 삭제 중 오류가 발생했습니다: ' + error.message);
}
},
handleFileSelect(event) {
this.selectedFile = event.target.files[0];
this.restoreStatus = null;
},
async restoreBackup() {
if (!this.selectedFile) return;
if (!confirm('현재 데이터가 모두 삭제되고 백업 데이터로 복원됩니다. 계속하시겠습니까?')) {
return;
}
this.loading = true;
this.restoreStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const formData = new FormData();
// formData.append('backup_file', this.selectedFile);
// await window.api.restoreBackup(formData);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 5000));
this.restoreStatus = {
type: 'success',
message: '백업이 성공적으로 복원되었습니다. 페이지를 새로고침해주세요.'
};
} catch (error) {
this.restoreStatus = {
type: 'error',
message: '복원 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
}
</script>
</body>
</html>

View File

@@ -127,55 +127,6 @@
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p> <p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
</div> </div>
</div> </div>
<!-- PDF 매칭 섹션 -->
<div x-show="availablePDFs.length > 0" class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-file-pdf mr-2 text-red-600"></i>
사용 가능한 PDF 문서
<span class="ml-2 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full" x-text="availablePDFs.length"></span>
</h2>
<p class="text-gray-600 text-sm mt-1">이 서적과 연결할 수 있는 PDF 문서들입니다</p>
</div>
<div class="p-6">
<div class="grid gap-4">
<template x-for="pdf in availablePDFs" :key="pdf.id">
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-md font-medium text-gray-900 mb-1" x-text="pdf.title"></h3>
<p class="text-gray-600 text-sm mb-2" x-text="pdf.description || '설명이 없습니다'"></p>
<div class="flex items-center text-sm text-gray-500 space-x-4">
<span class="flex items-center">
<i class="fas fa-file-pdf mr-1 text-red-500"></i>
<span x-text="pdf.original_filename"></span>
</span>
<span class="flex items-center">
<i class="fas fa-calendar mr-1"></i>
<span x-text="formatDate(pdf.created_at)"></span>
</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button @click="matchPDFToBook(pdf.id)"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
<i class="fas fa-link mr-1"></i>서적에 연결
</button>
<button @click="openPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="PDF 열기">
<i class="fas fa-external-link-alt"></i>
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</main> </main>
<!-- JavaScript 파일들 --> <!-- JavaScript 파일들 -->

View File

@@ -146,14 +146,19 @@
<!-- PDF 매칭 및 컨트롤 --> <!-- PDF 매칭 및 컨트롤 -->
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<!-- PDF 매칭 드롭다운 --> <!-- PDF 매칭 드롭다운 -->
<div class="min-w-48"> <div class="min-w-48 relative">
<select x-model="doc.matched_pdf_id" <select x-model="doc.matched_pdf_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"> :class="doc.matched_pdf_id ? 'border-green-300 bg-green-50' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">PDF 매칭 없음</option> <option value="">PDF 매칭 없음</option>
<template x-for="pdf in availablePDFs" :key="pdf.id"> <template x-for="pdf in availablePDFs" :key="pdf.id">
<option :value="pdf.id" x-text="pdf.title"></option> <option :value="pdf.id" x-text="pdf.title"></option>
</template> </template>
</select> </select>
<!-- 매칭 상태 표시 -->
<div x-show="doc.matched_pdf_id" class="absolute -top-1 -right-1">
<div class="w-3 h-3 bg-green-500 rounded-full border-2 border-white"></div>
</div>
</div> </div>
<!-- 이동 버튼 --> <!-- 이동 버튼 -->

View File

@@ -1,5 +1,5 @@
<!-- 공통 헤더 컴포넌트 --> <!-- 공통 헤더 컴포넌트 -->
<header class="header-modern fade-in"> <header class="header-modern fade-in fixed top-0 left-0 right-0 z-50">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<!-- 로고 --> <!-- 로고 -->
@@ -8,101 +8,224 @@
<h1 class="text-xl font-bold text-gray-900">Document Server</h1> <h1 class="text-xl font-bold text-gray-900">Document Server</h1>
</div> </div>
<!-- 메인 네비게이션 - 3가지 기능 --> <!-- 메인 네비게이션 -->
<nav class="flex space-x-6"> <nav class="hidden md:flex items-center space-x-1 relative">
<!-- 문서 관리 시스템 --> <!-- 문서 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="index.html" class="nav-link" id="doc-nav-link"> <button class="nav-link-modern" id="doc-nav-link">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open text-blue-600"></i>
<span>문서 관리</span> <span>문서 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i> <i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</a> </button>
<div x-show="open" x-transition class="nav-dropdown"> <div x-show="open" x-transition:enter="transition ease-out duration-150" 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-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<a href="index.html" class="nav-dropdown-item" id="index-nav-item"> <div class="grid grid-cols-2 gap-2">
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리 <a href="index.html" class="nav-dropdown-card" id="index-nav-item">
</a> <div class="flex items-center space-x-3">
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item"> <div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리 <i class="fas fa-th-large text-blue-600"></i>
</a> </div>
<div>
<div class="font-semibold text-gray-900">문서 관리</div>
<div class="text-xs text-gray-500">HTML 문서 관리</div>
</div>
</div>
</a>
<a href="pdf-manager.html" class="nav-dropdown-card" id="pdf-manager-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-pdf text-red-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">PDF 관리</div>
<div class="text-xs text-gray-500">PDF 파일 관리</div>
</div>
</div>
</a>
</div>
</div> </div>
</div> </div>
<!-- 소설 관리 시스템 --> <!-- 통합 검색 -->
<a href="search.html" class="nav-link-modern" id="search-nav-link">
<i class="fas fa-search text-green-600"></i>
<span>통합 검색</span>
</a>
<!-- 할일관리 -->
<a href="todos.html" class="nav-link-modern" id="todos-nav-link">
<i class="fas fa-tasks text-indigo-600"></i>
<span>할일관리</span>
</a>
<!-- 소설 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="memo-tree.html" class="nav-link" id="novel-nav-link"> <button class="nav-link-modern" id="novel-nav-link">
<i class="fas fa-feather-alt"></i> <i class="fas fa-feather-alt text-purple-600"></i>
<span>소설 관리</span> <span>소설 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i> <i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</a> </button>
<div x-show="open" x-transition class="nav-dropdown"> <div x-show="open" x-transition:enter="transition ease-out duration-150" 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-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item"> <div class="grid grid-cols-2 gap-2">
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰 <a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
</a> <div class="flex items-center space-x-3">
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item"> <div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰 <i class="fas fa-sitemap text-purple-600"></i>
</a> </div>
<div>
<div class="font-semibold text-gray-900">트리 뷰</div>
<div class="text-xs text-gray-500">계층형 메모 관리</div>
</div>
</div>
</a>
<a href="story-view.html" class="nav-dropdown-card" id="story-view-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book-open text-orange-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">스토리 뷰</div>
<div class="text-xs text-gray-500">스토리 읽기 모드</div>
</div>
</div>
</a>
</div>
</div> </div>
</div> </div>
<!-- 노트 관리 시스템 --> <!-- 노트 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false"> <div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="notes.html" class="nav-link" id="notes-nav-link"> <button class="nav-link-modern" id="notes-nav-link">
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note text-yellow-600"></i>
<span>노트 관리</span> <span>노트 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i> <i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</a> </button>
<div x-show="open" x-transition class="nav-dropdown"> <div x-show="open" x-transition:enter="transition ease-out duration-150" 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-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item"> <div class="grid grid-cols-3 gap-2">
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리 <a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
</a> <div class="flex flex-col items-center text-center space-y-2">
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item"> <div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록 <i class="fas fa-book text-blue-600"></i>
</a> </div>
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item"> <div>
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성 <div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
</a> <div class="text-xs text-gray-500">그룹 관리</div>
</div>
</div>
</a>
<a href="notes.html" class="nav-dropdown-card" id="notes-list-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-list text-green-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">노트 목록</div>
<div class="text-xs text-gray-500">전체 보기</div>
</div>
</div>
</a>
<a href="note-editor.html" class="nav-dropdown-card" id="note-editor-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-edit text-purple-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">새 노트 작성</div>
<div class="text-xs text-gray-500">노트 만들기</div>
</div>
</div>
</a>
</div>
</div> </div>
</div> </div>
</nav> </nav>
<!-- 모바일 메뉴 버튼 -->
<div class="md:hidden">
<button x-data="{ open: false }" @click="open = !open" class="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
</div>
<!-- 사용자 메뉴 --> <!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- PDF 관리 버튼 -->
<a href="pdf-manager.html" class="nav-link" title="PDF 관리">
<i class="fas fa-file-pdf text-red-500"></i>
<span class="hidden sm:inline">PDF</span>
</a>
<!-- 언어 전환 버튼 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link" title="언어 설정">
<i class="fas fa-globe"></i>
<span class="hidden sm:inline">한국어</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</button>
<div x-show="open" x-transition class="nav-dropdown">
<button class="nav-dropdown-item" onclick="handleLanguageChange('ko')">
<i class="fas fa-flag mr-2 text-blue-500"></i>한국어
</button>
<button class="nav-dropdown-item" onclick="handleLanguageChange('en')">
<i class="fas fa-flag mr-2 text-red-500"></i>English
</button>
</div>
</div>
<!-- 로그인/로그아웃 --> <!-- 사용자 계정 메뉴 -->
<div class="flex items-center space-x-3" id="user-menu"> <div class="flex items-center space-x-3" id="user-menu">
<!-- 로그인된 사용자 --> <!-- 로그인된 사용자 드롭다운 -->
<div class="hidden" id="logged-in-menu"> <div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
<span class="text-sm text-gray-600" id="user-name">User</span> <button @click="open = !open" class="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors">
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm"> <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<i class="fas fa-sign-out-alt"></i> 로그아웃 <i class="fas fa-user text-white text-sm"></i>
</div>
<div class="hidden sm:block">
<div class="text-sm font-medium text-gray-900" id="user-name">User</div>
</div>
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button> </button>
<!-- 심플한 드롭다운 메뉴 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<!-- 사용자 정보 헤더 -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="text-sm font-medium text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-500" id="dropdown-user-email">user@example.com</div>
</div>
<!-- 메뉴 항목들 -->
<div class="py-1">
<a href="profile.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-user-edit w-4 h-4 mr-3 text-gray-400"></i>
프로필 관리
</a>
<!-- 관리자 메뉴 (관리자만 표시) -->
<div class="hidden" id="admin-menu-section">
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2">
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">관리자</div>
</div>
<a href="user-management.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-users-cog w-4 h-4 mr-3 text-indigo-500"></i>
계정 관리
</a>
<a href="system-settings.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-server w-4 h-4 mr-3 text-green-500"></i>
시스템 설정
</a>
<a href="backup-restore.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-database w-4 h-4 mr-3 text-blue-500"></i>
백업/복원
</a>
<a href="logs.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-file-alt w-4 h-4 mr-3 text-orange-500"></i>
시스템 로그
</a>
</div>
<!-- 로그아웃 -->
<div class="border-t border-gray-100 my-1"></div>
<button onclick="handleLogout()" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="fas fa-sign-out-alt w-4 h-4 mr-3"></i>
로그아웃
</button>
</div>
</div>
</div> </div>
<!-- 로그인 버튼 --> <!-- 로그인 버튼 -->
<div class="hidden" id="login-button"> <div class="" id="login-button">
<button onclick="handleLogin()" class="btn-improved btn-primary-improved"> <button id="login-btn" class="login-btn-modern">
<i class="fas fa-sign-in-alt"></i> 로그인 <i class="fas fa-sign-in-alt"></i>
<span>로그인</span>
</button> </button>
</div> </div>
</div> </div>
@@ -113,35 +236,99 @@
<!-- 헤더 관련 스타일 --> <!-- 헤더 관련 스타일 -->
<style> <style>
/* 네비게이션 링크 스타일 */ /* 모던 네비게이션 링크 스타일 */
.nav-link { .nav-link-modern {
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200; @apply flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
} }
.nav-link.active { .nav-link-modern:hover {
@apply text-blue-600 bg-blue-50 font-medium; @apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
} }
.nav-link:hover { .nav-link-modern.active {
@apply bg-gray-50; @apply text-blue-700 bg-blue-50 border-blue-200;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
} }
/* 드롭다운 메뉴 스타일 */ /* 와이드 드롭다운 메뉴 스타일 */
.nav-dropdown { .nav-dropdown-wide {
@apply absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-2 min-w-44 z-50; position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
margin-top: 0.5rem !important;
z-index: 9999 !important;
min-width: 400px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
padding: 1rem;
pointer-events: auto;
} }
.nav-dropdown-item { .nav-dropdown-card {
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150; @apply block p-4 bg-white border border-gray-100 rounded-lg hover:border-gray-200 hover:shadow-md transition-all duration-200 cursor-pointer;
} }
.nav-dropdown-item.active { .nav-dropdown-card:hover {
@apply text-blue-600 bg-blue-50 font-medium; @apply bg-gradient-to-br from-gray-50 to-blue-50 transform -translate-y-1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.nav-dropdown-card.active {
@apply border-blue-200 bg-blue-50;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
/* 드롭다운 컨테이너 안정성 */
nav > div.relative {
position: relative !important;
display: inline-block !important;
}
/* 애니메이션 중 위치 고정 */
.nav-dropdown-wide[x-show] {
position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
}
/* 모바일 메뉴 버튼 */
.mobile-menu-btn {
@apply p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200;
}
/* 사용자 메뉴 스타일 */
/* 사용자 메뉴 관련 스타일은 인라인으로 처리됨 */
/* 로그인 버튼 모던 스타일 */
.login-btn-modern {
@apply flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
}
.login-btn-modern:hover {
@apply from-blue-700 to-blue-800 transform -translate-y-0.5;
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
.login-btn-modern:active {
@apply transform translate-y-0;
} }
/* 헤더 모던 스타일 */ /* 헤더 모던 스타일 */
.header-modern { .header-modern {
@apply bg-white border-b border-gray-200 shadow-sm; @apply bg-white/95 backdrop-blur-md border-b border-gray-200/50 shadow-lg;
transition: all 0.3s ease;
}
/* 헤더 호버 효과 */
.header-modern:hover {
@apply bg-white shadow-xl;
} }
/* 언어 전환 스타일 */ /* 언어 전환 스타일 */
@@ -212,34 +399,9 @@
// 로그인 관련 함수들 // 로그인 관련 함수들
window.handleLogin = () => { window.handleLogin = () => {
console.log('🔐 handleLogin 호출됨'); console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
const currentUrl = encodeURIComponent(window.location.href);
// Alpine.js 컨텍스트에서 함수 찾기 window.location.href = `login.html?redirect=${currentUrl}`;
const bodyElement = document.querySelector('body');
if (bodyElement && bodyElement._x_dataStack) {
const alpineData = bodyElement._x_dataStack[0];
if (alpineData && typeof alpineData.openLoginModal === 'function') {
console.log('✅ Alpine 컨텍스트에서 openLoginModal 호출');
alpineData.openLoginModal();
return;
}
if (alpineData && alpineData.showLoginModal !== undefined) {
console.log('✅ Alpine 컨텍스트에서 showLoginModal 설정');
alpineData.showLoginModal = true;
return;
}
}
// 전역 함수로 시도
if (typeof window.openLoginModal === 'function') {
console.log('✅ 전역 openLoginModal 호출');
window.openLoginModal();
return;
}
// 직접 이벤트 발생
console.log('🔄 커스텀 이벤트로 로그인 모달 열기');
document.dispatchEvent(new CustomEvent('open-login-modal'));
}; };
window.handleLogout = () => { window.handleLogout = () => {
@@ -253,22 +415,73 @@
// 사용자 상태 업데이트 함수 // 사용자 상태 업데이트 함수
window.updateUserMenu = (user) => { window.updateUserMenu = (user) => {
console.log('🔄 updateUserMenu 호출됨:', user);
const loggedInMenu = document.getElementById('logged-in-menu'); const loggedInMenu = document.getElementById('logged-in-menu');
const loginButton = document.getElementById('login-button'); const loginButton = document.getElementById('login-button');
const adminMenuSection = document.getElementById('admin-menu-section');
console.log('🔍 요소 찾기:', {
loggedInMenu: !!loggedInMenu,
loginButton: !!loginButton,
adminMenuSection: !!adminMenuSection
});
// 사용자 정보 요소들
const userName = document.getElementById('user-name'); const userName = document.getElementById('user-name');
const userRole = document.getElementById('user-role');
const dropdownUserName = document.getElementById('dropdown-user-name');
const dropdownUserEmail = document.getElementById('dropdown-user-email');
const dropdownUserRole = document.getElementById('dropdown-user-role');
if (user) { if (user) {
// 로그인된 상태 // 로그인된 상태
if (loggedInMenu) loggedInMenu.classList.remove('hidden'); console.log('✅ 사용자 로그인 상태 - UI 업데이트 시작');
if (loginButton) loginButton.classList.add('hidden'); if (loggedInMenu) {
if (userName) userName.textContent = user.username || user.full_name || user.email || 'User'; loggedInMenu.classList.remove('hidden');
console.log('✅ 로그인 메뉴 표시');
}
if (loginButton) {
loginButton.classList.add('hidden');
console.log('✅ 로그인 버튼 숨김');
}
// 사용자 정보 업데이트
const displayName = user.full_name || user.email || 'User';
const roleText = getRoleText(user.role);
if (userName) userName.textContent = displayName;
if (userRole) userRole.textContent = roleText;
if (dropdownUserName) dropdownUserName.textContent = displayName;
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
// 관리자 메뉴 표시/숨김
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
adminMenuSection.classList.remove('hidden');
} else {
adminMenuSection.classList.add('hidden');
}
}
} else { } else {
// 로그아웃된 상태 // 로그아웃된 상태
if (loggedInMenu) loggedInMenu.classList.add('hidden'); if (loggedInMenu) loggedInMenu.classList.add('hidden');
if (loginButton) loginButton.classList.remove('hidden'); if (loginButton) loginButton.classList.remove('hidden');
if (adminMenuSection) adminMenuSection.classList.add('hidden');
} }
}; };
// 역할 텍스트 변환 함수
function getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
}
// 언어 토글 함수 (전역) // 언어 토글 함수 (전역)
// 통합 언어 변경 함수 // 통합 언어 변경 함수
window.handleLanguageChange = (lang) => { window.handleLanguageChange = (lang) => {
@@ -329,9 +542,27 @@
// 향후 다국어 지원 시 구현 // 향후 다국어 지원 시 구현
}; };
// 헤더 로드 완료 후 언어 설정 적용 // 헤더 로드 완료 후 이벤트 바인딩
document.addEventListener('headerLoaded', () => { document.addEventListener('headerLoaded', () => {
console.log('🔧 헤더 로드 완료 - 언어 전환 함수 등록'); console.log('🔧 헤더 로드 완료 - 이벤트 바인딩 시작');
// 로그인 버튼 이벤트 리스너 추가
const loginBtn = document.getElementById('login-btn');
if (loginBtn) {
loginBtn.addEventListener('click', () => {
console.log('🔐 로그인 버튼 클릭됨');
if (typeof window.handleLogin === 'function') {
window.handleLogin();
} else {
console.error('❌ handleLogin 함수를 찾을 수 없습니다');
}
});
console.log('✅ 로그인 버튼 이벤트 리스너 등록 완료');
}
// 언어 설정 적용
const savedLang = localStorage.getItem('preferred_language') || 'ko'; const savedLang = localStorage.getItem('preferred_language') || 'ko';
console.log('💾 저장된 언어 설정 적용:', savedLang); console.log('💾 저장된 언어 설정 적용:', savedLang);

View File

@@ -10,6 +10,9 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 --> <!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script> <script src="static/js/header-loader.js"></script>
</head> </head>
@@ -55,7 +58,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨텐츠 --> <!-- 메인 컨텐츠 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-8">
<!-- 로그인하지 않은 경우 --> <!-- 로그인하지 않은 경우 -->
<template x-if="!isAuthenticated"> <template x-if="!isAuthenticated">
<div class="text-center py-16"> <div class="text-center py-16">

316
frontend/login.html Normal file
View File

@@ -0,0 +1,316 @@
<!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>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
<style>
/* 배경 이미지 스타일 */
.login-background {
background: url('static/images/login-bg.jpg') center/cover;
background-attachment: fixed;
}
/* 기본 배경 (이미지가 없을 때) */
.login-background-fallback {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(147, 51, 234, 0.3));
}
/* 글래스모피즘 효과 */
.glass-effect {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 애니메이션 */
.fade-in {
animation: fadeIn 0.8s ease-out;
}
.slide-up {
animation: slideUp 0.6s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 입력 필드 포커스 효과 */
.input-glow:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* 로그인 버튼 호버 효과 */
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.4);
}
/* 파티클 애니메이션 */
.particle {
position: absolute;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* 로고 영역 개선 */
.logo-container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
</style>
</head>
<body class="min-h-screen login-background-fallback" x-data="loginApp()">
<!-- 배경 파티클 -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="particle w-2 h-2" style="left: 10%; top: 20%; animation-delay: 0s;"></div>
<div class="particle w-3 h-3" style="left: 20%; top: 80%; animation-delay: 2s;"></div>
<div class="particle w-1 h-1" style="left: 80%; top: 30%; animation-delay: 4s;"></div>
<div class="particle w-2 h-2" style="left: 90%; top: 70%; animation-delay: 1s;"></div>
<div class="particle w-1 h-1" style="left: 30%; top: 10%; animation-delay: 3s;"></div>
<div class="particle w-2 h-2" style="left: 70%; top: 90%; animation-delay: 5s;"></div>
</div>
<!-- 메인 컨테이너 -->
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative">
<!-- 로그인 영역 -->
<div class="max-w-md w-full space-y-8">
<!-- 로고 및 제목 -->
<div class="text-center fade-in">
<div class="mx-auto h-24 w-24 glass-effect rounded-full flex items-center justify-center mb-6 shadow-2xl">
<i class="fas fa-book text-white text-4xl"></i>
</div>
<h2 class="text-4xl font-bold text-white mb-2 drop-shadow-lg">Document Server</h2>
<p class="text-blue-100 text-lg">지식을 관리하고 공유하세요</p>
</div>
<!-- 로그인 폼 -->
<div class="glass-effect rounded-2xl shadow-2xl p-8 slide-up">
<div class="mb-6">
<h3 class="text-2xl font-semibold text-white text-center mb-2">로그인</h3>
<p class="text-blue-100 text-center text-sm">계정에 로그인하여 시작하세요</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-500/20 text-green-100 border border-green-400/30' : 'bg-red-500/20 text-red-100 border border-red-400/30'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-400' : 'fas fa-exclamation-circle text-red-400'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<form @submit.prevent="login()" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-blue-100 mb-2">
<i class="fas fa-envelope mr-2"></i>이메일
</label>
<input type="email" id="email" x-model="loginForm.email" required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300"
placeholder="이메일을 입력하세요">
</div>
<div>
<label for="password" class="block text-sm font-medium text-blue-100 mb-2">
<i class="fas fa-lock mr-2"></i>비밀번호
</label>
<div class="relative">
<input :type="showPassword ? 'text' : 'password'" id="password" x-model="loginForm.password" required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300 pr-12"
placeholder="비밀번호를 입력하세요">
<button type="button" @click="showPassword = !showPassword"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-blue-200 hover:text-white transition-colors">
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<!-- 로그인 유지 -->
<div class="flex items-center justify-between">
<label class="flex items-center text-sm text-blue-100">
<input type="checkbox" x-model="loginForm.remember"
class="mr-2 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
로그인 상태 유지
</label>
<a href="#" class="text-sm text-blue-200 hover:text-white transition-colors">
비밀번호를 잊으셨나요?
</a>
</div>
<button type="submit" :disabled="loading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed btn-login transition-all duration-300">
<i class="fas fa-spinner fa-spin mr-2" x-show="loading"></i>
<i class="fas fa-sign-in-alt mr-2" x-show="!loading"></i>
<span x-text="loading ? '로그인 중...' : '로그인'"></span>
</button>
</form>
<!-- 추가 옵션 -->
<div class="mt-6 text-center">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/20"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-transparent text-blue-200">또는</span>
</div>
</div>
<div class="mt-6">
<button @click="goToSetup()" class="text-blue-200 hover:text-white text-sm transition-colors">
<i class="fas fa-cog mr-1"></i>시스템 초기 설정
</button>
</div>
</div>
</div>
<!-- 푸터 -->
<div class="text-center text-blue-200 text-sm fade-in mt-8">
<p>&copy; 2024 Document Server. All rights reserved.</p>
<p class="mt-1">
<i class="fas fa-shield-alt mr-1"></i>
안전하고 신뢰할 수 있는 문서 관리 시스템
</p>
</div>
</div>
</div>
<!-- 배경 이미지 로드 스크립트 -->
<script>
// 배경 이미지 로드 시도
const img = new Image();
img.onload = function() {
document.body.classList.remove('login-background-fallback');
document.body.classList.add('login-background');
};
img.onerror = function() {
console.log('배경 이미지를 찾을 수 없어 기본 그라디언트를 사용합니다.');
};
img.src = 'static/images/login-bg.jpg';
</script>
<!-- API 스크립트 -->
<script src="static/js/api.js"></script>
<!-- 로그인 앱 스크립트 -->
<script>
function loginApp() {
return {
loading: false,
showPassword: false,
loginForm: {
email: '',
password: '',
remember: false
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔐 로그인 앱 초기화');
// 이미 로그인된 경우 메인 페이지로 리다이렉트
const token = localStorage.getItem('access_token');
if (token) {
try {
await api.getCurrentUser();
window.location.href = 'index.html';
return;
} catch (error) {
// 토큰이 유효하지 않으면 제거
localStorage.removeItem('access_token');
}
}
// URL 파라미터에서 리다이렉트 URL 확인
const urlParams = new URLSearchParams(window.location.search);
this.redirectUrl = urlParams.get('redirect') || 'index.html';
},
async login() {
if (!this.loginForm.email || !this.loginForm.password) {
this.showNotification('이메일과 비밀번호를 입력해주세요.', 'error');
return;
}
this.loading = true;
try {
console.log('🔐 로그인 시도:', this.loginForm.email);
const result = await api.login(this.loginForm.email, this.loginForm.password);
if (result.success) {
this.showNotification('로그인 성공! 페이지를 이동합니다...', 'success');
// 잠시 후 리다이렉트
setTimeout(() => {
window.location.href = this.redirectUrl || 'index.html';
}, 1000);
} else {
this.showNotification(result.message || '로그인에 실패했습니다.', 'error');
}
} catch (error) {
console.error('❌ 로그인 오류:', error);
this.showNotification('로그인 중 오류가 발생했습니다.', 'error');
} finally {
this.loading = false;
}
},
goToSetup() {
window.location.href = 'setup.html';
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
};
}
</script>
</body>
</html>

331
frontend/logs.html Normal file
View File

@@ -0,0 +1,331 @@
<!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.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="logsApp()">
<div class="max-w-6xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">시스템 로그</h1>
<p class="text-gray-600">시스템 활동과 오류 로그를 확인할 수 있습니다.</p>
</div>
<!-- 필터 및 컨트롤 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 로그 레벨 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">로그 레벨</label>
<select x-model="filters.level" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="">전체</option>
<option value="INFO">정보</option>
<option value="WARNING">경고</option>
<option value="ERROR">오류</option>
<option value="DEBUG">디버그</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">날짜</label>
<input type="date" x-model="filters.date" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
<input type="text" x-model="filters.search" @input="filterLogs()" placeholder="메시지 검색..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 컨트롤 버튼 -->
<div class="flex items-end space-x-2">
<button @click="refreshLogs()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
<i class="fas fa-sync-alt mr-1"></i>
새로고침
</button>
<button @click="clearLogs()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</div>
</div>
<!-- 로그 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-blue-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">정보</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.info"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-yellow-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">경고</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.warning"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">오류</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.error"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<i class="fas fa-bug text-gray-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">디버그</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.debug"></p>
</div>
</div>
</div>
</div>
<!-- 로그 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-list text-gray-500 mr-3"></i>
로그 목록
<span class="ml-2 text-sm font-normal text-gray-500">(<span x-text="filteredLogs.length"></span>개)</span>
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">시간</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">레벨</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소스</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">메시지</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-for="log in paginatedLogs" :key="log.id">
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900" x-text="log.timestamp"></td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-800': log.level === 'INFO',
'bg-yellow-100 text-yellow-800': log.level === 'WARNING',
'bg-red-100 text-red-800': log.level === 'ERROR',
'bg-gray-100 text-gray-800': log.level === 'DEBUG'
}"
x-text="log.level">
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" x-text="log.source"></td>
<td class="px-6 py-4 text-sm text-gray-900">
<div class="max-w-md truncate" x-text="log.message" :title="log.message"></div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
<span x-text="(currentPage - 1) * pageSize + 1"></span>-<span x-text="Math.min(currentPage * pageSize, filteredLogs.length)"></span> / <span x-text="filteredLogs.length"></span>
</div>
<div class="flex space-x-2">
<button @click="currentPage > 1 && currentPage--"
:disabled="currentPage <= 1"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
이전
</button>
<button @click="currentPage < totalPages && currentPage++"
:disabled="currentPage >= totalPages"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
다음
</button>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function logsApp() {
return {
logs: [],
filteredLogs: [],
currentPage: 1,
pageSize: 50,
filters: {
level: '',
date: '',
search: ''
},
stats: {
info: 0,
warning: 0,
error: 0,
debug: 0
},
get totalPages() {
return Math.ceil(this.filteredLogs.length / this.pageSize);
},
get paginatedLogs() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.filteredLogs.slice(start, end);
},
init() {
this.loadLogs();
// 자동 새로고침 (30초마다)
setInterval(() => {
this.refreshLogs();
}, 30000);
},
async loadLogs() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.logs = await window.api.getLogs();
// 임시 데모 데이터
this.logs = this.generateDemoLogs();
this.filterLogs();
this.updateStats();
} catch (error) {
console.error('로그 로드 실패:', error);
}
},
generateDemoLogs() {
const levels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'];
const sources = ['auth', 'documents', 'database', 'nginx', 'system'];
const messages = [
'사용자 로그인 성공: admin@test.com',
'문서 업로드 완료: test.pdf',
'데이터베이스 연결 실패',
'API 요청 처리 완료',
'메모리 사용량 경고: 85%',
'백업 작업 시작',
'인증 토큰 만료',
'파일 업로드 오류',
'시스템 재시작 완료',
'캐시 정리 작업 완료'
];
const logs = [];
for (let i = 0; i < 200; i++) {
const date = new Date();
date.setMinutes(date.getMinutes() - i * 5);
logs.push({
id: i + 1,
timestamp: date.toLocaleString('ko-KR'),
level: levels[Math.floor(Math.random() * levels.length)],
source: sources[Math.floor(Math.random() * sources.length)],
message: messages[Math.floor(Math.random() * messages.length)]
});
}
return logs.reverse();
},
filterLogs() {
this.filteredLogs = this.logs.filter(log => {
let matches = true;
if (this.filters.level && log.level !== this.filters.level) {
matches = false;
}
if (this.filters.date) {
const logDate = new Date(log.timestamp).toISOString().split('T')[0];
if (logDate !== this.filters.date) {
matches = false;
}
}
if (this.filters.search && !log.message.toLowerCase().includes(this.filters.search.toLowerCase())) {
matches = false;
}
return matches;
});
this.currentPage = 1;
},
updateStats() {
this.stats = {
info: this.logs.filter(log => log.level === 'INFO').length,
warning: this.logs.filter(log => log.level === 'WARNING').length,
error: this.logs.filter(log => log.level === 'ERROR').length,
debug: this.logs.filter(log => log.level === 'DEBUG').length
};
},
async refreshLogs() {
await this.loadLogs();
},
async clearLogs() {
if (!confirm('모든 로그를 삭제하시겠습니까?')) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.clearLogs();
this.logs = [];
this.filteredLogs = [];
this.updateStats();
alert('로그가 삭제되었습니다.');
} catch (error) {
alert('로그 삭제 중 오류가 발생했습니다: ' + error.message);
}
}
}
}
</script>
</body>
</html>

View File

@@ -597,6 +597,21 @@
.status-writing { border-left-color: #f59e0b; } .status-writing { border-left-color: #f59e0b; }
.status-review { border-left-color: #3b82f6; } .status-review { border-left-color: #3b82f6; }
.status-complete { border-left-color: #10b981; } .status-complete { border-left-color: #10b981; }
/* 드래그 앤 드롭 스타일 */
.drop-target-highlight {
background: rgba(59, 130, 246, 0.1) !important;
border: 2px dashed #3b82f6 !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3) !important;
}
.dragging {
opacity: 0.7;
transform: scale(1.05);
z-index: 1000;
cursor: grabbing !important;
}
</style> </style>
</head> </head>
@@ -641,7 +656,7 @@
</div> </div>
<!-- 메인 컨테이너 --> <!-- 메인 컨테이너 -->
<div class="h-screen pt-16" x-show="currentUser"> <div class="h-screen pt-20" x-show="currentUser">
<!-- 상단 툴바 --> <!-- 상단 툴바 -->
<div class="bg-white border-b shadow-sm p-4"> <div class="bg-white border-b shadow-sm p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -764,7 +779,8 @@
<div <div
class="absolute tree-diagram-node" class="absolute tree-diagram-node"
:style="getNodePosition(node)" :style="getNodePosition(node)"
@mousedown="startDragNode($event, node)" :data-node-id="node.id"
@click="selectNode(node)"
> >
<div <div
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative" class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
@@ -772,7 +788,6 @@
'tree-node-canonical': node.is_canonical, 'tree-node-canonical': node.is_canonical,
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id 'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
}" }"
@click="selectNode(node)"
@dblclick="editNodeInline(node)" @dblclick="editNodeInline(node)"
> >
<!-- 정사 경로 배지 --> <!-- 정사 경로 배지 -->
@@ -977,6 +992,35 @@
</div> </div>
</div> </div>
<!-- 토스트 알림 -->
<div x-show="notification.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full"
class="fixed top-4 right-4 z-50 max-w-sm">
<div class="rounded-lg shadow-lg border p-4"
:class="{
'bg-green-50 border-green-200 text-green-800': notification.type === 'success',
'bg-red-50 border-red-200 text-red-800': notification.type === 'error',
'bg-blue-50 border-blue-200 text-blue-800': notification.type === 'info'
}">
<div class="flex items-center">
<i :class="{
'fas fa-check-circle text-green-600': notification.type === 'success',
'fas fa-exclamation-circle text-red-600': notification.type === 'error',
'fas fa-info-circle text-blue-600': notification.type === 'info'
}" class="mr-2"></i>
<span x-text="notification.message"></span>
<button @click="notification.show = false" class="ml-auto text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 로그인이 필요한 상태 --> <!-- 로그인이 필요한 상태 -->
<div x-show="!currentUser" class="flex items-center justify-center h-screen"> <div x-show="!currentUser" class="flex items-center justify-center h-screen">
<div class="text-center"> <div class="text-center">

View File

@@ -85,7 +85,7 @@
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"> 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="">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id"> <template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option> <option :value="notebook.id" x-text="notebook.title"></option>
</template> </template>
</select> </select>
</div> </div>

View File

@@ -23,6 +23,18 @@
.gradient-bg { .gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
} }
.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> </style>
</head> </head>
<body class="bg-gray-50 min-h-screen" x-data="notebooksApp()"> <body class="bg-gray-50 min-h-screen" x-data="notebooksApp()">
@@ -30,7 +42,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨텐츠 --> <!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 --> <!-- 페이지 헤더 -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
@@ -200,7 +212,7 @@
</div> </div>
<!-- 노트북 메타데이터 --> <!-- 노트북 메타데이터 -->
<div class="flex items-center justify-between text-xs text-gray-500"> <div class="flex items-center justify-between text-xs text-gray-500 mb-2">
<span x-text="formatDate(notebook.updated_at)"></span> <span x-text="formatDate(notebook.updated_at)"></span>
<div class="flex items-center space-x-2"> <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 x-show="!notebook.is_active" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
@@ -212,6 +224,28 @@
</span> </span>
</div> </div>
</div> </div>
<!-- 빠른 액션 -->
<div x-show="notebook.note_count === 0" class="text-center py-2">
<p class="text-xs text-gray-400 mb-2">노트가 없습니다</p>
<button @click.stop="createNoteInNotebook(notebook)"
class="text-xs bg-blue-50 text-blue-600 px-3 py-1 rounded-full hover:bg-blue-100 transition-colors">
<i class="fas fa-plus mr-1"></i>
첫 노트 작성
</button>
</div>
<div x-show="notebook.note_count > 0" class="flex items-center justify-between text-xs">
<span class="text-gray-400">
<i class="fas fa-clock mr-1"></i>
<span x-text="formatDate(notebook.last_note_created_at || notebook.updated_at)"></span>
</span>
<button @click.stop="createNoteInNotebook(notebook)"
class="text-blue-600 hover:text-blue-800 transition-colors">
<i class="fas fa-plus mr-1"></i>
노트 추가
</button>
</div>
</div> </div>
</template> </template>
</div> </div>
@@ -229,6 +263,35 @@
</div> </div>
</main> </main>
<!-- 토스트 알림 -->
<div x-show="notification.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full"
class="fixed top-4 right-4 z-50 max-w-sm">
<div class="rounded-lg shadow-lg border p-4"
:class="{
'bg-green-50 border-green-200 text-green-800': notification.type === 'success',
'bg-red-50 border-red-200 text-red-800': notification.type === 'error',
'bg-blue-50 border-blue-200 text-blue-800': notification.type === 'info'
}">
<div class="flex items-center">
<i :class="{
'fas fa-check-circle text-green-600': notification.type === 'success',
'fas fa-exclamation-circle text-red-600': notification.type === 'error',
'fas fa-info-circle text-blue-600': notification.type === 'info'
}" class="mr-2"></i>
<span x-text="notification.message"></span>
<button @click="notification.show = false" class="ml-auto text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 노트북 생성/편집 모달 --> <!-- 노트북 생성/편집 모달 -->
<div x-show="showCreateModal || showEditModal" <div x-show="showCreateModal || showEditModal"
x-transition:enter="transition ease-out duration-300" x-transition:enter="transition ease-out duration-300"
@@ -321,6 +384,66 @@
</div> </div>
</div> </div>
<!-- 삭제 확인 모달 -->
<div x-show="showDeleteModal"
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 mb-4">
<div class="p-3 rounded-full bg-red-100 mr-4">
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">노트북 삭제</h3>
<p class="text-sm text-gray-600">이 작업은 되돌릴 수 없습니다.</p>
</div>
</div>
<div class="mb-6">
<p class="text-gray-700 mb-2">
<strong x-text="deletingNotebook?.title"></strong> 노트북을 삭제하시겠습니까?
</p>
<div x-show="deletingNotebook?.note_count > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div class="flex items-center">
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
<span class="text-sm text-yellow-800">
포함된 <strong x-text="deletingNotebook?.note_count"></strong>개의 노트는 미분류 상태가 됩니다.
</span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeDeleteModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="button"
@click="confirmDeleteNotebook()"
:disabled="deleting"
:class="deleting ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!deleting">삭제</span>
<span x-show="deleting">삭제 중...</span>
</button>
</div>
</div>
</div>
<!-- JavaScript 파일들 --> <!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script> <script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script> <script src="/static/js/api.js?v=2025012607"></script>

View File

@@ -28,7 +28,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨텐츠 --> <!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 --> <!-- 페이지 헤더 -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
@@ -137,7 +137,7 @@
class="px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"> 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> <option value="">노트북 선택...</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id"> <template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option> <option :value="notebook.id" x-text="notebook.title"></option>
</template> </template>
</select> </select>
@@ -180,7 +180,7 @@
<option value="">전체</option> <option value="">전체</option>
<option value="unassigned">미분류</option> <option value="unassigned">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id"> <template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.name"></option> <option :value="notebook.id" x-text="notebook.title"></option>
</template> </template>
</select> </select>
</div> </div>

View File

@@ -22,7 +22,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨텐츠 --> <!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 --> <!-- 페이지 헤더 -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -193,6 +193,12 @@
<!-- 액션 버튼 --> <!-- 액션 버튼 -->
<div class="flex items-center space-x-2 ml-4"> <div class="flex items-center space-x-2 ml-4">
<button @click="previewPDF(pdf)"
class="p-2 text-gray-400 hover:text-green-600 transition-colors"
title="PDF 미리보기">
<i class="fas fa-eye"></i>
</button>
<button @click="downloadPDF(pdf)" <button @click="downloadPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors" class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="PDF 다운로드"> title="PDF 다운로드">
@@ -225,10 +231,86 @@
</div> </div>
</main> </main>
<!-- PDF 미리보기 모달 -->
<div x-show="showPreviewModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
@click.self="closePreview()">
<div class="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
<!-- 헤더 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex items-center space-x-3">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
<div>
<h3 class="text-xl font-bold text-gray-900" x-text="previewPdf?.title"></h3>
<p class="text-sm text-gray-500" x-text="previewPdf?.original_filename"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadPDF(previewPdf)"
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<i class="fas fa-download"></i>
<span>다운로드</span>
</button>
<button @click="closePreview()"
class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
</div>
<!-- PDF 뷰어 -->
<div class="p-6 overflow-y-auto" style="max-height: calc(90vh - 120px);">
<!-- PDF 미리보기 -->
<div x-show="previewPdf" class="mb-4">
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 600px;">
<!-- PDF iframe 뷰어 -->
<iframe x-show="!pdfPreviewError && !pdfPreviewLoading && pdfPreviewSrc"
class="w-full h-full border-0"
:src="pdfPreviewSrc"
@load="pdfPreviewLoaded = true"
@error="handlePdfPreviewError()">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfPreviewLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
</div>
</div>
<!-- PDF 에러 상태 -->
<div x-show="pdfPreviewError" class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
<button @click="retryPdfPreview()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
다시 시도
</button>
<button @click="downloadPDF(previewPdf)"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
파일 다운로드
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript 파일들 --> <!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012384"></script> <script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script> <script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script> <script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/pdf-manager.js?v=2025012459"></script> <script src="/static/js/pdf-manager.js?v=2025012627"></script>
</body> </body>
</html> </html>

363
frontend/profile.html Normal file
View File

@@ -0,0 +1,363 @@
<!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>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
</head>
<body class="bg-gray-50 min-h-screen" x-data="profileApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- 페이지 제목 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">프로필 관리</h1>
<p class="text-gray-600">개인 정보와 계정 설정을 관리하세요.</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- 프로필 카드 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="p-6">
<div class="flex items-center space-x-6 mb-6">
<!-- 프로필 아바타 -->
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas fa-user text-blue-600 text-2xl"></i>
</div>
<!-- 기본 정보 -->
<div>
<h2 class="text-2xl font-bold text-gray-900" x-text="user.full_name || user.email"></h2>
<p class="text-gray-600" x-text="user.email"></p>
<div class="flex items-center mt-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
<span x-text="getRoleText(user.role)"></span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="mb-8">
<nav class="flex space-x-8" aria-label="Tabs">
<button @click="activeTab = 'profile'"
:class="activeTab === 'profile' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-user mr-2"></i>프로필 정보
</button>
<button @click="activeTab = 'security'"
:class="activeTab === 'security' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-lock mr-2"></i>보안 설정
</button>
<button @click="activeTab = 'preferences'"
:class="activeTab === 'preferences' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-cog mr-2"></i>환경 설정
</button>
</nav>
</div>
<!-- 프로필 정보 탭 -->
<div x-show="activeTab === 'profile'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">프로필 정보</h3>
<form @submit.prevent="updateProfile()" class="space-y-6">
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">이름</label>
<input type="text" id="full_name" x-model="profileForm.full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" id="email" x-model="user.email" disabled
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
<p class="mt-1 text-sm text-gray-500">이메일은 변경할 수 없습니다.</p>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="profileLoading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="profileLoading"></i>
<span x-text="profileLoading ? '저장 중...' : '프로필 저장'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- 보안 설정 탭 -->
<div x-show="activeTab === 'security'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">비밀번호 변경</h3>
<form @submit.prevent="changePassword()" class="space-y-6">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
<input type="password" id="current_password" x-model="passwordForm.current_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required>
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
<input type="password" id="new_password" x-model="passwordForm.new_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required minlength="6">
<p class="mt-1 text-sm text-gray-500">최소 6자 이상 입력해주세요.</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="passwordLoading || passwordForm.new_password !== passwordForm.confirm_password"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="passwordLoading"></i>
<span x-text="passwordLoading ? '변경 중...' : '비밀번호 변경'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- 환경 설정 탭 -->
<div x-show="activeTab === 'preferences'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">환경 설정</h3>
<form @submit.prevent="updatePreferences()" class="space-y-6">
<div>
<label for="theme" class="block text-sm font-medium text-gray-700 mb-2">테마</label>
<select id="theme" x-model="preferencesForm.theme"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="light">라이트 모드</option>
<option value="dark">다크 모드</option>
<option value="auto">시스템 설정 따름</option>
</select>
</div>
<div>
<label for="language" class="block text-sm font-medium text-gray-700 mb-2">언어</label>
<select id="language" x-model="preferencesForm.language"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div>
<label for="timezone" class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
<select id="timezone" x-model="preferencesForm.timezone"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="Asia/Seoul">서울 (UTC+9)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">뉴욕 (UTC-5)</option>
<option value="Europe/London">런던 (UTC+0)</option>
</select>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="preferencesLoading"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="preferencesLoading"></i>
<span x-text="preferencesLoading ? '저장 중...' : '설정 저장'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 공통 스크립트 -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<!-- 프로필 관리 스크립트 -->
<script>
function profileApp() {
return {
user: {},
activeTab: 'profile',
profileLoading: false,
passwordLoading: false,
preferencesLoading: false,
profileForm: {
full_name: ''
},
passwordForm: {
current_password: '',
new_password: '',
confirm_password: ''
},
preferencesForm: {
theme: 'light',
language: 'ko',
timezone: 'Asia/Seoul'
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔧 프로필 앱 초기화');
await this.loadUserProfile();
},
async loadUserProfile() {
try {
const response = await api.get('/users/me');
this.user = response;
// 폼 데이터 초기화
this.profileForm.full_name = response.full_name || '';
this.preferencesForm.theme = response.theme || 'light';
this.preferencesForm.language = response.language || 'ko';
this.preferencesForm.timezone = response.timezone || 'Asia/Seoul';
// 헤더 사용자 메뉴 업데이트
if (window.updateUserMenu) {
window.updateUserMenu(response);
}
console.log('✅ 사용자 프로필 로드 완료:', response);
} catch (error) {
console.error('❌ 사용자 프로필 로드 실패:', error);
this.showNotification('사용자 정보를 불러올 수 없습니다.', 'error');
}
},
async updateProfile() {
this.profileLoading = true;
try {
const response = await api.put('/users/me', this.profileForm);
this.user = { ...this.user, ...response };
// 헤더 사용자 메뉴 업데이트
if (window.updateUserMenu) {
window.updateUserMenu(this.user);
}
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
console.log('✅ 프로필 업데이트 완료');
} catch (error) {
console.error('❌ 프로필 업데이트 실패:', error);
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
} finally {
this.profileLoading = false;
}
},
async changePassword() {
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.passwordLoading = true;
try {
await api.post('/users/me/change-password', {
current_password: this.passwordForm.current_password,
new_password: this.passwordForm.new_password
});
// 폼 초기화
this.passwordForm = {
current_password: '',
new_password: '',
confirm_password: ''
};
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
console.log('✅ 비밀번호 변경 완료');
} catch (error) {
console.error('❌ 비밀번호 변경 실패:', error);
this.showNotification('비밀번호 변경에 실패했습니다. 현재 비밀번호를 확인해주세요.', 'error');
} finally {
this.passwordLoading = false;
}
},
async updatePreferences() {
this.preferencesLoading = true;
try {
const response = await api.put('/users/me', this.preferencesForm);
this.user = { ...this.user, ...response };
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
console.log('✅ 환경 설정 업데이트 완료');
} catch (error) {
console.error('❌ 환경 설정 업데이트 실패:', error);
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
} finally {
this.preferencesLoading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
},
getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
}
};
}
</script>
</body>
</html>

740
frontend/search.html Normal file
View File

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

274
frontend/setup.html Normal file
View File

@@ -0,0 +1,274 @@
<!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>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen" x-data="setupApp()">
<!-- 메인 컨테이너 -->
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- 로고 및 제목 -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-blue-600 rounded-full flex items-center justify-center mb-6">
<i class="fas fa-book text-white text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2">Document Server</h2>
<p class="text-gray-600">시스템 초기 설정</p>
</div>
<!-- 설정 상태 확인 중 -->
<div x-show="loading" class="text-center">
<div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-blue-600 bg-white">
<i class="fas fa-spinner fa-spin mr-2"></i>
시스템 상태 확인 중...
</div>
</div>
<!-- 이미 설정된 시스템 -->
<div x-show="!loading && !setupRequired" class="bg-white rounded-lg shadow-md p-8 text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">시스템이 이미 설정되었습니다</h3>
<p class="text-gray-600 mb-6">Document Server가 정상적으로 구성되어 있습니다.</p>
<a href="index.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
</a>
</div>
<!-- 초기 설정 폼 -->
<div x-show="!loading && setupRequired" class="bg-white rounded-lg shadow-md p-8">
<div class="mb-6">
<h3 class="text-xl font-semibold text-gray-900 mb-2">관리자 계정 생성</h3>
<p class="text-gray-600">시스템 관리자(Root) 계정을 생성해주세요.</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<form @submit.prevent="initializeSystem()" class="space-y-6">
<div>
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-envelope mr-1"></i>관리자 이메일
</label>
<input type="email" id="admin_email" x-model="setupForm.admin_email" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="admin@example.com">
</div>
<div>
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>관리자 비밀번호
</label>
<input type="password" id="admin_password" x-model="setupForm.admin_password" required minlength="6"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="최소 6자 이상">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>비밀번호 확인
</label>
<input type="password" id="confirm_password" x-model="confirmPassword" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="비밀번호를 다시 입력하세요">
</div>
<div>
<label for="admin_full_name" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-user mr-1"></i>관리자 이름 (선택사항)
</label>
<input type="text" id="admin_full_name" x-model="setupForm.admin_full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="시스템 관리자">
</div>
<!-- 주의사항 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<div class="text-sm text-yellow-800">
<p class="font-medium mb-1">주의사항:</p>
<ul class="list-disc list-inside space-y-1">
<li>이 계정은 시스템의 최고 관리자 권한을 가집니다.</li>
<li>안전한 비밀번호를 사용하고 잘 보관해주세요.</li>
<li>설정 완료 후에는 이 페이지에 다시 접근할 수 없습니다.</li>
</ul>
</div>
</div>
</div>
<button type="submit" :disabled="setupLoading || setupForm.admin_password !== confirmPassword"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="setupLoading"></i>
<i class="fas fa-rocket mr-2" x-show="!setupLoading"></i>
<span x-text="setupLoading ? '설정 중...' : '시스템 초기화'"></span>
</button>
</form>
</div>
<!-- 설정 완료 -->
<div x-show="setupComplete" class="bg-white rounded-lg shadow-md p-8 text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">설정이 완료되었습니다!</h3>
<p class="text-gray-600 mb-6">Document Server가 성공적으로 초기화되었습니다.</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="text-sm text-blue-800">
<p class="font-medium mb-2">생성된 관리자 계정:</p>
<p><strong>이메일:</strong> <span x-text="createdAdmin.email"></span></p>
<p><strong>이름:</strong> <span x-text="createdAdmin.full_name"></span></p>
<p><strong>역할:</strong> 시스템 관리자</p>
</div>
</div>
<div class="space-y-3">
<a href="index.html" class="w-full inline-flex justify-center items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
</a>
<button @click="goToLogin()" class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
<i class="fas fa-sign-in-alt mr-2"></i>로그인하기
</button>
</div>
</div>
</div>
</div>
<!-- API 스크립트 -->
<script>
// 간단한 API 클라이언트
const setupApi = {
async get(endpoint) {
const response = await fetch(`/api${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
},
async post(endpoint, data) {
const response = await fetch(`/api${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return await response.json();
}
};
</script>
<!-- 설정 앱 스크립트 -->
<script>
function setupApp() {
return {
loading: true,
setupRequired: false,
setupComplete: false,
setupLoading: false,
confirmPassword: '',
createdAdmin: {},
setupForm: {
admin_email: '',
admin_password: '',
admin_full_name: ''
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔧 설정 앱 초기화');
await this.checkSetupStatus();
},
async checkSetupStatus() {
try {
const status = await setupApi.get('/setup/status');
this.setupRequired = status.is_setup_required;
console.log('✅ 설정 상태 확인 완료:', status);
} catch (error) {
console.error('❌ 설정 상태 확인 실패:', error);
this.showNotification('시스템 상태를 확인할 수 없습니다.', 'error');
} finally {
this.loading = false;
}
},
async initializeSystem() {
if (this.setupForm.admin_password !== this.confirmPassword) {
this.showNotification('비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.setupLoading = true;
try {
const result = await setupApi.post('/setup/initialize', this.setupForm);
this.createdAdmin = result.admin_user;
this.setupComplete = true;
this.setupRequired = false;
console.log('✅ 시스템 초기화 완료:', result);
} catch (error) {
console.error('❌ 시스템 초기화 실패:', error);
this.showNotification(error.message || '시스템 초기화에 실패했습니다.', 'error');
} finally {
this.setupLoading = false;
}
},
goToLogin() {
// 로그인 모달을 열거나 로그인 페이지로 이동
window.location.href = 'index.html';
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
};
}
</script>
</body>
</html>

View File

@@ -1,6 +1,7 @@
/* 메인 스타일 */ /* 메인 스타일 */
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
} }
/* 알림 애니메이션 */ /* 알림 애니메이션 */

View File

@@ -0,0 +1,41 @@
# 로그인 페이지 이미지
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
## 필요한 이미지 파일
### 배경 이미지
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
## 이미지 사양
- **형식**: JPG, PNG 지원
- **품질**: 웹 최적화된 고품질 이미지
- **용량**: 1MB 이하 권장
- **비율**: 16:9 또는 16:10 비율 권장
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
## 폴백 동작
배경 이미지 파일이 없는 경우:
- 파란색-보라색 그라디언트 배경으로 자동 폴백
## 사용 예시
```
static/images/
└── login-bg.jpg (전체 배경)
```
## 배경 이미지 선택 가이드
- **문서/도서관 테마**: 책장, 도서관, 서재 등
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
- **미니멀 테마**: 단순한 패턴, 텍스처
## 변경 사항 (v2.0)
- 갤러리 액자 기능 제거
- 중앙 집중형 로그인 레이아웃으로 변경
- 배경 이미지만 사용하는 심플한 디자인

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

View File

@@ -255,8 +255,8 @@ class DocumentServerAPI {
return await this.get(`/highlights/document/${documentId}`); return await this.get(`/highlights/document/${documentId}`);
} }
async updateHighlight(highlightId, data) { async updateHighlight(highlightId, updateData) {
return await this.put(`/highlights/${highlightId}`, data); return await this.put(`/highlights/${highlightId}`, updateData);
} }
async deleteHighlight(highlightId) { async deleteHighlight(highlightId) {
@@ -266,22 +266,28 @@ class DocumentServerAPI {
// === 하이라이트 메모 (Highlight Memo) 관련 API === // === 하이라이트 메모 (Highlight Memo) 관련 API ===
// 용어 정의: 하이라이트에 달리는 짧은 코멘트 // 용어 정의: 하이라이트에 달리는 짧은 코멘트
async createNote(noteData) { async createNote(noteData) {
return await this.post('/notes/', noteData); return await this.post('/highlight-notes/', noteData);
} }
async getNotes(params = {}) { async getNotes(params = {}) {
return await this.get('/notes/', params); return await this.get('/highlight-notes/', params);
}
async updateNote(noteId, updateData) {
return await this.put(`/highlight-notes/${noteId}`, updateData);
}
async deleteNote(noteId) {
return await this.delete(`/highlight-notes/${noteId}`);
} }
// === 문서 메모 조회 === // === 문서 메모 조회 ===
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회 // 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
async getDocumentNotes(documentId) { async getDocumentNotes(documentId) {
return await this.get(`/notes/document/${documentId}`); return await this.get(`/highlight-notes/`, { document_id: documentId });
} }
async updateNote(noteId, data) {
return await this.put(`/notes/${noteId}`, data);
}
async deleteNote(noteId) { async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`); return await this.delete(`/notes/${noteId}`);
@@ -375,10 +381,6 @@ class DocumentServerAPI {
return await this.post('/notes/', noteData); return await this.post('/notes/', noteData);
} }
async updateNote(noteId, noteData) {
return await this.put(`/notes/${noteId}`, noteData);
}
async deleteNote(noteId) { async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`); return await this.delete(`/notes/${noteId}`);
} }
@@ -500,10 +502,6 @@ class DocumentServerAPI {
return await this.post('/notes/', noteData); return await this.post('/notes/', noteData);
} }
async updateNote(noteId, noteData) {
return await this.put(`/notes/${noteId}`, noteData);
}
async deleteNote(noteId) { async deleteNote(noteId) {
return await this.delete(`/notes/${noteId}`); return await this.delete(`/notes/${noteId}`);
} }
@@ -617,6 +615,11 @@ class DocumentServerAPI {
return await this.get(`/note-documents/${noteId}`); return await this.get(`/note-documents/${noteId}`);
} }
// 특정 노트북의 노트들 조회
async getNotesInNotebook(notebookId) {
return await this.get('/note-documents/', { notebook_id: notebookId });
}
// === 노트 문서 (Note Document) 관련 API === // === 노트 문서 (Note Document) 관련 API ===
// 용어 정의: 독립적인 문서 작성 (HTML 기반) // 용어 정의: 독립적인 문서 작성 (HTML 기반)
async createNoteDocument(noteData) { async createNoteDocument(noteData) {
@@ -694,6 +697,53 @@ class DocumentServerAPI {
async removeNoteFromNotebook(notebookId, noteId) { async removeNoteFromNotebook(notebookId, noteId) {
return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`); return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`);
} }
// ============================================================================
// 할일관리 API
// ============================================================================
// 할일 아이템 관리
async getTodos(status = null) {
const params = status ? `?status=${status}` : '';
return await this.get(`/todos/${params}`);
}
async createTodo(todoData) {
return await this.post('/todos/', todoData);
}
async getTodo(todoId) {
return await this.get(`/todos/${todoId}`);
}
async scheduleTodo(todoId, scheduleData) {
return await this.post(`/todos/${todoId}/schedule`, scheduleData);
}
async splitTodo(todoId, splitData) {
return await this.post(`/todos/${todoId}/split`, splitData);
}
async getActiveTodos() {
return await this.get('/todos/active');
}
async completeTodo(todoId) {
return await this.put(`/todos/${todoId}/complete`);
}
async delayTodo(todoId, delayData) {
return await this.put(`/todos/${todoId}/delay`, delayData);
}
// 댓글 관리
async getTodoComments(todoId) {
return await this.get(`/todos/${todoId}/comments`);
}
async createTodoComment(todoId, commentData) {
return await this.post(`/todos/${todoId}/comments`, commentData);
}
} }
// 전역 API 인스턴스 // 전역 API 인스턴스

View File

@@ -0,0 +1,92 @@
/**
* 인증 가드 - 모든 보호된 페이지에서 사용
* 로그인하지 않은 사용자를 자동으로 로그인 페이지로 리다이렉트
*/
(function() {
'use strict';
// 인증이 필요하지 않은 페이지들
const PUBLIC_PAGES = [
'login.html',
'setup.html'
];
// 현재 페이지가 공개 페이지인지 확인
function isPublicPage() {
const currentPath = window.location.pathname;
return PUBLIC_PAGES.some(page => currentPath.includes(page));
}
// 로그인 페이지로 리다이렉트
function redirectToLogin() {
const currentUrl = encodeURIComponent(window.location.href);
console.log('🔐 인증되지 않은 접근. 로그인 페이지로 이동합니다.');
window.location.href = `login.html?redirect=${currentUrl}`;
}
// 인증 체크 함수
async function checkAuthentication() {
// 공개 페이지는 체크하지 않음
if (isPublicPage()) {
return;
}
const token = localStorage.getItem('access_token');
// 토큰이 없으면 즉시 리다이렉트
if (!token) {
redirectToLogin();
return;
}
try {
// 토큰 유효성 검사
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.log('🔐 토큰이 유효하지 않습니다. 상태:', response.status);
localStorage.removeItem('access_token');
redirectToLogin();
return;
}
// 인증 성공
const user = await response.json();
console.log('✅ 인증 성공:', user.email);
// 전역 사용자 정보 설정
window.currentUser = user;
// 헤더 사용자 메뉴 업데이트
if (typeof window.updateUserMenu === 'function') {
window.updateUserMenu(user);
}
} catch (error) {
console.error('🔐 인증 확인 중 오류:', error);
localStorage.removeItem('access_token');
redirectToLogin();
}
}
// DOM 로드 완료 전에 인증 체크 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkAuthentication);
} else {
checkAuthentication();
}
// 전역 함수로 노출
window.authGuard = {
checkAuthentication,
redirectToLogin,
isPublicPage
};
})();

View File

@@ -38,6 +38,9 @@ window.bookDocumentsApp = () => ({
this.bookId = urlParams.get('book_id') || urlParams.get('bookId'); // 둘 다 지원 this.bookId = urlParams.get('book_id') || urlParams.get('bookId'); // 둘 다 지원
console.log('📖 서적 ID:', this.bookId); console.log('📖 서적 ID:', this.bookId);
console.log('🔍 전체 URL 파라미터:', window.location.search); console.log('🔍 전체 URL 파라미터:', window.location.search);
console.log('🔍 URLSearchParams 객체:', urlParams);
console.log('🔍 book_id 파라미터:', urlParams.get('book_id'));
console.log('🔍 bookId 파라미터:', urlParams.get('bookId'));
}, },
// 인증 상태 확인 // 인증 상태 확인
@@ -166,11 +169,23 @@ window.bookDocumentsApp = () => ({
// 서적 편집 페이지 열기 // 서적 편집 페이지 열기
openBookEditor() { openBookEditor() {
console.log('🔧 서적 편집 버튼 클릭됨');
console.log('📖 현재 bookId:', this.bookId);
console.log('🔍 bookId 타입:', typeof this.bookId);
if (this.bookId === 'none') { if (this.bookId === 'none') {
alert('서적 미분류 문서들은 편집할 수 없습니다.'); alert('서적 미분류 문서들은 편집할 수 없습니다.');
return; return;
} }
window.location.href = `book-editor.html?bookId=${this.bookId}`;
if (!this.bookId) {
alert('서적 ID가 없습니다. 페이지를 새로고침해주세요.');
return;
}
const targetUrl = `book-editor.html?bookId=${this.bookId}`;
console.log('🔗 이동할 URL:', targetUrl);
window.location.href = targetUrl;
}, },
// 문서 수정 // 문서 수정

View File

@@ -90,6 +90,25 @@ window.bookEditorApp = () => ({
console.log('📄 서적 문서들:', this.documents.length, '개'); console.log('📄 서적 문서들:', this.documents.length, '개');
// 각 문서의 PDF 매칭 상태 확인
this.documents.forEach((doc, index) => {
console.log(`📄 문서 ${index + 1}: ${doc.title}`);
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
console.log(` - sort_order: ${doc.sort_order || 'null'}`);
// null 값을 빈 문자열로 변환 (UI 바인딩을 위해)
if (doc.matched_pdf_id === null) {
doc.matched_pdf_id = "";
}
// 디버깅: 실제 값과 타입 확인
console.log(` - matched_pdf_id 타입: ${typeof doc.matched_pdf_id}`);
console.log(` - matched_pdf_id 값: "${doc.matched_pdf_id}"`);
console.log(` - 빈 문자열인가? ${doc.matched_pdf_id === ""}`);
console.log(` - null인가? ${doc.matched_pdf_id === null}`);
console.log(` - undefined인가? ${doc.matched_pdf_id === undefined}`);
});
// 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만) // 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만)
console.log('🔍 현재 서적 ID:', this.bookId); console.log('🔍 현재 서적 ID:', this.bookId);
console.log('🔍 전체 문서 수:', allDocuments.length); console.log('🔍 전체 문서 수:', allDocuments.length);
@@ -112,11 +131,17 @@ window.bookEditorApp = () => ({
console.log('📎 현재 서적의 PDF:', this.availablePDFs.length, '개'); console.log('📎 현재 서적의 PDF:', this.availablePDFs.length, '개');
console.log('📎 현재 서적 PDF 목록:', this.availablePDFs.map(pdf => ({ console.log('📎 현재 서적 PDF 목록:', this.availablePDFs.map(pdf => ({
id: pdf.id,
title: pdf.title, title: pdf.title,
book_id: pdf.book_id, book_id: pdf.book_id,
book_title: pdf.book_title book_title: pdf.book_title
}))); })));
// 각 PDF의 ID 확인
this.availablePDFs.forEach((pdf, index) => {
console.log(`📎 PDF ${index + 1}: ID="${pdf.id}", 제목="${pdf.title}"`);
});
// 디버깅: 다른 서적의 PDF들도 확인 // 디버깅: 다른 서적의 PDF들도 확인
const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId); const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId);
console.log('🔍 다른 서적의 PDF:', otherBookPDFs.length, '개'); console.log('🔍 다른 서적의 PDF:', otherBookPDFs.length, '개');
@@ -128,6 +153,23 @@ window.bookEditorApp = () => ({
}))); })));
} }
// Alpine.js DOM 업데이트 강제 실행
this.$nextTick(() => {
console.log('🔄 Alpine.js DOM 업데이트 완료');
// DOM이 완전히 렌더링된 후 실행
setTimeout(() => {
this.documents.forEach((doc, index) => {
if (doc.matched_pdf_id) {
console.log(`🔧 문서 ${index + 1} 강제 업데이트: ${doc.matched_pdf_id}`);
// Alpine.js 반응성 트리거
const oldValue = doc.matched_pdf_id;
doc.matched_pdf_id = "";
doc.matched_pdf_id = oldValue;
}
});
}, 100);
});
} catch (error) { } catch (error) {
console.error('서적 데이터 로드 실패:', error); console.error('서적 데이터 로드 실패:', error);
this.error = '데이터를 불러오는데 실패했습니다: ' + error.message; this.error = '데이터를 불러오는데 실패했습니다: ' + error.message;
@@ -206,24 +248,36 @@ window.bookEditorApp = () => ({
if (this.saving) return; if (this.saving) return;
this.saving = true; this.saving = true;
console.log('💾 저장 시작...');
try { try {
// 저장 전에 순서 업데이트
this.updateDisplayOrder();
// 서적 정보 업데이트 // 서적 정보 업데이트
console.log('📚 서적 정보 업데이트 중...');
await window.api.updateBook(this.bookId, { await window.api.updateBook(this.bookId, {
title: this.bookInfo.title, title: this.bookInfo.title,
author: this.bookInfo.author, author: this.bookInfo.author,
description: this.bookInfo.description description: this.bookInfo.description
}); });
console.log('✅ 서적 정보 업데이트 완료');
// 각 문서의 순서와 PDF 매칭 정보 업데이트 // 각 문서의 순서와 PDF 매칭 정보 업데이트
const updatePromises = this.documents.map(doc => { console.log('📄 문서 업데이트 시작...');
const updatePromises = this.documents.map((doc, index) => {
console.log(`📄 문서 ${index + 1}/${this.documents.length}: ${doc.title}`);
console.log(` - sort_order: ${doc.sort_order}`);
console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`);
return window.api.updateDocument(doc.id, { return window.api.updateDocument(doc.id, {
sort_order: doc.sort_order, sort_order: doc.sort_order,
matched_pdf_id: doc.matched_pdf_id || null matched_pdf_id: doc.matched_pdf_id === "" || doc.matched_pdf_id === null ? null : doc.matched_pdf_id
}); });
}); });
await Promise.all(updatePromises); const results = await Promise.all(updatePromises);
console.log('✅ 모든 문서 업데이트 완료:', results.length, '개');
console.log('✅ 모든 변경사항 저장 완료'); console.log('✅ 모든 변경사항 저장 완료');
this.showNotification('변경사항이 저장되었습니다', 'success'); this.showNotification('변경사항이 저장되었습니다', 'success');
@@ -234,7 +288,7 @@ window.bookEditorApp = () => ({
}, 1500); }, 1500);
} catch (error) { } catch (error) {
console.error('저장 실패:', error); console.error('저장 실패:', error);
this.showNotification('저장에 실패했습니다: ' + error.message, 'error'); this.showNotification('저장에 실패했습니다: ' + error.message, 'error');
} finally { } finally {
this.saving = false; this.saving = false;

View File

@@ -124,7 +124,11 @@ class HeaderLoader {
'index': 'index-nav-item', 'index': 'index-nav-item',
'hierarchy': 'hierarchy-nav-item', 'hierarchy': 'hierarchy-nav-item',
'memo-tree': 'memo-tree-nav-item', 'memo-tree': 'memo-tree-nav-item',
'story-view': 'story-view-nav-item' 'story-view': 'story-view-nav-item',
'search': 'search-nav-link',
'notes': 'notes-nav-link',
'notebooks': 'notebooks-nav-item',
'note-editor': 'note-editor-nav-item'
}; };
const itemId = pageItemMap[pageInfo.filename]; const itemId = pageItemMap[pageInfo.filename];
@@ -151,9 +155,109 @@ document.addEventListener('headerLoaded', () => {
setTimeout(() => { setTimeout(() => {
window.headerLoader.updateActiveStates(); window.headerLoader.updateActiveStates();
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작) // updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
if (typeof window.updateUserMenu === 'function') { if (typeof window.updateUserMenu === 'undefined') {
window.updateUserMenu(null); window.updateUserMenu = (user) => {
console.log('🔄 updateUserMenu 호출됨:', user);
const loggedInMenu = document.getElementById('logged-in-menu');
const loginButton = document.getElementById('login-button');
const adminMenuSection = document.getElementById('admin-menu-section');
// 사용자 정보 요소들
const userName = document.getElementById('user-name');
const userRole = document.getElementById('user-role');
const dropdownUserName = document.getElementById('dropdown-user-name');
const dropdownUserEmail = document.getElementById('dropdown-user-email');
const dropdownUserRole = document.getElementById('dropdown-user-role');
if (user) {
// 로그인된 상태
console.log('✅ 사용자 로그인 상태 - UI 업데이트');
if (loggedInMenu) {
loggedInMenu.classList.remove('hidden');
console.log('✅ 로그인 메뉴 표시');
}
if (loginButton) {
loginButton.classList.add('hidden');
console.log('✅ 로그인 버튼 숨김');
}
// 사용자 정보 업데이트
const displayName = user.full_name || user.email || 'User';
const roleText = user.role === 'root' ? '시스템 관리자' :
user.role === 'admin' ? '관리자' : '사용자';
if (userName) userName.textContent = displayName;
if (userRole) userRole.textContent = roleText;
if (dropdownUserName) dropdownUserName.textContent = displayName;
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
// 관리자 메뉴 표시/숨김
console.log('🔍 사용자 권한 확인:', {
role: user.role,
is_admin: user.is_admin,
can_manage_books: user.can_manage_books,
can_manage_notes: user.can_manage_notes,
can_manage_novels: user.can_manage_novels
});
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
console.log('✅ 관리자 메뉴 표시');
adminMenuSection.classList.remove('hidden');
} else {
console.log('❌ 관리자 메뉴 숨김');
adminMenuSection.classList.add('hidden');
}
} else {
console.log('❌ adminMenuSection 요소를 찾을 수 없음');
}
} else {
// 로그아웃된 상태
console.log('❌ 로그아웃 상태');
if (loggedInMenu) loggedInMenu.classList.add('hidden');
if (loginButton) loginButton.classList.remove('hidden');
if (adminMenuSection) adminMenuSection.classList.add('hidden');
}
};
console.log('✅ updateUserMenu 함수 정의 완료');
}
// 사용자 메뉴 상태 설정 (현재 로그인 상태 확인)
setTimeout(() => {
// 전역 사용자 정보가 있으면 사용, 없으면 토큰으로 확인
if (window.currentUser) {
window.updateUserMenu(window.currentUser);
} else {
// 토큰이 있으면 사용자 정보 다시 가져오기
const token = localStorage.getItem('access_token');
if (token) {
fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(response => response.ok ? response.json() : null)
.then(user => {
if (user) {
window.currentUser = user;
window.updateUserMenu(user);
} else {
window.updateUserMenu(null);
}
})
.catch(() => window.updateUserMenu(null));
} else {
window.updateUserMenu(null);
}
}
}, 200);
// 전역 함수들이 정의되지 않은 경우 빈 함수로 초기화
if (typeof window.handleLanguageChange === 'undefined') {
window.handleLanguageChange = function(lang) {
console.log('언어 변경 함수가 아직 로드되지 않았습니다:', lang);
};
} }
}, 100); }, 100);
}); });

View File

@@ -65,6 +65,15 @@ window.documentApp = () => ({
this.isAuthenticated = false; this.isAuthenticated = false;
this.currentUser = null; this.currentUser = null;
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
// 로그인 페이지로 리다이렉트 (setup.html 제외)
if (!window.location.pathname.includes('setup.html') &&
!window.location.pathname.includes('login.html')) {
const currentUrl = encodeURIComponent(window.location.href);
window.location.href = `login.html?redirect=${currentUrl}`;
return;
}
this.syncUIState(); // UI 상태 동기화 this.syncUIState(); // UI 상태 동기화
} }
}, },

View File

@@ -23,6 +23,13 @@ window.memoTreeApp = function() {
showLoginModal: false, showLoginModal: false,
showMobileEditModal: false, showMobileEditModal: false,
// 알림 시스템
notification: {
show: false,
message: '',
type: 'info' // 'success', 'error', 'info'
},
// 로그인 폼 상태 // 로그인 폼 상태
loginForm: { loginForm: {
email: '', email: '',
@@ -56,9 +63,6 @@ window.memoTreeApp = function() {
treePanX: 0, treePanX: 0,
treePanY: 0, treePanY: 0,
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑 nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
isDragging: false,
dragNode: null,
dragOffset: { x: 0, y: 0 },
// 로그인 관련 함수들 // 로그인 관련 함수들
openLoginModal() { openLoginModal() {
@@ -283,7 +287,15 @@ window.memoTreeApp = function() {
const node = await window.api.createMemoNode(nodeData); const node = await window.api.createMemoNode(nodeData);
this.treeNodes.push(node); this.treeNodes.push(node);
this.selectNode(node);
// 노드 위치 재계산 (새 노드 추가 후)
this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 루트 노드 생성 완료'); console.log('✅ 루트 노드 생성 완료');
} catch (error) { } catch (error) {
@@ -294,6 +306,11 @@ window.memoTreeApp = function() {
// 노드 선택 // 노드 선택
selectNode(node) { selectNode(node) {
// 현재 팬 값 저장 (위치 변경 방지)
const currentPanX = this.treePanX;
const currentPanY = this.treePanY;
const currentZoom = this.treeZoom;
// 이전 노드 저장 // 이전 노드 저장
if (this.selectedNode && this.isEditorDirty) { if (this.selectedNode && this.isEditorDirty) {
this.saveNode(); this.saveNode();
@@ -316,6 +333,11 @@ window.memoTreeApp = function() {
}, 100); }, 100);
} }
// 팬 값 복원 (위치 변경 방지)
this.treePanX = currentPanX;
this.treePanY = currentPanY;
this.treeZoom = currentZoom;
console.log('📝 노드 선택:', node.title); console.log('📝 노드 선택:', node.title);
}, },
@@ -503,6 +525,20 @@ window.memoTreeApp = function() {
}, },
// 트리 타입별 아이콘 가져오기 // 트리 타입별 아이콘 가져오기
// 알림 표시
showNotification(message, type = 'info') {
this.notification = {
show: true,
message: message,
type: type
};
// 3초 후 자동으로 숨김
setTimeout(() => {
this.notification.show = false;
}, 3000);
},
getTreeIcon(treeType) { getTreeIcon(treeType) {
const icons = { const icons = {
novel: '📚', novel: '📚',
@@ -531,8 +567,14 @@ window.memoTreeApp = function() {
// 부모 노드 펼치기 // 부모 노드 펼치기
this.expandedNodes.add(parentNode.id); this.expandedNodes.add(parentNode.id);
// 새 노드 선택 // 노드 위치 재계산 (새 노드 추가 후)
this.selectNode(node); this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 자식 노드 생성 완료'); console.log('✅ 자식 노드 생성 완료');
} catch (error) { } catch (error) {
@@ -602,12 +644,32 @@ window.memoTreeApp = function() {
} }
}, },
// 노드 드래그 시작
startDragNode(event, node) {
// 현재는 드래그 기능 비활성화 (패닝과 충돌 방지)
event.stopPropagation();
console.log('드래그 시작:', node.title); // 노드를 다른 부모로 이동
// TODO: 노드 드래그 앤 드롭 구현 async moveNodeToParent(nodeId, newParentId) {
try {
console.log(`📦 노드 이동: ${nodeId} -> 부모: ${newParentId}`);
const moveData = {
parent_id: newParentId,
sort_order: 0 // 새 부모의 첫 번째 자식으로
};
await window.api.moveMemoNode(nodeId, moveData);
// 트리 다시 로드
await this.loadTreeNodes();
console.log('✅ 노드 이동 완료');
this.showNotification('노드가 이동되었습니다.', 'success');
} catch (error) {
console.error('❌ 노드 이동 실패:', error);
this.showNotification('노드 이동에 실패했습니다.', 'error');
}
}, },
// 노드 인라인 편집 // 노드 인라인 편집
@@ -628,15 +690,12 @@ window.memoTreeApp = function() {
// 노드 위치 계산 및 반환 // 노드 위치 계산 및 반환
getNodePosition(node) { getNodePosition(node) {
if (!this.nodePositions.has(node.id)) { // 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
this.calculateNodePositions();
}
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 }; const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
return `left: ${pos.x}px; top: ${pos.y}px;`; return `left: ${pos.x}px; top: ${pos.y}px;`;
}, },
// 트리 노드 위치 자동 계산 // 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
calculateNodePositions() { calculateNodePositions() {
const canvas = document.getElementById('tree-canvas'); const canvas = document.getElementById('tree-canvas');
if (!canvas) return; if (!canvas) return;
@@ -647,11 +706,11 @@ window.memoTreeApp = function() {
// 노드 크기 설정 // 노드 크기 설정
const nodeWidth = 200; const nodeWidth = 200;
const nodeHeight = 80; const nodeHeight = 80;
const levelHeight = 150; // 레벨 간 간격 const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
const nodeSpacing = 50; // 노드 간 간격 const nodeSpacing = 100; // 노드 간 세로 간격
const margin = 100; // 여백 const margin = 100; // 여백
// 레벨별 노드 그룹화 // 레벨별 노드 그룹화 (가로 방향)
const levels = new Map(); const levels = new Map();
// 루트 노드들 찾기 // 루트 노드들 찾기
@@ -659,7 +718,7 @@ window.memoTreeApp = function() {
if (rootNodes.length === 0) return; if (rootNodes.length === 0) return;
// BFS로 레벨별 노드 배치 // BFS로 레벨별 노드 배치 (가로 방향)
const queue = []; const queue = [];
rootNodes.forEach(node => { rootNodes.forEach(node => {
queue.push({ node, level: 0 }); queue.push({ node, level: 0 });
@@ -680,25 +739,25 @@ window.memoTreeApp = function() {
}); });
} }
// 트리 전체 크기 계산 // 트리 전체 크기 계산 (가로 방향)
const maxLevel = Math.max(...levels.keys()); const maxLevel = Math.max(...levels.keys());
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length)); const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing; const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
const treeHeight = (maxLevel + 1) * levelHeight; const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산 // 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2); const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2); const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
// 각 레벨의 노드들 위치 계산 // 각 레벨의 노드들 위치 계산 (가로 방향)
levels.forEach((nodes, level) => { levels.forEach((nodes, level) => {
const y = offsetY + level * levelHeight; const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing; const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
const startX = offsetX + (treeWidth - levelWidth) / 2; const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
nodes.forEach((node, index) => { nodes.forEach((node, index) => {
const x = startX + index * (nodeWidth + nodeSpacing); const y = startY + index * (nodeHeight + nodeSpacing);
this.nodePositions.set(node.id, { x, y }); this.nodePositions.set(node.id, { x, y });
}); });
}); });
@@ -738,17 +797,17 @@ window.memoTreeApp = function() {
// 연결선 생성 // 연결선 생성
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// 부모 노드 하단 중앙에서 시작 // 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
const startX = parentPos.x + 100; // 노드 중앙 const startX = parentPos.x + 200; // 노드 오른쪽 끝
const startY = parentPos.y + 80; // 노드 하단 const startY = parentPos.y + 40; // 노드 세로 중앙
// 자식 노드 상단 중앙으로 연결 // 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
const endX = childPos.x + 100; // 노드 중앙 const endX = childPos.x; // 노드 왼쪽 끝
const endY = childPos.y; // 노드 상단 const endY = childPos.y + 40; // 노드 세로 중앙
// 곡선 경로 생성 (베지어 곡선) // 곡선 경로 생성 (베지어 곡선, 가로 방향)
const midY = startY + (endY - startY) / 2; const midX = startX + (endX - startX) / 2;
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`; const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
line.setAttribute('d', path); line.setAttribute('d', path);
line.setAttribute('stroke', '#9CA3AF'); line.setAttribute('stroke', '#9CA3AF');

View File

@@ -54,13 +54,21 @@ function noteEditorApp() {
return; return;
} }
// URL에서 노트 ID 확인 (편집 모드) // URL에서 노트 ID 및 노트북 정보 확인
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.noteId = urlParams.get('id'); this.noteId = urlParams.get('id');
const notebookId = urlParams.get('notebook_id');
const notebookName = urlParams.get('notebook_name');
// 노트북 목록 로드 // 노트북 목록 로드
await this.loadNotebooks(); await this.loadNotebooks();
// URL에서 노트북이 지정된 경우 자동 설정
if (notebookId && !this.noteId) { // 새 노트 생성 시에만
this.noteData.notebook_id = notebookId;
console.log('📚 노트북 자동 설정:', notebookName || notebookId);
}
if (this.noteId) { if (this.noteId) {
this.isEditing = true; this.isEditing = true;
await this.loadNote(this.noteId); await this.loadNote(this.noteId);
@@ -119,6 +127,14 @@ function noteEditorApp() {
// 임시: 직접 API 호출 // 임시: 직접 API 호출
this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true }); this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true });
console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개'); console.log('📚 노트북 로드됨:', this.availableNotebooks.length, '개');
console.log('📚 노트북 데이터 상세:', this.availableNotebooks);
// 각 노트북의 필드 확인
if (this.availableNotebooks.length > 0) {
console.log('📚 첫 번째 노트북 필드:', Object.keys(this.availableNotebooks[0]));
console.log('📚 첫 번째 노트북 title:', this.availableNotebooks[0].title);
console.log('📚 첫 번째 노트북 name:', this.availableNotebooks[0].name);
}
} catch (error) { } catch (error) {
console.error('노트북 로드 실패:', error); console.error('노트북 로드 실패:', error);
this.availableNotebooks = []; this.availableNotebooks = [];

View File

@@ -7,6 +7,13 @@ window.notebooksApp = () => ({
saving: false, saving: false,
error: '', error: '',
// 알림 시스템
notification: {
show: false,
message: '',
type: 'info' // 'success', 'error', 'info'
},
// 필터링 // 필터링
searchQuery: '', searchQuery: '',
activeOnly: true, activeOnly: true,
@@ -18,7 +25,10 @@ window.notebooksApp = () => ({
// 모달 상태 // 모달 상태
showCreateModal: false, showCreateModal: false,
showEditModal: false, showEditModal: false,
showDeleteModal: false,
editingNotebook: null, editingNotebook: null,
deletingNotebook: null,
deleting: false,
// 노트북 폼 // 노트북 폼
notebookForm: { notebookForm: {
@@ -168,7 +178,12 @@ window.notebooksApp = () => ({
// 노트북 열기 (노트 목록으로 이동) // 노트북 열기 (노트 목록으로 이동)
openNotebook(notebook) { openNotebook(notebook) {
window.location.href = `/notes.html?notebook_id=${notebook.id}&notebook_name=${encodeURIComponent(notebook.name)}`; window.location.href = `/notes.html?notebook_id=${notebook.id}&notebook_name=${encodeURIComponent(notebook.title)}`;
},
// 노트북에 노트 생성
createNoteInNotebook(notebook) {
window.location.href = `/note-editor.html?notebook_id=${notebook.id}&notebook_name=${encodeURIComponent(notebook.title)}`;
}, },
// 노트북 편집 // 노트북 편집
@@ -185,22 +200,37 @@ window.notebooksApp = () => ({
this.showEditModal = true; this.showEditModal = true;
}, },
// 노트북 삭제 // 노트북 삭제 (모달 표시)
async deleteNotebook(notebook) { deleteNotebook(notebook) {
if (!confirm(`"${notebook.title}" 노트북을 삭제하시겠습니까?\n\n${notebook.note_count > 0 ? `포함된 ${notebook.note_count}개의 노트는 미분류 상태가 됩니다.` : ''}`)) { this.deletingNotebook = notebook;
return; this.showDeleteModal = true;
} },
// 삭제 확인
async confirmDeleteNotebook() {
if (!this.deletingNotebook) return;
this.deleting = true;
try { try {
await this.api.deleteNotebook(notebook.id, true); // force=true await this.api.deleteNotebook(this.deletingNotebook.id, true); // force=true
this.showNotification('노트북이 삭제되었습니다.', 'success'); this.showNotification('노트북이 삭제되었습니다.', 'success');
this.closeDeleteModal();
await this.refreshNotebooks(); await this.refreshNotebooks();
} catch (error) { } catch (error) {
console.error('노트북 삭제 실패:', error); console.error('노트북 삭제 실패:', error);
this.showNotification('노트북 삭제에 실패했습니다.', 'error'); this.showNotification('노트북 삭제에 실패했습니다.', 'error');
} finally {
this.deleting = false;
} }
}, },
// 삭제 모달 닫기
closeDeleteModal() {
this.showDeleteModal = false;
this.deletingNotebook = null;
this.deleting = false;
},
// 노트북 저장 // 노트북 저장
async saveNotebook() { async saveNotebook() {
if (!this.notebookForm.title.trim()) { if (!this.notebookForm.title.trim()) {
@@ -266,12 +296,15 @@ window.notebooksApp = () => ({
// 알림 표시 // 알림 표시
showNotification(message, type = 'info') { showNotification(message, type = 'info') {
if (type === 'error') { this.notification = {
alert('❌ ' + message); show: true,
} else if (type === 'success') { message: message,
alert('✅ ' + message); type: type
} else { };
alert(' ' + message);
} // 3초 후 자동으로 숨김
setTimeout(() => {
this.notification.show = false;
}, 3000);
} }
}); });

View File

@@ -11,6 +11,14 @@ window.pdfManagerApp = () => ({
isAuthenticated: false, isAuthenticated: false,
currentUser: null, currentUser: null,
// PDF 미리보기 상태
showPreviewModal: false,
previewPdf: null,
pdfPreviewSrc: '',
pdfPreviewLoading: false,
pdfPreviewError: false,
pdfPreviewLoaded: false,
// 초기화 // 초기화
async init() { async init() {
console.log('🚀 PDF Manager App 초기화 시작'); console.log('🚀 PDF Manager App 초기화 시작');
@@ -213,6 +221,56 @@ window.pdfManagerApp = () => ({
} }
}, },
// ==================== PDF 미리보기 관련 ====================
async previewPDF(pdf) {
console.log('👁️ PDF 미리보기:', pdf.title);
this.previewPdf = pdf;
this.showPreviewModal = true;
this.pdfPreviewLoading = true;
this.pdfPreviewError = false;
this.pdfPreviewLoaded = false;
try {
const token = localStorage.getItem('access_token');
if (!token || token === 'null' || token === null) {
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
// PDF 미리보기 URL 설정
this.pdfPreviewSrc = `/api/documents/${pdf.id}/pdf?_token=${encodeURIComponent(token)}`;
console.log('✅ PDF 미리보기 준비 완료:', this.pdfPreviewSrc);
} catch (error) {
console.error('❌ PDF 미리보기 로드 실패:', error);
this.pdfPreviewError = true;
this.showNotification('PDF 미리보기 로드에 실패했습니다: ' + error.message, 'error');
} finally {
this.pdfPreviewLoading = false;
}
},
closePreview() {
this.showPreviewModal = false;
this.previewPdf = null;
this.pdfPreviewSrc = '';
this.pdfPreviewLoading = false;
this.pdfPreviewError = false;
this.pdfPreviewLoaded = false;
},
handlePdfPreviewError() {
console.error('❌ PDF 미리보기 iframe 로드 오류');
this.pdfPreviewError = true;
this.pdfPreviewLoading = false;
},
async retryPdfPreview() {
if (this.previewPdf) {
await this.previewPDF(this.previewPdf);
}
},
// 날짜 포맷팅 // 날짜 포맷팅
formatDate(dateString) { formatDate(dateString) {
if (!dateString) return ''; if (!dateString) return '';

View File

@@ -0,0 +1,692 @@
/**
* 통합 검색 JavaScript
*/
// 검색 애플리케이션 Alpine.js 컴포넌트
window.searchApp = function() {
return {
// 상태 관리
searchQuery: '',
searchResults: [],
filteredResults: [],
loading: false,
hasSearched: false,
searchTime: 0,
// 필터링
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
fileTypeFilter: '', // '', 'PDF', 'HTML'
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
// 검색 디바운스
searchTimeout: null,
// 미리보기 모달
showPreviewModal: false,
previewResult: null,
previewLoading: false,
pdfError: false,
pdfLoading: false,
pdfLoaded: false,
pdfSrc: '',
// HTML 뷰어 상태
htmlLoading: false,
htmlRawMode: false,
htmlSourceCode: '',
// 인증 상태
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
// 초기화
async init() {
console.log('🔍 검색 앱 초기화 시작');
try {
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
// 헤더 로드
await this.loadHeader();
// 인증 상태 확인
await this.checkAuthStatus();
// URL 파라미터에서 검색어 확인
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (query) {
this.searchQuery = query;
await this.performSearch();
}
console.log('✅ 검색 앱 초기화 완료');
} catch (error) {
console.error('❌ 검색 앱 초기화 실패:', error);
}
},
// 인증 상태 확인
async checkAuthStatus() {
try {
const user = await this.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
console.log('✅ 인증됨:', user.username || user.email);
} catch (error) {
console.log('❌ 인증되지 않음');
this.isAuthenticated = false;
this.currentUser = null;
// 검색은 로그인 없이도 가능하도록 허용
}
},
// 헤더 로드
async loadHeader() {
try {
if (typeof loadHeaderComponent === 'function') {
await loadHeaderComponent();
} else if (typeof window.loadHeaderComponent === 'function') {
await window.loadHeaderComponent();
} else {
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
}
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// 검색 디바운스
debounceSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
if (this.searchQuery.trim()) {
this.performSearch();
}
}, 500);
},
// 검색 수행
async performSearch() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = false;
return;
}
this.loading = true;
const startTime = Date.now();
try {
console.log('🔍 검색 시작:', this.searchQuery);
// 검색 API 호출
const response = await this.api.search({
q: this.searchQuery,
type_filter: this.typeFilter || undefined,
limit: 50
});
this.searchResults = response.results || [];
this.hasSearched = true;
this.searchTime = Date.now() - startTime;
// 필터 적용
this.applyFilters();
// URL 업데이트
this.updateURL();
console.log('✅ 검색 완료:', this.searchResults.length, '개 결과');
} catch (error) {
console.error('❌ 검색 실패:', error);
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = true;
} finally {
this.loading = false;
}
},
// 필터 적용
applyFilters() {
let results = [...this.searchResults];
// 중복 ID 제거 (같은 문서의 document와 document_content가 중복될 수 있음)
const uniqueResults = [];
const seenIds = new Set();
results.forEach(result => {
const uniqueKey = `${result.type}-${result.id}`;
if (!seenIds.has(uniqueKey)) {
seenIds.add(uniqueKey);
uniqueResults.push({
...result,
unique_id: uniqueKey // Alpine.js x-for 키로 사용
});
}
});
results = uniqueResults;
// 타입 필터
if (this.typeFilter) {
results = results.filter(result => {
// 문서 타입은 document와 document_content 모두 포함
if (this.typeFilter === 'document') {
return result.type === 'document' || result.type === 'document_content';
}
// 하이라이트 타입은 highlight와 highlight_note 모두 포함
if (this.typeFilter === 'highlight') {
return result.type === 'highlight' || result.type === 'highlight_note';
}
return result.type === this.typeFilter;
});
}
// 파일 타입 필터
if (this.fileTypeFilter) {
results = results.filter(result => {
return result.highlight_info?.file_type === this.fileTypeFilter;
});
}
// 정렬
results.sort((a, b) => {
switch (this.sortBy) {
case 'relevance':
return (b.relevance_score || 0) - (a.relevance_score || 0);
case 'date_desc':
return new Date(b.created_at) - new Date(a.created_at);
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'title':
return a.title.localeCompare(b.title);
default:
return 0;
}
});
this.filteredResults = results;
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
},
// URL 업데이트
updateURL() {
const url = new URL(window.location);
if (this.searchQuery.trim()) {
url.searchParams.set('q', this.searchQuery);
} else {
url.searchParams.delete('q');
}
window.history.replaceState({}, '', url);
},
// 미리보기 표시
async showPreview(result) {
console.log('👁️ 미리보기 표시:', result);
this.previewResult = result;
this.showPreviewModal = true;
this.previewLoading = true;
try {
// 문서 타입인 경우 상세 정보 먼저 로드
if (result.type === 'document' || result.type === 'document_content') {
try {
const docInfo = await this.api.get(`/documents/${result.document_id}`);
// PDF 정보 업데이트
this.previewResult = {
...result,
highlight_info: {
...result.highlight_info,
has_pdf: !!docInfo.pdf_path,
has_html: !!docInfo.html_path
}
};
// PDF가 있으면 PDF 미리보기, 없으면 HTML 미리보기
if (docInfo.pdf_path) {
// PDF 미리보기 준비
await this.loadPdfPreview(result.document_id);
} else if (docInfo.html_path) {
// HTML 문서 미리보기
await this.loadHtmlPreview(result.document_id);
}
} catch (docError) {
console.error('문서 정보 로드 실패:', docError);
// 기본 내용 로드로 fallback
const fullContent = await this.loadFullContent(result);
if (fullContent) {
this.previewResult = { ...result, content: fullContent };
}
}
} else {
// 기타 타입 - 전체 내용 로드
const fullContent = await this.loadFullContent(result);
if (fullContent) {
this.previewResult = { ...result, content: fullContent };
}
}
} catch (error) {
console.error('미리보기 로드 실패:', error);
} finally {
this.previewLoading = false;
}
},
// 전체 내용 로드
async loadFullContent(result) {
try {
let content = '';
switch (result.type) {
case 'document':
case 'document_content':
try {
// 문서 내용 API 호출 (HTML 응답)
const response = await fetch(`/api/documents/${result.document_id}/content`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const htmlContent = await response.text();
// HTML에서 텍스트만 추출
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
content = doc.body.textContent || doc.body.innerText || '';
// 너무 길면 자르기
if (content.length > 2000) {
content = content.substring(0, 2000) + '...';
}
} else {
content = result.content;
}
} catch (err) {
console.warn('문서 내용 로드 실패, 기본 내용 사용:', err);
content = result.content;
}
break;
case 'note':
try {
// 노트 내용 API 호출
const noteContent = await this.api.get(`/note-documents/${result.id}/content`);
content = noteContent;
} catch (err) {
console.warn('노트 내용 로드 실패, 기본 내용 사용:', err);
content = result.content;
}
break;
case 'memo':
try {
// 메모 노드 상세 정보 로드
const memoNode = await this.api.get(`/memo-trees/nodes/${result.id}`);
content = memoNode.content || result.content;
} catch (err) {
console.warn('메모 내용 로드 실패, 기본 내용 사용:', err);
content = result.content;
}
break;
default:
content = result.content;
}
return content;
} catch (error) {
console.error('내용 로드 실패:', error);
return result.content;
}
},
// 미리보기 닫기
closePreview() {
this.showPreviewModal = false;
this.previewResult = null;
this.previewLoading = false;
this.pdfError = false;
// PDF 리소스 정리
this.pdfLoading = false;
this.pdfLoaded = false;
this.pdfSrc = '';
// HTML 리소스 정리
this.htmlLoading = false;
this.htmlRawMode = false;
this.htmlSourceCode = '';
},
// PDF 미리보기 로드
async loadPdfPreview(documentId) {
this.pdfLoading = true;
this.pdfError = false;
this.pdfLoaded = false;
try {
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
const token = localStorage.getItem('access_token');
console.log('🔍 토큰 디버깅:', {
token: token,
tokenType: typeof token,
tokenLength: token ? token.length : 0,
isNull: token === null,
isStringNull: token === 'null',
localStorage: Object.keys(localStorage)
});
if (!token || token === 'null' || token === null) {
console.error('❌ 토큰 문제:', token);
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
} catch (error) {
console.error('PDF 미리보기 로드 실패:', error);
this.pdfError = true;
} finally {
this.pdfLoading = false;
}
},
// PDF 에러 처리
handlePdfError() {
console.error('PDF iframe 로드 오류');
this.pdfError = true;
this.pdfLoading = false;
},
// PDF에서 검색어 찾기 (브라우저 내장 검색 활용)
searchInPdf() {
if (this.searchQuery && this.pdfLoaded) {
// iframe 내에서 검색 실행 (Ctrl+F 시뮬레이션)
const iframe = document.querySelector('#pdf-preview-iframe');
if (iframe && iframe.contentWindow) {
try {
iframe.contentWindow.focus();
// 브라우저 검색 창 열기 시도
if (iframe.contentWindow.find) {
iframe.contentWindow.find(this.searchQuery);
} else {
// 대안: 사용자에게 수동 검색 안내
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
}
} catch (e) {
// 보안상 직접 접근이 안 되는 경우, 사용자에게 안내
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
}
}
}
},
// 알림 표시 (간단한 토스트)
showNotification(message, type = 'info') {
// 간단한 알림 구현 (실제로는 더 정교한 토스트 시스템을 사용할 수 있음)
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
type === 'info' ? 'bg-blue-500' :
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-gray-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3초 후 자동 제거
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
},
// HTML 미리보기 로드
async loadHtmlPreview(documentId) {
this.htmlLoading = true;
try {
// API를 통해 HTML 내용 가져오기
const htmlContent = await this.api.get(`/documents/${documentId}/content`);
if (htmlContent) {
this.htmlSourceCode = this.escapeHtml(htmlContent);
// iframe에 HTML 로드
const iframe = document.getElementById('htmlPreviewFrame');
if (iframe) {
// iframe src를 직접 설정 (인증 헤더 포함)
const token = localStorage.getItem('access_token');
console.log('🔍 HTML 미리보기 토큰:', token ? '있음' : '없음', token);
if (!token || token === 'null' || token === null) {
console.error('❌ HTML 미리보기 토큰 문제:', token);
throw new Error('인증 토큰이 없습니다.');
}
iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
// iframe 로드 완료 후 검색어 하이라이트
iframe.onload = () => {
if (this.searchQuery) {
setTimeout(() => {
this.highlightInIframe(iframe, this.searchQuery);
}, 100);
}
};
}
} else {
throw new Error('HTML 내용이 비어있습니다');
}
} catch (error) {
console.error('HTML 미리보기 로드 실패:', error);
// 에러 시 기본 내용 표시
this.htmlSourceCode = `<div class="p-4 text-center text-gray-500">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<p>HTML 내용을 로드할 수 없습니다.</p>
<p class="text-sm">${error.message}</p>
</div>`;
} finally {
this.htmlLoading = false;
}
},
// HTML 소스/렌더링 모드 토글
toggleHtmlRaw() {
this.htmlRawMode = !this.htmlRawMode;
},
// iframe 내부 검색어 하이라이트
highlightInIframe(iframe, query) {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
const walker = doc.createTreeWalker(
doc.body,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
if (node.textContent.toLowerCase().includes(query.toLowerCase())) {
textNodes.push(node);
}
}
textNodes.forEach(textNode => {
const parent = textNode.parentNode;
const text = textNode.textContent;
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
const highlightedHTML = text.replace(regex, '<mark style="background: yellow; color: black;">$1</mark>');
const wrapper = doc.createElement('span');
wrapper.innerHTML = highlightedHTML;
parent.replaceChild(wrapper, textNode);
});
} catch (error) {
console.error('iframe 하이라이트 실패:', error);
}
},
// HTML 이스케이프
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// 노트 편집기에서 열기
toggleNoteEdit() {
if (this.previewResult && this.previewResult.type === 'note') {
const url = `/note-editor.html?id=${this.previewResult.id}`;
window.open(url, '_blank');
}
},
// PDF에서 검색
async searchInPdf() {
if (!this.previewResult || !this.searchQuery) return;
try {
const searchResults = await this.api.get(
`/documents/${this.previewResult.document_id}/search-in-content?q=${encodeURIComponent(this.searchQuery)}`
);
if (searchResults.total_matches > 0) {
// 첫 번째 매치로 이동하여 뷰어에서 열기
const firstMatch = searchResults.matches[0];
let url = `/viewer.html?id=${this.previewResult.document_id}`;
if (firstMatch.page > 1) {
url += `&page=${firstMatch.page}`;
}
// 검색어 하이라이트를 위한 파라미터 추가
url += `&search=${encodeURIComponent(this.searchQuery)}`;
window.open(url, '_blank');
this.closePreview();
} else {
alert('PDF에서 검색 결과를 찾을 수 없습니다.');
}
} catch (error) {
console.error('PDF 검색 실패:', error);
alert('PDF 검색 중 오류가 발생했습니다.');
}
},
// 검색 결과 열기
openResult(result) {
console.log('📂 검색 결과 열기:', result);
let url = '';
switch (result.type) {
case 'document':
case 'document_content':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
// 하이라이트 위치로 이동
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
case 'note':
url = `/viewer.html?id=${result.id}&contentType=note`;
break;
case 'memo':
// 메모 트리에서 해당 노드로 이동
url = `/memo-tree.html?node_id=${result.id}`;
break;
case 'highlight':
case 'highlight_note':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
default:
console.warn('알 수 없는 결과 타입:', result.type);
return;
}
// 새 탭에서 열기
window.open(url, '_blank');
},
// 타입별 결과 개수
getResultCount(type) {
return this.searchResults.filter(result => result.type === type).length;
},
// 타입 라벨
getTypeLabel(type) {
const labels = {
document: '문서',
document_content: '본문',
note: '노트',
memo: '메모',
highlight: '하이라이트',
highlight_note: '메모'
};
return labels[type] || type;
},
// 텍스트 하이라이트
highlightText(text, query) {
if (!text || !query) return text;
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
return text.replace(regex, '<span class="highlight-text">$1</span>');
},
// 정규식 이스케이프
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
// 텍스트 자르기
truncateText(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return '오늘';
} else if (diffDays === 2) {
return '어제';
} else if (diffDays <= 7) {
return `${diffDays - 1}일 전`;
} else if (diffDays <= 30) {
return `${Math.ceil(diffDays / 7)}주 전`;
} else if (diffDays <= 365) {
return `${Math.ceil(diffDays / 30)}개월 전`;
} else {
return date.toLocaleDateString('ko-KR');
}
}
};
};
console.log('🔍 검색 JavaScript 로드 완료');

View File

@@ -17,7 +17,10 @@ window.storyViewApp = function() {
// UI 상태 // UI 상태
showLoginModal: false, showLoginModal: false,
showEditModal: false, showEditModal: false,
editingNode: null, editingNode: {
title: '',
content: ''
},
// 로그인 폼 상태 // 로그인 폼 상태
loginForm: { loginForm: {
@@ -79,11 +82,22 @@ window.storyViewApp = function() {
async loadUserTrees() { async loadUserTrees() {
try { try {
console.log('📊 사용자 트리 목록 로딩...'); console.log('📊 사용자 트리 목록 로딩...');
console.log('🔍 API 객체 확인:', window.api);
console.log('🔍 getUserMemoTrees 함수 확인:', typeof window.api?.getUserMemoTrees);
const trees = await window.api.getUserMemoTrees(); const trees = await window.api.getUserMemoTrees();
this.userTrees = trees || []; this.userTrees = trees || [];
console.log(`${this.userTrees.length}개 트리 로드 완료`); console.log(`${this.userTrees.length}개 트리 로드 완료`);
console.log('📋 트리 목록:', this.userTrees);
// Alpine.js 반응성 업데이트를 위한 약간의 지연 후 URL 파라미터 확인
setTimeout(() => {
this.checkUrlParams();
}, 100);
} catch (error) { } catch (error) {
console.error('❌ 트리 목록 로드 실패:', error); console.error('❌ 트리 목록 로드 실패:', error);
console.error('❌ 에러 상세:', error.message);
console.error('❌ 에러 스택:', error.stack);
this.userTrees = []; this.userTrees = [];
} }
}, },

728
frontend/static/js/todos.js Normal file
View File

@@ -0,0 +1,728 @@
/**
* 할일관리 애플리케이션
*/
console.log('📋 할일관리 JavaScript 로드 완료');
function todosApp() {
return {
// 상태 관리
loading: false,
activeTab: 'todo', // draft, todo, completed
// 할일 데이터
todos: [],
stats: {
total_count: 0,
draft_count: 0,
scheduled_count: 0,
active_count: 0,
completed_count: 0,
delayed_count: 0,
completion_rate: 0
},
// 입력 폼
newTodoContent: '',
// 모달 상태
showScheduleModal: false,
showDelayModal: false,
showCommentModal: false,
showSplitModal: false,
// 현재 선택된 할일
currentTodo: null,
currentTodoComments: [],
// 메모 상태 (각 할일별)
todoMemos: {},
showMemoForTodo: {},
// 폼 데이터
scheduleForm: {
start_date: '',
estimated_minutes: 30
},
delayForm: {
delayed_until: ''
},
commentForm: {
content: ''
},
splitForm: {
subtasks: ['', ''],
estimated_minutes_per_task: [30, 30]
},
// 계산된 속성들
get draftTodos() {
return this.todos.filter(todo => todo.status === 'draft');
},
get activeTodos() {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return this.todos.filter(todo => {
// active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우
if (todo.status === 'active') return true;
if (todo.status === 'scheduled' && todo.start_date) {
const startDate = new Date(todo.start_date);
const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
return startDay <= today;
}
return false;
});
},
get scheduledTodos() {
return this.todos.filter(todo => todo.status === 'scheduled');
},
get completedTodos() {
return this.todos.filter(todo => todo.status === 'completed');
},
// 초기화
async init() {
console.log('📋 할일관리 초기화 중...');
// API 로드 대기
let retryCount = 0;
while (!window.api && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.api) {
console.error('❌ API가 로드되지 않았습니다.');
return;
}
await this.loadTodos();
await this.loadStats();
await this.loadAllTodoMemos();
// 주기적으로 활성 할일 업데이트 (1분마다)
setInterval(() => {
this.loadActiveTodos();
}, 60000);
},
// 할일 목록 로드
async loadTodos() {
try {
this.loading = true;
const response = await window.api.get('/todos/');
this.todos = response || [];
console.log(`${this.todos.length}개 할일 로드 완료`);
} catch (error) {
console.error('❌ 할일 목록 로드 실패:', error);
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
} finally {
this.loading = false;
}
},
// 활성 할일 로드 (시간 체크 포함)
async loadActiveTodos() {
try {
const response = await window.api.get('/todos/active');
const activeTodos = response || [];
// 기존 todos에서 active 상태 업데이트
this.todos = this.todos.map(todo => {
const activeVersion = activeTodos.find(active => active.id === todo.id);
return activeVersion || todo;
});
// 새로 활성화된 할일들 추가
activeTodos.forEach(activeTodo => {
if (!this.todos.find(todo => todo.id === activeTodo.id)) {
this.todos.push(activeTodo);
}
});
await this.loadStats();
} catch (error) {
console.error('❌ 활성 할일 로드 실패:', error);
}
},
// 통계 로드
async loadStats() {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const stats = {
total_count: this.todos.length,
draft_count: this.todos.filter(t => t.status === 'draft').length,
todo_count: this.todos.filter(t => {
// active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우
if (t.status === 'active') return true;
if (t.status === 'scheduled' && t.start_date) {
const startDate = new Date(t.start_date);
const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
return startDay <= today;
}
return false;
}).length,
completed_count: this.todos.filter(t => t.status === 'completed').length
};
stats.completion_rate = stats.total_count > 0
? Math.round((stats.completed_count / stats.total_count) * 100)
: 0;
this.stats = stats;
},
// 새 할일 생성
async createTodo() {
if (!this.newTodoContent.trim()) return;
try {
this.loading = true;
const response = await window.api.post('/todos/', {
content: this.newTodoContent.trim()
});
this.todos.unshift(response);
// 새 할일의 메모 상태 초기화
this.todoMemos[response.id] = [];
this.showMemoForTodo[response.id] = false;
this.newTodoContent = '';
await this.loadStats();
console.log('✅ 새 할일 생성 완료');
// 검토필요 탭으로 이동
this.activeTab = 'draft';
} catch (error) {
console.error('❌ 할일 생성 실패:', error);
alert('할일 생성 중 오류가 발생했습니다.');
} finally {
this.loading = false;
}
},
// 일정 설정 모달 열기
openScheduleModal(todo) {
this.currentTodo = todo;
// 기존 값이 있으면 사용, 없으면 기본값
this.scheduleForm = {
start_date: todo.start_date ?
new Date(todo.start_date).toISOString().slice(0, 10) :
this.formatDateLocal(new Date()),
estimated_minutes: todo.estimated_minutes || 30
};
this.showScheduleModal = true;
},
// 일정 설정 모달 닫기
closeScheduleModal() {
this.showScheduleModal = false;
this.currentTodo = null;
},
// 할일 일정 설정
async scheduleTodo() {
if (!this.currentTodo || !this.scheduleForm.start_date) return;
try {
// 선택한 날짜의 총 시간 체크
const selectedDate = this.scheduleForm.start_date;
const newMinutes = parseInt(this.scheduleForm.estimated_minutes);
// 해당 날짜의 기존 할일들 시간 합계
const existingMinutes = this.todos
.filter(todo => {
if (!todo.start_date || todo.id === this.currentTodo.id) return false;
const todoDate = new Date(todo.start_date).toISOString().slice(0, 10);
return todoDate === selectedDate && (todo.status === 'scheduled' || todo.status === 'active');
})
.reduce((sum, todo) => sum + (todo.estimated_minutes || 0), 0);
const totalMinutes = existingMinutes + newMinutes;
const totalHours = Math.round(totalMinutes / 60 * 10) / 10;
// 8시간 초과 시 경고
if (totalMinutes > 480) { // 8시간 = 480분
const choice = await this.showOverworkWarning(selectedDate, totalHours);
if (choice === 'cancel') return;
if (choice === 'change') {
// 모달을 닫지 않고 사용자가 다시 선택할 수 있도록 함
return;
}
// choice === 'continue'인 경우 계속 진행
}
// 날짜만 사용하여 해당 날짜의 시작 시간으로 설정
const startDate = new Date(this.scheduleForm.start_date + 'T00:00:00');
let response;
// 이미 일정이 설정된 할일인지 확인
if (this.currentTodo.status === 'draft') {
// 새로 일정 설정
response = await window.api.post(`/todos/${this.currentTodo.id}/schedule`, {
start_date: startDate.toISOString(),
estimated_minutes: newMinutes
});
} else {
// 기존 일정 지연 (active 상태의 할일)
response = await window.api.put(`/todos/${this.currentTodo.id}/delay`, {
delayed_until: startDate.toISOString()
});
}
// 할일 업데이트
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
if (index !== -1) {
this.todos[index] = response;
}
await this.loadStats();
this.closeScheduleModal();
console.log('✅ 할일 일정 설정 완료');
} catch (error) {
console.error('❌ 일정 설정 실패:', error);
if (error.message.includes('split')) {
alert('2시간 이상의 작업은 분할하는 것을 권장합니다.');
} else {
alert('일정 설정 중 오류가 발생했습니다.');
}
}
},
// 할일 완료
async completeTodo(todoId) {
try {
const response = await window.api.put(`/todos/${todoId}/complete`);
// 할일 업데이트
const index = this.todos.findIndex(t => t.id === todoId);
if (index !== -1) {
this.todos[index] = response;
}
await this.loadStats();
console.log('✅ 할일 완료');
} catch (error) {
console.error('❌ 할일 완료 실패:', error);
alert('할일 완료 처리 중 오류가 발생했습니다.');
}
},
// 지연 모달 열기 (일정 설정 모달 재사용) - 더 이상 사용하지 않음
openDelayModal(todo) {
// 일정변경과 동일하게 처리
this.openScheduleModal(todo);
},
// 댓글 모달 열기
async openCommentModal(todo) {
this.currentTodo = todo;
this.commentForm = { content: '' };
try {
const response = await window.api.get(`/todos/${todo.id}/comments`);
this.currentTodoComments = response || [];
} catch (error) {
console.error('❌ 댓글 로드 실패:', error);
this.currentTodoComments = [];
}
this.showCommentModal = true;
},
// 댓글 모달 닫기
closeCommentModal() {
this.showCommentModal = false;
this.currentTodo = null;
this.currentTodoComments = [];
},
// 댓글 추가
async addComment() {
if (!this.currentTodo || !this.commentForm.content.trim()) return;
try {
const response = await window.api.post(`/todos/${this.currentTodo.id}/comments`, {
content: this.commentForm.content.trim()
});
this.currentTodoComments.push(response);
this.commentForm.content = '';
// 할일의 댓글 수 업데이트
const index = this.todos.findIndex(t => t.id === this.currentTodo.id);
if (index !== -1) {
this.todos[index].comment_count = this.currentTodoComments.length;
}
console.log('✅ 댓글 추가 완료');
} catch (error) {
console.error('❌ 댓글 추가 실패:', error);
alert('댓글 추가 중 오류가 발생했습니다.');
}
},
// 분할 모달 열기
openSplitModal(todo) {
this.currentTodo = todo;
this.splitForm = {
subtasks: ['', ''],
estimated_minutes_per_task: [30, 30]
};
this.showSplitModal = true;
},
// 분할 모달 닫기
closeSplitModal() {
this.showSplitModal = false;
this.currentTodo = null;
},
// 할일 분할
async splitTodo() {
if (!this.currentTodo) return;
const validSubtasks = this.splitForm.subtasks.filter(s => s.trim());
const validMinutes = this.splitForm.estimated_minutes_per_task.slice(0, validSubtasks.length);
if (validSubtasks.length < 2) {
alert('최소 2개의 하위 작업이 필요합니다.');
return;
}
try {
const response = await window.api.post(`/todos/${this.currentTodo.id}/split`, {
subtasks: validSubtasks,
estimated_minutes_per_task: validMinutes
});
// 원본 할일 제거하고 분할된 할일들 추가
this.todos = this.todos.filter(t => t.id !== this.currentTodo.id);
this.todos.unshift(...response);
await this.loadStats();
this.closeSplitModal();
console.log('✅ 할일 분할 완료');
} catch (error) {
console.error('❌ 할일 분할 실패:', error);
alert('할일 분할 중 오류가 발생했습니다.');
}
},
// 댓글 토글
async toggleComments(todoId) {
const todo = this.todos.find(t => t.id === todoId);
if (todo) {
await this.openCommentModal(todo);
}
},
// 유틸리티 함수들
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR');
},
formatDateLocal(date) {
const d = new Date(date);
return d.toISOString().slice(0, 10);
},
formatRelativeTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return '방금 전';
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR');
},
// 햅틱 피드백 (모바일)
hapticFeedback(element) {
// 진동 API 지원 확인
if ('vibrate' in navigator) {
navigator.vibrate(50); // 50ms 진동
}
// 시각적 피드백
if (element) {
element.classList.add('haptic-feedback');
setTimeout(() => {
element.classList.remove('haptic-feedback');
}, 100);
}
},
// 풀 투 리프레시 (모바일)
handlePullToRefresh() {
let startY = 0;
let currentY = 0;
let pullDistance = 0;
const threshold = 100;
document.addEventListener('touchstart', (e) => {
if (window.scrollY === 0) {
startY = e.touches[0].clientY;
}
});
document.addEventListener('touchmove', (e) => {
if (startY > 0) {
currentY = e.touches[0].clientY;
pullDistance = currentY - startY;
if (pullDistance > 0 && pullDistance < threshold) {
// 당기는 중 시각적 피드백
const opacity = pullDistance / threshold;
document.body.style.background = `linear-gradient(to bottom, rgba(99, 102, 241, ${opacity * 0.1}) 0%, #f9fafb 100%)`;
}
}
});
document.addEventListener('touchend', async () => {
if (pullDistance > threshold) {
// 새로고침 실행
await this.loadTodos();
await this.loadActiveTodos();
// 햅틱 피드백
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100]);
}
}
// 리셋
startY = 0;
pullDistance = 0;
document.body.style.background = '';
});
},
// 모든 할일의 메모 로드 (초기화용)
async loadAllTodoMemos() {
try {
console.log('📋 모든 할일 메모 로드 중...');
// 모든 할일에 대해 메모 로드
const memoPromises = this.todos.map(async (todo) => {
try {
const response = await window.api.get(`/todos/${todo.id}/comments`);
this.todoMemos[todo.id] = response || [];
if (this.todoMemos[todo.id].length > 0) {
console.log(`${todo.content.slice(0, 20)}... - ${this.todoMemos[todo.id].length}개 메모 로드`);
}
} catch (error) {
console.error(`${todo.id} 메모 로드 실패:`, error);
this.todoMemos[todo.id] = [];
}
});
await Promise.all(memoPromises);
console.log('✅ 모든 할일 메모 로드 완료');
} catch (error) {
console.error('❌ 전체 메모 로드 실패:', error);
}
},
// 할일 메모 로드 (인라인용)
async loadTodoMemos(todoId) {
try {
const response = await window.api.get(`/todos/${todoId}/comments`);
this.todoMemos[todoId] = response || [];
return this.todoMemos[todoId];
} catch (error) {
console.error('❌ 메모 로드 실패:', error);
this.todoMemos[todoId] = [];
return [];
}
},
// 할일 메모 추가 (인라인용)
async addTodoMemo(todoId, content) {
try {
const response = await window.api.post(`/todos/${todoId}/comments`, {
content: content.trim()
});
// 메모 목록 새로고침
await this.loadTodoMemos(todoId);
console.log('✅ 메모 추가 완료');
return response;
} catch (error) {
console.error('❌ 메모 추가 실패:', error);
alert('메모 추가 중 오류가 발생했습니다.');
throw error;
}
},
// 메모 토글
toggleMemo(todoId) {
this.showMemoForTodo[todoId] = !this.showMemoForTodo[todoId];
// 메모가 처음 열릴 때만 로드
if (this.showMemoForTodo[todoId] && !this.todoMemos[todoId]) {
this.loadTodoMemos(todoId);
}
},
// 특정 할일의 메모 개수 가져오기
getTodoMemoCount(todoId) {
return this.todoMemos[todoId] ? this.todoMemos[todoId].length : 0;
},
// 특정 할일의 메모 목록 가져오기
getTodoMemos(todoId) {
return this.todoMemos[todoId] || [];
},
// 8시간 초과 경고 모달 표시
showOverworkWarning(selectedDate, totalHours) {
return new Promise((resolve) => {
// 기존 경고 모달이 있으면 제거
const existingModal = document.getElementById('overwork-warning-modal');
if (existingModal) {
existingModal.remove();
}
// 모달 HTML 생성
const modalHTML = `
<div id="overwork-warning-modal" class="fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div class="p-6 border-b border-orange-200 bg-orange-50 rounded-t-2xl">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-2xl text-orange-600"></i>
</div>
<div class="ml-3">
<h3 class="text-lg font-bold text-orange-900">⚠️ 과로 경고</h3>
<p class="text-sm text-orange-700 mt-1">하루 권장 작업시간을 초과했습니다</p>
</div>
</div>
</div>
<div class="p-6">
<div class="mb-6">
<p class="text-gray-700 mb-2">
<strong>${selectedDate}</strong>의 총 작업시간이
<strong class="text-red-600">${totalHours}시간</strong>이 됩니다.
</p>
<p class="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
<i class="fas fa-info-circle mr-1 text-blue-500"></i>
건강한 작업을 위해 하루 8시간 이내로 계획하는 것을 권장합니다.
</p>
</div>
<div class="flex flex-col space-y-3">
<button
onclick="resolveOverworkWarning('continue')"
class="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors"
>
<i class="fas fa-check mr-2"></i>그냥 쓴다 (계속 진행)
</button>
<button
onclick="resolveOverworkWarning('change')"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors"
>
<i class="fas fa-edit mr-2"></i>변경한다 (다시 설정)
</button>
<button
onclick="resolveOverworkWarning('cancel')"
class="w-full px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
<i class="fas fa-times mr-2"></i>취소
</button>
</div>
</div>
</div>
</div>
`;
// 모달을 body에 추가
document.body.insertAdjacentHTML('beforeend', modalHTML);
// 전역 함수로 resolve 설정
window.resolveOverworkWarning = (choice) => {
const modal = document.getElementById('overwork-warning-modal');
if (modal) {
modal.remove();
}
delete window.resolveOverworkWarning;
resolve(choice);
};
});
}
};
}
// 모바일 감지 및 초기화
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform));
}
// 모바일 최적화 초기화
document.addEventListener('DOMContentLoaded', () => {
if (isMobile()) {
// 모바일 전용 스타일 추가
document.body.classList.add('mobile-optimized');
// iOS Safari 주소창 숨김 대응
const setVH = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVH();
window.addEventListener('resize', setVH);
window.addEventListener('orientationchange', setVH);
// 터치 스크롤 개선
document.body.style.webkitOverflowScrolling = 'touch';
}
});
console.log('📋 할일관리 컴포넌트 등록 완료');

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,15 @@ class DocumentLoader {
document.title = `${noteDocument.title} - Document Server`; document.title = `${noteDocument.title} - Document Server`;
// 노트 내용을 HTML로 설정 // 노트 내용을 HTML로 설정
const contentElement = document.getElementById('document-content'); const noteContentElement = document.getElementById('note-content');
if (contentElement && noteDocument.content) { if (noteContentElement && noteDocument.content) {
contentElement.innerHTML = noteDocument.content; noteContentElement.innerHTML = noteDocument.content;
} else {
// 폴백: document-content 사용
const contentElement = document.getElementById('document-content');
if (contentElement && noteDocument.content) {
contentElement.innerHTML = noteDocument.content;
}
} }
console.log('📝 노트 로드 완료:', noteDocument.title); console.log('📝 노트 로드 완료:', noteDocument.title);
@@ -46,25 +52,28 @@ class DocumentLoader {
// 백엔드에서 문서 정보 가져오기 (캐싱 적용) // 백엔드에서 문서 정보 가져오기 (캐싱 적용)
const docData = await this.cachedApi.get(`/documents/${documentId}`, { content_type: 'document' }, { category: 'document' }); const docData = await this.cachedApi.get(`/documents/${documentId}`, { content_type: 'document' }, { category: 'document' });
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = docData.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
if (!response.ok) {
throw new Error('문서 파일을 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트 // 페이지 제목 업데이트
document.title = `${docData.title} - Document Server`; document.title = `${docData.title} - Document Server`;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의 // PDF 문서가 아닌 경우에만 HTML 로드
this.setupDocumentScriptHandlers(); if (!docData.pdf_path && docData.html_path) {
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = docData.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
console.log('✅ 문서 로드 완료:', docData.title); if (!response.ok) {
throw new Error('문서 파일을 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
this.setupDocumentScriptHandlers();
}
console.log('✅ 문서 로드 완료:', docData.title, docData.pdf_path ? '(PDF)' : '(HTML)');
return docData; return docData;
} catch (error) { } catch (error) {

View File

@@ -42,14 +42,20 @@ class HighlightManager {
*/ */
async loadNotes(documentId, contentType) { async loadNotes(documentId, contentType) {
try { try {
console.log('📝 메모 로드 시작:', { documentId, contentType });
if (contentType === 'note') { if (contentType === 'note') {
this.notes = await this.api.get(`/note/${documentId}/notes`).catch(() => []); // 노트 문서의 하이라이트 메모
this.notes = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []);
} else { } else {
this.notes = await this.cachedApi.get('/notes', { document_id: documentId, content_type: contentType }, { category: 'notes' }).catch(() => []); // 일반 문서의 하이라이트 메모
this.notes = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []);
} }
console.log('📝 메모 로드 완료:', this.notes.length, '개');
return this.notes || []; return this.notes || [];
} catch (error) { } catch (error) {
console.error('메모 로드 실패:', error); console.error('메모 로드 실패:', error);
return []; return [];
} }
} }
@@ -235,12 +241,46 @@ class HighlightManager {
span.style.cursor = 'help'; span.style.cursor = 'help';
} }
// 하이라이트 클릭 이벤트 추가 // 하이라이트 클릭 이벤트 추가 (통합 툴팁 사용)
span.style.cursor = 'pointer'; span.style.cursor = 'pointer';
span.addEventListener('click', (e) => { span.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.showHighlightModal(highlights);
console.log('🎨 하이라이트 클릭됨:', {
text: span.textContent,
highlightId: span.dataset.highlightId,
classList: Array.from(span.classList)
});
// 링크, 백링크, 하이라이트 모두 찾기
const overlappingElements = window.documentViewerInstance.getOverlappingElements(span);
const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length;
console.log('🎨 하이라이트 클릭 분석:', {
links: overlappingElements.links.length,
backlinks: overlappingElements.backlinks.length,
highlights: overlappingElements.highlights.length,
total: totalElements,
selectedText: overlappingElements.selectedText
});
if (totalElements > 1) {
// 통합 툴팁 표시 (링크 + 백링크 + 하이라이트)
console.log('🎯 통합 툴팁 표시 시작 (하이라이트에서)');
await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span);
} else {
// 단일 하이라이트 툴팁
console.log('🎨 단일 하이라이트 툴팁 표시');
// 클릭된 하이라이트 찾기
const clickedHighlightId = span.dataset.highlightId;
const clickedHighlight = this.highlights.find(h => h.id === clickedHighlightId);
if (clickedHighlight) {
await this.showHighlightTooltip(clickedHighlight, span);
} else {
console.error('❌ 클릭된 하이라이트를 찾을 수 없음:', clickedHighlightId);
}
}
}); });
// DOM 교체 // DOM 교체
@@ -488,6 +528,109 @@ class HighlightManager {
} }
} }
/**
* 하이라이트 색상 변경
*/
async updateHighlightColor(highlightId, newColor) {
try {
console.log('🎨 하이라이트 색상 업데이트:', highlightId, newColor);
// API 호출 (구현 필요)
await this.api.updateHighlight(highlightId, { highlight_color: newColor });
// 로컬 데이터 업데이트
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
highlight.highlight_color = newColor;
}
// 하이라이트 다시 렌더링
this.renderHighlights();
this.hideTooltip();
console.log('✅ 하이라이트 색상 변경 완료');
} catch (error) {
console.error('❌ 하이라이트 색상 변경 실패:', error);
throw error;
}
}
/**
* 하이라이트 복사
*/
async duplicateHighlight(highlightId) {
try {
console.log('📋 하이라이트 복사:', highlightId);
const originalHighlight = this.highlights.find(h => h.id === highlightId);
if (!originalHighlight) {
throw new Error('원본 하이라이트를 찾을 수 없습니다.');
}
// 새 하이라이트 데이터 생성 (약간 다른 위치에)
const duplicateData = {
document_id: originalHighlight.document_id,
start_offset: originalHighlight.start_offset,
end_offset: originalHighlight.end_offset,
selected_text: originalHighlight.selected_text,
highlight_color: originalHighlight.highlight_color,
highlight_type: originalHighlight.highlight_type
};
// API 호출
const newHighlight = await this.api.createHighlight(duplicateData);
// 로컬 데이터에 추가
this.highlights.push(newHighlight);
// 하이라이트 다시 렌더링
this.renderHighlights();
this.hideTooltip();
console.log('✅ 하이라이트 복사 완료:', newHighlight);
} catch (error) {
console.error('❌ 하이라이트 복사 실패:', error);
throw error;
}
}
/**
* 메모 업데이트
*/
async updateNote(noteId, newContent) {
try {
console.log('✏️ 메모 업데이트:', noteId, newContent);
// API 호출
const apiToUse = this.cachedApi || this.api;
await apiToUse.updateNote(noteId, { content: newContent });
// 로컬 데이터 업데이트
const note = this.notes.find(n => n.id === noteId);
if (note) {
note.content = newContent;
}
// 툴팁 새로고침 (현재 표시 중인 경우)
const tooltip = document.getElementById('highlight-tooltip');
if (tooltip) {
// 간단히 툴팁을 다시 로드하는 대신 텍스트만 업데이트
const noteElement = document.querySelector(`[data-note-id="${noteId}"] .text-gray-800`);
if (noteElement) {
noteElement.textContent = newContent;
}
}
console.log('✅ 메모 업데이트 완료');
} catch (error) {
console.error('❌ 메모 업데이트 실패:', error);
throw error;
}
}
/** /**
* 텍스트 오프셋 계산 * 텍스트 오프셋 계산
*/ */
@@ -587,7 +730,21 @@ class HighlightManager {
async deleteHighlight(highlightId) { async deleteHighlight(highlightId) {
try { try {
await this.api.delete(`/highlights/${highlightId}`); await this.api.delete(`/highlights/${highlightId}`);
// 하이라이트 배열에서 제거
this.highlights = this.highlights.filter(h => h.id !== highlightId); this.highlights = this.highlights.filter(h => h.id !== highlightId);
// 메모 배열에서도 해당 하이라이트의 메모들 제거
this.notes = this.notes.filter(note => note.highlight_id !== highlightId);
// 캐시 무효화 (하이라이트와 메모 모두)
if (window.documentViewerInstance && window.documentViewerInstance.cacheManager) {
window.documentViewerInstance.cacheManager.invalidateCategory('highlights');
window.documentViewerInstance.cacheManager.invalidateCategory('notes');
console.log('🗑️ 하이라이트 삭제 후 캐시 무효화 완료');
}
// 화면 다시 렌더링
this.renderHighlights(); this.renderHighlights();
console.log('하이라이트 삭제 완료:', highlightId); console.log('하이라이트 삭제 완료:', highlightId);
} catch (error) { } catch (error) {
@@ -719,13 +876,70 @@ class HighlightManager {
return colorNames[color] || '기타'; return colorNames[color] || '기타';
} }
/**
* 날짜 포맷팅 (상세)
*/
formatDate(dateString) {
if (!dateString) return '알 수 없음';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* 날짜 포맷팅 (간단)
*/
formatShortDate(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', {
month: 'short',
day: 'numeric'
});
}
}
/** /**
* 하이라이트 툴팁 표시 * 하이라이트 툴팁 표시
*/ */
showHighlightTooltip(clickedHighlight, element) { async showHighlightTooltip(clickedHighlight, element) {
// 기존 말풍선 제거 // 기존 말풍선 제거
this.hideTooltip(); this.hideTooltip();
// 메모 데이터 다시 로드 (최신 상태 보장)
console.log('📝 하이라이트 툴팁용 메모 로드 시작...');
const documentId = window.documentViewerInstance.documentId;
const contentType = window.documentViewerInstance.contentType;
console.log('📝 메모 로드 파라미터:', { documentId, contentType });
console.log('📝 기존 메모 개수:', this.notes ? this.notes.length : 'undefined');
await this.loadNotes(documentId, contentType);
console.log('📝 메모 로드 완료:', this.notes.length, '개');
console.log('📝 로드된 메모 상세:', this.notes.map(n => ({
id: n.id,
highlight_id: n.highlight_id,
content: n.content,
created_at: n.created_at
})));
// 동일한 범위의 모든 하이라이트 찾기 // 동일한 범위의 모든 하이라이트 찾기
const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight); const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight);
const colorGroups = this.groupHighlightsByColor(overlappingHighlights); const colorGroups = this.groupHighlightsByColor(overlappingHighlights);
@@ -744,9 +958,19 @@ class HighlightManager {
let tooltipHTML = ` let tooltipHTML = `
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-600 mb-2">선택된 텍스트</div> <div class="flex items-center justify-between mb-3">
<div class="font-medium text-gray-900 bg-gray-100 px-3 py-2 rounded border-l-4 border-blue-500"> <div class="text-lg font-semibold text-blue-800 flex items-center">
"${longestText}" <svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
하이라이트 정보
</div>
<div class="text-xs text-gray-500">${overlappingHighlights.length}개 하이라이트</div>
</div>
<div class="font-medium text-gray-900 bg-blue-50 px-4 py-3 rounded-lg border-l-4 border-blue-500">
<div class="text-sm text-blue-700 mb-1">선택된 텍스트</div>
<div class="text-base">"${longestText}"</div>
</div> </div>
</div> </div>
`; `;
@@ -756,39 +980,84 @@ class HighlightManager {
Object.entries(colorGroups).forEach(([color, highlights]) => { Object.entries(colorGroups).forEach(([color, highlights]) => {
const colorName = this.getColorName(color); const colorName = this.getColorName(color);
const allNotes = highlights.flatMap(h =>
this.notes.filter(note => note.highlight_id === h.id) // 각 하이라이트에 대한 메모 찾기 (디버깅 로그 추가)
); const allNotes = highlights.flatMap(h => {
const notesForHighlight = this.notes.filter(note => note.highlight_id === h.id);
console.log(`📝 하이라이트 ${h.id}에 대한 메모:`, notesForHighlight.length, '개');
if (notesForHighlight.length > 0) {
console.log('📝 메모 내용:', notesForHighlight.map(n => n.content));
}
return notesForHighlight;
});
console.log(`🎨 ${colorName} 하이라이트의 총 메모:`, allNotes.length, '개');
const createdDate = highlights[0].created_at ? this.formatDate(highlights[0].created_at) : '알 수 없음';
tooltipHTML += ` tooltipHTML += `
<div class="border rounded-lg p-3" style="border-left: 4px solid ${color}"> <div class="border rounded-lg p-4 bg-gradient-to-r from-gray-50 to-gray-100" style="border-left: 4px solid ${color}">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-3">
<div class="w-3 h-3 rounded" style="background-color: ${color}"></div> <div class="w-4 h-4 rounded-full shadow-sm" style="background-color: ${color}"></div>
<span class="text-sm font-medium text-gray-700">${colorName} 메모 (${allNotes.length})</span> <div>
<span class="text-sm font-semibold text-gray-800">${colorName} 하이라이트</span>
<div class="text-xs text-gray-600">${createdDate} 생성</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="window.documentViewerInstance.highlightManager.changeHighlightColor('${highlights[0].id}')"
class="text-xs bg-gray-500 text-white px-2 py-1 rounded hover:bg-gray-600 transition-colors"
title="색상 변경">
🎨
</button>
<button onclick="window.documentViewerInstance.highlightManager.showAddNoteForm('${highlights[0].id}')"
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600 transition-colors">
📝 메모 추가
</button>
</div> </div>
<button onclick="window.documentViewerInstance.highlightManager.showAddNoteForm('${highlights[0].id}')"
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
+ 추가
</button>
</div> </div>
<div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-32 overflow-y-auto"> <!-- 메모 목록 -->
${allNotes.length > 0 ? <div class="mb-3">
allNotes.map(note => ` <div class="text-sm font-medium text-gray-700 mb-2 flex items-center">
<div class="bg-gray-50 p-2 rounded text-sm"> <svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<div class="text-gray-800">${note.content}</div> <path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
<div class="text-xs text-gray-500 mt-1 flex justify-between items-center"> <path fill-rule="evenodd" d="M4 5a2 2 0 012-2v1a1 1 0 102 0V3a2 2 0 012 0v1a1 1 0 102 0V3a2 2 0 012 2v6.586A2 2 0 0115.414 13L13 15.586A2 2 0 0111.586 16H6a2 2 0 01-2-2V5zm8 4a1 1 0 10-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"></path>
<span>${this.formatShortDate(note.created_at)} · Administrator</span> </svg>
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')" 메모 (${allNotes.length}개)
class="text-red-600 hover:text-red-800"> </div>
삭제
</button> <div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-40 overflow-y-auto">
${allNotes.length > 0 ?
allNotes.map(note => `
<div class="bg-white p-3 rounded-lg border shadow-sm group">
<div class="text-gray-800 text-sm leading-relaxed">${note.content}</div>
<div class="text-xs text-gray-500 mt-2 flex justify-between items-center">
<span class="flex items-center">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
${this.formatShortDate(note.created_at)}
</span>
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="window.documentViewerInstance.highlightManager.editNote('${note.id}', '${note.content.replace(/'/g, "\\'")}');"
class="text-blue-600 hover:text-blue-800 mr-2 text-xs"
title="메모 편집">
✏️
</button>
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')"
class="text-red-600 hover:text-red-800 text-xs"
title="메모 삭제">
🗑️
</button>
</div>
</div>
</div> </div>
</div> `).join('') :
`).join('') : '<div class="text-sm text-gray-500 italic bg-white p-3 rounded-lg border">메모가 없습니다. 위의 "📝 메모 추가" 버튼을 클릭해보세요!</div>'
'<div class="text-sm text-gray-500 italic">메모가 없습니다</div>' }
} </div>
</div> </div>
</div> </div>
`; `;
@@ -796,13 +1065,36 @@ class HighlightManager {
tooltipHTML += '</div>'; tooltipHTML += '</div>';
// 하이라이트 삭제 버튼 // 하이라이트 관리 버튼
tooltipHTML += ` tooltipHTML += `
<div class="mt-4 pt-3 border-t"> <div class="mt-4 pt-4 border-t border-gray-200">
<button onclick="window.documentViewerInstance.highlightManager.deleteHighlight('${clickedHighlight.id}')" <div class="flex items-center justify-between">
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"> <div class="text-sm font-medium text-gray-700 flex items-center">
하이라이트 삭제 <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
</button> <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
하이라이트 관리
</div>
<div class="flex items-center space-x-2">
<button onclick="window.documentViewerInstance.highlightManager.duplicateHighlight('${clickedHighlight.id}')"
class="text-xs bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600 transition-colors flex items-center"
title="하이라이트 복사">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"></path>
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"></path>
</svg>
복사
</button>
<button onclick="window.documentViewerInstance.deleteHighlightWithConfirm('${clickedHighlight.id}')"
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 transition-colors flex items-center"
title="하이라이트 삭제">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
삭제
</button>
</div>
</div>
</div> </div>
`; `;

File diff suppressed because it is too large Load Diff

View File

@@ -232,22 +232,24 @@ class CachedAPI {
return cached; return cached;
} }
// 기존 API 메서드 직접 사용 // 하이라이트 메모 API 사용
try { try {
let result; let result;
if (contentType === 'note') { if (contentType === 'note') {
result = await this.api.get(`/note/${documentId}/notes`).catch(() => []); // 노트 문서의 하이라이트 메모
result = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []);
} else { } else {
result = await this.api.getDocumentNotes(documentId).catch(() => []); // 일반 문서의 하이라이트 메모
result = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []);
} }
// 캐시에 저장 // 캐시에 저장
this.cache.set(cacheKey, result, 'notes', 10 * 60 * 1000); this.cache.set(cacheKey, result, 'notes', 10 * 60 * 1000);
console.log(`💾 메모 캐시 저장: ${documentId}`); console.log(`💾 메모 캐시 저장: ${documentId} (${result.length}개)`);
return result; return result;
} catch (error) { } catch (error) {
console.error('메모 로드 실패:', error); console.error('메모 로드 실패:', error);
return []; return [];
} }
} }
@@ -359,7 +361,25 @@ class CachedAPI {
* 메모 생성 (캐시 무효화) * 메모 생성 (캐시 무효화)
*/ */
async createNote(data) { async createNote(data) {
return await this.post('/notes/', data, { return await this.post('/highlight-notes/', data, {
invalidateCategories: ['notes', 'highlights']
});
}
/**
* 메모 업데이트 (캐시 무효화)
*/
async updateNote(noteId, data) {
return await this.put(`/highlight-notes/${noteId}`, data, {
invalidateCategories: ['notes', 'highlights']
});
}
/**
* 메모 삭제 (캐시 무효화)
*/
async deleteNote(noteId) {
return await this.delete(`/highlight-notes/${noteId}`, {
invalidateCategories: ['notes', 'highlights'] invalidateCategories: ['notes', 'highlights']
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨테이너 --> <!-- 메인 컨테이너 -->
<div class="min-h-screen pt-4" x-show="currentUser"> <div class="min-h-screen pt-20" x-show="currentUser">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- 상단 네비게이션 바 --> <!-- 상단 네비게이션 바 -->

View File

@@ -44,7 +44,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨테이너 --> <!-- 메인 컨테이너 -->
<div class="min-h-screen pt-4" x-show="currentUser"> <div class="min-h-screen pt-20" x-show="currentUser">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- 상단 툴바 --> <!-- 상단 툴바 -->
@@ -54,8 +54,8 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<select <select
x-model="selectedTreeId" x-model="selectedTreeId"
@change="loadStory(selectedTreeId)" @change="loadStory($event.target.value)"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer relative z-[60]"
> >
<option value="">📚 스토리 선택</option> <option value="">📚 스토리 선택</option>
<template x-for="tree in userTrees" :key="tree.id"> <template x-for="tree in userTrees" :key="tree.id">
@@ -63,6 +63,7 @@
</template> </template>
</select> </select>
<div x-show="selectedTree" class="text-sm text-gray-600"> <div x-show="selectedTree" class="text-sm text-gray-600">
<span x-text="`총 ${canonicalNodes.length}개 챕터`"></span> <span x-text="`총 ${canonicalNodes.length}개 챕터`"></span>
<span class="mx-2"></span> <span class="mx-2"></span>
@@ -178,7 +179,7 @@
</div> </div>
</div> </div>
<div class="flex-1 p-6 overflow-hidden"> <div class="flex-1 p-6 overflow-hidden" x-show="editingNode">
<div class="h-full flex flex-col space-y-4"> <div class="h-full flex flex-col space-y-4">
<!-- 제목 편집 --> <!-- 제목 편집 -->
<div> <div>
@@ -288,17 +289,31 @@
// 스크립트 순차 로딩 // 스크립트 순차 로딩
(async () => { (async () => {
try { try {
await loadScript('static/js/api.js?v=2025012380'); console.log('🚀 스크립트 로딩 시작...');
await loadScript('static/js/api.js?v=2025012627');
console.log('✅ API 스크립트 로드 완료'); console.log('✅ API 스크립트 로드 완료');
await loadScript('static/js/story-view.js?v=2025012364');
await loadScript('static/js/story-view.js?v=2025012627');
console.log('✅ Story View 스크립트 로드 완료'); console.log('✅ Story View 스크립트 로드 완료');
// Alpine.js 로드 전에 함수 등록 확인
console.log('🔍 storyViewApp 함수 확인:', typeof window.storyViewApp);
// 모든 스크립트 로드 완료 후 Alpine.js 로드 // 모든 스크립트 로드 완료 후 Alpine.js 로드
console.log('🚀 Alpine.js 로딩...'); console.log('🚀 Alpine.js 로딩...');
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js'); await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
console.log('✅ Alpine.js 로드 완료'); console.log('✅ Alpine.js 로드 완료');
// Alpine.js 초기화 확인
setTimeout(() => {
console.log('🔍 Alpine 객체 확인:', typeof Alpine);
console.log('🔍 Alpine 초기화 상태:', Alpine ? 'OK' : 'FAILED');
}, 500);
} catch (error) { } catch (error) {
console.error('❌ 스크립트 로드 실패:', error); console.error('❌ 스크립트 로드 실패:', error);
console.error('❌ 에러 상세:', error.message);
} }
})(); })();
</script> </script>

View File

@@ -0,0 +1,368 @@
<!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>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 메인 앱 -->
<div x-data="systemSettingsApp()" x-init="init()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-6xl">
<!-- 권한 확인 중 로딩 -->
<div x-show="!permissionChecked" class="flex items-center justify-center py-20">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
<p class="text-gray-600">권한을 확인하고 있습니다...</p>
</div>
</div>
<!-- 권한 없음 -->
<div x-show="permissionChecked && !hasPermission" class="text-center py-20">
<div class="bg-red-100 border border-red-200 rounded-lg p-8 max-w-md mx-auto">
<i class="fas fa-exclamation-triangle text-4xl text-red-600 mb-4"></i>
<h2 class="text-xl font-semibold text-red-800 mb-2">접근 권한이 없습니다</h2>
<p class="text-red-600 mb-4">시스템 설정에 접근하려면 관리자 권한이 필요합니다.</p>
<button onclick="history.back()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
<!-- 시스템 설정 (권한 있는 경우만 표시) -->
<div x-show="permissionChecked && hasPermission">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-2">
<i class="fas fa-server text-2xl text-blue-600"></i>
<h1 class="text-3xl font-bold text-gray-900">시스템 설정</h1>
</div>
<p class="text-gray-600">시스템 전체 설정을 관리하세요</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-600' : 'fas fa-exclamation-circle text-red-600'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- 설정 섹션들 -->
<div class="space-y-8">
<!-- 시스템 정보 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-info-circle text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">시스템 정보</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center space-x-2 mb-2">
<i class="fas fa-users text-blue-600"></i>
<span class="font-medium text-gray-700">총 사용자 수</span>
</div>
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.totalUsers">-</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center space-x-2 mb-2">
<i class="fas fa-user-shield text-green-600"></i>
<span class="font-medium text-gray-700">활성 사용자</span>
</div>
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.activeUsers">-</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center space-x-2 mb-2">
<i class="fas fa-crown text-yellow-600"></i>
<span class="font-medium text-gray-700">관리자 수</span>
</div>
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.adminUsers">-</div>
</div>
</div>
</div>
<!-- 기본 설정 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-cog text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">기본 설정</h2>
</div>
<form @submit.prevent="updateSystemSettings()" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">시스템 이름</label>
<input type="text" x-model="settings.systemName"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Document Server">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">기본 언어</label>
<select x-model="settings.defaultLanguage"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">기본 테마</label>
<select x-model="settings.defaultTheme"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="light">라이트</option>
<option value="dark">다크</option>
<option value="auto">자동</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">기본 세션 타임아웃 (분)</label>
<select x-model="settings.defaultSessionTimeout"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="5">5분</option>
<option value="15">15분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
<option value="0">무제한</option>
</select>
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-save"></i>
<span x-text="loading ? '저장 중...' : '설정 저장'"></span>
</button>
</div>
</form>
</div>
<!-- 사용자 관리 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<i class="fas fa-users-cog text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">사용자 관리</h2>
</div>
<a href="user-management.html" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center space-x-2">
<i class="fas fa-user-plus"></i>
<span>사용자 관리</span>
</a>
</div>
<div class="text-gray-600">
<p class="mb-2">• 새로운 사용자 계정 생성 및 관리</p>
<p class="mb-2">• 사용자 권한 설정 (서적관리, 노트관리, 소설관리)</p>
<p class="mb-2">• 개별 사용자 세션 타임아웃 설정</p>
<p>• 사용자 계정 활성화/비활성화</p>
</div>
</div>
<!-- 시스템 유지보수 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center space-x-3 mb-6">
<i class="fas fa-tools text-xl text-orange-600"></i>
<h2 class="text-xl font-semibold text-gray-900">시스템 유지보수</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2">캐시 정리</h3>
<p class="text-sm text-gray-600 mb-3">시스템 캐시를 정리하여 성능을 개선합니다.</p>
<button @click="clearCache()" :disabled="loading"
class="w-full px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50">
<i class="fas fa-broom mr-2"></i>캐시 정리
</button>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2">시스템 재시작</h3>
<p class="text-sm text-gray-600 mb-3">시스템을 재시작하여 설정을 적용합니다.</p>
<button @click="restartSystem()" :disabled="loading"
class="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
<i class="fas fa-power-off mr-2"></i>시스템 재시작
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- API 스크립트 -->
<script src="static/js/api.js"></script>
<!-- 시스템 설정 스크립트 -->
<script>
function systemSettingsApp() {
return {
loading: false,
permissionChecked: false,
hasPermission: false,
currentUser: null,
systemInfo: {
totalUsers: 0,
activeUsers: 0,
adminUsers: 0
},
settings: {
systemName: 'Document Server',
defaultLanguage: 'ko',
defaultTheme: 'light',
defaultSessionTimeout: 5
},
notification: {
show: false,
message: '',
type: 'success'
},
async init() {
console.log('🔧 시스템 설정 앱 초기화');
await this.checkPermission();
if (this.hasPermission) {
await this.loadSystemInfo();
await this.loadSettings();
}
},
async checkPermission() {
try {
this.currentUser = await api.getCurrentUser();
// root, admin 역할이거나 is_admin이 true인 경우 권한 허용
this.hasPermission = this.currentUser.role === 'root' ||
this.currentUser.role === 'admin' ||
this.currentUser.is_admin === true;
console.log('👤 현재 사용자:', this.currentUser);
console.log('🔐 관리자 권한:', this.hasPermission);
} catch (error) {
console.error('❌ 권한 확인 실패:', error);
this.hasPermission = false;
} finally {
this.permissionChecked = true;
}
},
async loadSystemInfo() {
try {
const users = await api.get('/users');
this.systemInfo.totalUsers = users.length;
this.systemInfo.activeUsers = users.filter(u => u.is_active).length;
this.systemInfo.adminUsers = users.filter(u => u.role === 'root' || u.role === 'admin' || u.is_admin).length;
console.log('📊 시스템 정보 로드 완료:', this.systemInfo);
} catch (error) {
console.error('❌ 시스템 정보 로드 실패:', error);
}
},
async loadSettings() {
// 실제로는 백엔드에서 시스템 설정을 가져와야 하지만,
// 현재는 기본값을 사용
console.log('⚙️ 시스템 설정 로드 (기본값 사용)');
},
async updateSystemSettings() {
this.loading = true;
try {
// 실제로는 백엔드 API를 호출해야 함
console.log('💾 시스템 설정 저장:', this.settings);
// 시뮬레이션을 위한 지연
await new Promise(resolve => setTimeout(resolve, 1000));
this.showNotification('시스템 설정이 성공적으로 저장되었습니다.', 'success');
} catch (error) {
console.error('❌ 시스템 설정 저장 실패:', error);
this.showNotification('시스템 설정 저장에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async clearCache() {
if (!confirm('시스템 캐시를 정리하시겠습니까?')) return;
this.loading = true;
try {
// 실제로는 백엔드 API를 호출해야 함
console.log('🧹 캐시 정리 중...');
// 시뮬레이션을 위한 지연
await new Promise(resolve => setTimeout(resolve, 2000));
this.showNotification('캐시가 성공적으로 정리되었습니다.', 'success');
} catch (error) {
console.error('❌ 캐시 정리 실패:', error);
this.showNotification('캐시 정리에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async restartSystem() {
if (!confirm('시스템을 재시작하시겠습니까? 모든 사용자의 연결이 끊어집니다.')) return;
this.loading = true;
try {
// 실제로는 백엔드 API를 호출해야 함
console.log('🔄 시스템 재시작 중...');
this.showNotification('시스템 재시작이 요청되었습니다. 잠시 후 페이지가 새로고침됩니다.', 'success');
// 시뮬레이션을 위한 지연 후 페이지 새로고침
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
console.error('❌ 시스템 재시작 실패:', error);
this.showNotification('시스템 재시작에 실패했습니다.', 'error');
this.loading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
message: message,
type: type
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
};
}
</script>
</body>
</html>

View File

@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 문서</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 40px;
background-color: #f9f9f9;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
border-bottom: 3px solid #007bff;
padding-bottom: 10px;
}
h2 {
color: #555;
margin-top: 30px;
}
p {
margin-bottom: 15px;
text-align: justify;
}
.highlight {
background-color: #fff3cd;
padding: 15px;
border-left: 4px solid #ffc107;
margin: 20px 0;
}
.code {
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
font-family: 'Courier New', monospace;
border: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<h1>Document Server 테스트 문서</h1>
<p>이 문서는 Document Server의 업로드 및 뷰어 기능을 테스트하기 위한 샘플 문서입니다.
하이라이트, 메모, 책갈피 등의 기능을 테스트할 수 있습니다.</p>
<h2>주요 기능</h2>
<p>Document Server는 다음과 같은 기능을 제공합니다:</p>
<ul>
<li><strong>문서 업로드</strong>: HTML 및 PDF 파일 업로드</li>
<li><strong>스마트 하이라이트</strong>: 텍스트 선택 및 하이라이트</li>
<li><strong>연결된 메모</strong>: 하이라이트에 메모 추가</li>
<li><strong>책갈피</strong>: 중요한 위치 저장</li>
<li><strong>통합 검색</strong>: 문서 내용 및 메모 검색</li>
</ul>
<div class="highlight">
<strong>중요:</strong> 이 부분은 하이라이트 테스트를 위한 중요한 내용입니다.
사용자는 이 텍스트를 선택하여 하이라이트를 추가하고 메모를 작성할 수 있습니다.
</div>
<h2>기술 스택</h2>
<p>이 프로젝트는 다음 기술들을 사용하여 구축되었습니다:</p>
<div class="code">
<strong>백엔드:</strong> FastAPI, SQLAlchemy, PostgreSQL<br>
<strong>프론트엔드:</strong> HTML5, CSS3, JavaScript, Alpine.js<br>
<strong>인프라:</strong> Docker, Nginx
</div>
<h2>사용 방법</h2>
<p>문서를 읽으면서 다음과 같은 작업을 수행할 수 있습니다:</p>
<ol>
<li>텍스트를 선택하여 하이라이트 추가</li>
<li>하이라이트에 메모 작성</li>
<li>중요한 위치에 책갈피 설정</li>
<li>검색 기능을 통해 내용 찾기</li>
</ol>
<p>이 문서는 업로드 테스트가 완료되면 뷰어에서 확인할 수 있으며,
모든 annotation 기능을 테스트해볼 수 있습니다.</p>
<div class="highlight">
<strong>테스트 완료:</strong> 이 문서가 정상적으로 표시되면 업로드 기능이
올바르게 작동하는 것입니다.
</div>
</div>
</body>
</html>

View File

@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 페이지</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body class="bg-gray-100 p-8">
<div x-data="{ message: '페이지가 정상 작동합니다!' }">
<h1 class="text-3xl font-bold text-blue-600 mb-4" x-text="message"></h1>
<p class="text-gray-700">이 페이지가 보이면 기본 설정은 정상입니다.</p>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">링크</h2>
<div class="space-y-2">
<a href="index.html" class="block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">그리드 뷰</a>
<a href="hierarchy.html" class="block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">계층구조 뷰</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -78,12 +78,15 @@
// URL에서 문서 ID 추출 // URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id'); this.documentId = urlParams.get('id');
this.contentType = urlParams.get('contentType') || 'document'; // 기본값은 document
if (!this.documentId) { if (!this.documentId) {
this.showError('문서 ID가 없습니다'); this.showError('문서 ID가 없습니다');
return; return;
} }
console.log('🔧 초기화:', { documentId: this.documentId, contentType: this.contentType });
// 인증 확인 // 인증 확인
if (!api.token) { if (!api.token) {
window.location.href = '/'; window.location.href = '/';
@@ -100,16 +103,28 @@
} }
async loadDocument() { async loadDocument() {
console.log('📄 문서 로드 중:', this.documentId); console.log('📄 문서 로드 중:', this.documentId, 'contentType:', this.contentType);
try { try {
// 문서 메타데이터 조회 // contentType에 따라 적절한 API 호출
const docResponse = await api.getDocument(this.documentId); let docResponse, contentEndpoint;
if (this.contentType === 'note') {
// 노트 문서 메타데이터 조회
docResponse = await api.getNoteDocument(this.documentId);
contentEndpoint = `/note-documents/${this.documentId}/content`;
console.log('📝 노트 메타데이터:', docResponse);
} else {
// 일반 문서 메타데이터 조회
docResponse = await api.getDocument(this.documentId);
contentEndpoint = `/documents/${this.documentId}/content`;
console.log('📋 문서 메타데이터:', docResponse);
}
this.document = docResponse; this.document = docResponse;
console.log('📋 문서 메타데이터:', docResponse);
// 문서 HTML 콘텐츠 조회 // 문서 HTML 콘텐츠 조회
const contentResponse = await fetch(`${api.baseURL}/documents/${this.documentId}/content`, { const contentResponse = await fetch(`${api.baseURL}${contentEndpoint}`, {
headers: { headers: {
'Authorization': `Bearer ${api.token}`, 'Authorization': `Bearer ${api.token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'

674
frontend/todos.html Normal file
View File

@@ -0,0 +1,674 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>할일관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
[x-cloak] { display: none !important; }
/* 모바일 최적화 */
@media (max-width: 768px) {
.container { padding-left: 1rem; padding-right: 1rem; }
.todo-input-inner { padding: 16px; }
.todo-textarea { font-size: 16px; } /* iOS 줌 방지 */
.todo-card { margin-bottom: 12px; }
.modal-content { margin: 1rem; max-height: 90vh; }
}
/* memos/트위터 스타일 입력창 */
.todo-input-container {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 16px;
padding: 2px;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
}
.todo-input-inner {
background: white;
border-radius: 14px;
padding: 20px;
}
.todo-textarea {
resize: none;
border: none;
outline: none;
font-size: 16px;
line-height: 1.6;
min-height: 80px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.todo-textarea::placeholder {
color: #9ca3af;
font-weight: 400;
}
/* 카드 스타일 */
.todo-card {
transition: all 0.2s ease;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: white;
position: relative;
overflow: hidden;
}
.todo-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
transition: all 0.2s ease;
}
.todo-card.draft::before { background: #9ca3af; }
.todo-card.scheduled::before { background: #3b82f6; }
.todo-card.active::before { background: #f59e0b; }
.todo-card.completed::before { background: #10b981; }
.todo-card.delayed::before { background: #ef4444; }
.todo-card:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 상태 배지 */
.status-badge {
font-size: 11px;
padding: 4px 10px;
border-radius: 20px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-draft { background: #f3f4f6; color: #6b7280; }
.status-scheduled { background: #dbeafe; color: #1d4ed8; }
.status-active { background: #fef3c7; color: #d97706; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-delayed { background: #fee2e2; color: #dc2626; }
/* 시간 배지 */
.time-badge {
background: #f0f9ff;
color: #0369a1;
font-size: 10px;
padding: 3px 8px;
border-radius: 12px;
font-weight: 500;
}
/* 탭 스타일 */
.tab-container {
background: white;
border-radius: 12px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.tab-button {
border-radius: 8px;
transition: all 0.2s ease;
font-weight: 500;
font-size: 14px;
}
.tab-button.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* 버튼 스타일 */
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
border-radius: 12px;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.btn-primary:active {
transform: translateY(0);
}
/* 액션 버튼들 */
.action-btn {
border-radius: 8px;
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
transition: all 0.2s ease;
border: none;
}
.action-btn:active {
transform: scale(0.95);
}
/* 댓글 버블 */
.comment-bubble {
background: #f8fafc;
border-radius: 12px;
padding: 12px;
margin-top: 8px;
border-left: 3px solid #e2e8f0;
}
/* 모달 스타일 */
.modal-content {
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
}
/* 빈 상태 */
.empty-state {
padding: 3rem 1rem;
text-align: center;
color: #6b7280;
}
.empty-state i {
margin-bottom: 1rem;
opacity: 0.5;
}
/* 스와이프 힌트 */
.swipe-hint {
font-size: 12px;
color: #9ca3af;
text-align: center;
padding: 8px;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 16px;
}
/* 풀 투 리프레시 스타일 */
.pull-refresh {
text-align: center;
padding: 20px;
color: #6366f1;
font-weight: 500;
}
/* 햅틱 피드백 애니메이션 */
@keyframes haptic-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.haptic-feedback {
animation: haptic-pulse 0.1s ease-in-out;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="todosApp()" x-init="init()" x-cloak>
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="text-center mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
<i class="fas fa-tasks text-indigo-600 mr-3"></i>
할일관리
</h1>
<p class="text-lg md:text-xl text-gray-600 mb-4">효율적인 일정 관리와 생산성 향상</p>
<!-- 간단한 통계 -->
<div class="flex justify-center space-x-6 text-sm text-gray-500">
<div class="flex items-center">
<i class="fas fa-inbox w-4 h-4 mr-1 text-gray-400"></i>
<span>검토필요 <strong x-text="stats.draft_count || 0"></strong></span>
</div>
<div class="flex items-center">
<i class="fas fa-tasks w-4 h-4 mr-1 text-blue-500"></i>
<span>진행중 <strong x-text="stats.todo_count || 0"></strong></span>
</div>
<div class="flex items-center">
<i class="fas fa-check w-4 h-4 mr-1 text-green-500"></i>
<span>완료 <strong x-text="stats.completed_count || 0"></strong></span>
</div>
</div>
</div>
<!-- 할일 입력 (memos/트위터 스타일) -->
<div class="max-w-2xl mx-auto mb-8">
<div class="todo-input-container">
<div class="todo-input-inner">
<textarea
x-model="newTodoContent"
@keydown.ctrl.enter="createTodo()"
placeholder="새로운 할일을 입력하세요... (Ctrl+Enter로 저장)"
class="todo-textarea w-full"
rows="3"
></textarea>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-500">
<i class="fas fa-lightbulb mr-1"></i>
Ctrl+Enter로 빠르게 저장하세요
</div>
<button
@click="createTodo(); hapticFeedback($event.target)"
:disabled="!newTodoContent.trim() || loading"
class="btn-primary px-6 py-3 text-white rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-plus mr-2"></i>
<span x-show="!loading">추가</span>
<span x-show="loading">
<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...
</span>
</button>
</div>
</div>
</div>
</div>
<!-- 탭 네비게이션 -->
<div class="max-w-4xl mx-auto mb-6">
<!-- 모바일용 스와이프 힌트 -->
<div class="swipe-hint md:hidden">
<i class="fas fa-hand-pointer mr-1"></i>
탭을 눌러서 전환하세요
</div>
<div class="tab-container">
<div class="grid grid-cols-3 gap-1">
<button
@click="activeTab = 'draft'; hapticFeedback($event.target)"
:class="activeTab === 'draft' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
class="px-4 py-3 text-center"
>
<div class="flex flex-col items-center">
<i class="fas fa-inbox text-lg mb-1"></i>
<span class="text-sm font-medium">검토필요</span>
<span class="text-xs opacity-75">(<span x-text="stats.draft_count || 0"></span>)</span>
</div>
</button>
<button
@click="activeTab = 'todo'; hapticFeedback($event.target)"
:class="activeTab === 'todo' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
class="px-4 py-3 text-center"
>
<div class="flex flex-col items-center">
<i class="fas fa-tasks text-lg mb-1"></i>
<span class="text-sm font-medium">TODO</span>
<span class="text-xs opacity-75">(<span x-text="stats.todo_count || 0"></span>)</span>
</div>
</button>
<button
@click="activeTab = 'completed'; hapticFeedback($event.target)"
:class="activeTab === 'completed' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
class="px-4 py-3 text-center"
>
<div class="flex flex-col items-center">
<i class="fas fa-check text-lg mb-1"></i>
<span class="text-sm font-medium">완료된일</span>
<span class="text-xs opacity-75">(<span x-text="stats.completed_count || 0"></span>)</span>
</div>
</button>
</div>
</div>
</div>
<!-- 할일 목록 -->
<div class="max-w-6xl mx-auto">
<!-- 검토필요 탭 -->
<div x-show="activeTab === 'draft'" class="space-y-4">
<template x-for="todo in draftTodos" :key="todo.id">
<div class="todo-card draft bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-draft">검토필요</span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.created_at)"></span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
@click="openScheduleModal(todo)"
class="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
<i class="fas fa-calendar-plus mr-1"></i>일정설정
</button>
<button
@click="openSplitModal(todo)"
class="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
<i class="fas fa-cut mr-1"></i>분할
</button>
</div>
</div>
</div>
</template>
<div x-show="draftTodos.length === 0" class="text-center py-12">
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">검토가 필요한 할일이 없습니다</p>
</div>
</div>
<!-- TODO 탭 -->
<div x-show="activeTab === 'todo'" class="space-y-4">
<template x-for="todo in activeTodos" :key="todo.id">
<div class="todo-card active bg-white rounded-lg shadow-sm p-6" x-data="{ newMemo: '', addingMemo: false }">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2 mb-3">
<span class="status-badge status-active">진행중</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
@click="completeTodo(todo.id); hapticFeedback($event.target)"
class="action-btn bg-green-600 text-white hover:bg-green-700"
>
<i class="fas fa-check mr-1"></i>완료
</button>
<button
@click="openScheduleModal(todo); hapticFeedback($event.target)"
class="action-btn bg-orange-600 text-white hover:bg-orange-700"
>
<i class="fas fa-clock mr-1"></i>지연
</button>
<button
@click="toggleMemo(todo.id); hapticFeedback($event.target)"
class="action-btn bg-indigo-600 text-white hover:bg-indigo-700"
>
<i class="fas fa-sticky-note mr-1"></i>메모
<span x-show="getTodoMemoCount(todo.id) > 0" class="ml-1 bg-white text-indigo-600 rounded-full px-1 text-xs" x-text="getTodoMemoCount(todo.id)"></span>
</button>
</div>
</div>
<!-- 인라인 메모 섹션 -->
<div x-show="showMemoForTodo[todo.id]" x-transition class="mt-4 border-t pt-4">
<!-- 기존 메모들 -->
<div x-show="getTodoMemos(todo.id).length > 0" class="space-y-2 mb-3">
<template x-for="memo in getTodoMemos(todo.id)" :key="memo.id">
<div class="comment-bubble">
<p class="text-sm text-gray-700" x-text="memo.content"></p>
<div class="text-xs text-gray-500 mt-1" x-text="formatRelativeTime(memo.created_at)"></div>
</div>
</template>
</div>
<!-- 새 메모 입력 -->
<div class="flex space-x-2">
<input
type="text"
x-model="newMemo"
placeholder="메모를 입력하세요..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="if(newMemo.trim()) { addingMemo = true; addTodoMemo(todo.id, newMemo).then(() => { newMemo = ''; addingMemo = false; }); }"
>
<button
@click="if(newMemo.trim()) { addingMemo = true; addTodoMemo(todo.id, newMemo).then(() => { newMemo = ''; addingMemo = false; }); hapticFeedback($event.target); }"
:disabled="!newMemo.trim() || addingMemo"
class="action-btn bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
>
<i class="fas fa-plus" x-show="!addingMemo"></i>
<i class="fas fa-spinner fa-spin" x-show="addingMemo"></i>
</button>
</div>
</div>
</div>
</template>
<div x-show="activeTodos.length === 0" class="empty-state">
<i class="fas fa-tasks text-4xl"></i>
<p class="text-lg font-medium">할 일이 없습니다</p>
<p class="text-sm">검토필요에서 일정을 설정해보세요</p>
</div>
</div>
<!-- 예정된일 탭 -->
<div x-show="activeTab === 'scheduled'" class="space-y-4">
<template x-for="todo in scheduledTodos" :key="todo.id">
<div class="todo-card scheduled bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-scheduled">예정됨</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
</div>
</div>
</div>
</div>
</template>
<div x-show="scheduledTodos.length === 0" class="text-center py-12">
<i class="fas fa-calendar text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">예정된 할일이 없습니다</p>
</div>
</div>
<!-- 완료된일 탭 -->
<div x-show="activeTab === 'completed'" class="space-y-4">
<template x-for="todo in completedTodos" :key="todo.id">
<div class="todo-card completed bg-white rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
<div class="flex items-center space-x-2">
<span class="status-badge status-completed">완료</span>
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
<span class="text-xs text-gray-500" x-text="formatDate(todo.completed_at)"></span>
</div>
</div>
</div>
</div>
</template>
<div x-show="completedTodos.length === 0" class="text-center py-12">
<i class="fas fa-check text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500">완료된 할일이 없습니다</p>
</div>
</div>
</div>
</main>
<!-- 일정 설정 모달 -->
<div x-show="showScheduleModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900" x-text="activeTab === 'todo' ? '일정 지연' : '일정 설정'"></h3>
<p class="text-sm text-gray-600 mt-1" x-text="activeTab === 'todo' ? '새로운 날짜와 시간을 설정하세요' : '날짜와 예상 소요시간을 설정하세요'"></p>
</div>
<div class="p-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">시작 날짜</label>
<input
type="date"
x-model="scheduleForm.start_date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">예상 소요시간</label>
<select
x-model="scheduleForm.estimated_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
<option value="15">15분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
<option value="90">1시간 30분</option>
<option value="120">2시간</option>
</select>
<p class="text-xs text-gray-500 mt-1">
<i class="fas fa-info-circle mr-1"></i>
하루 8시간 초과 시 경고가 표시됩니다
</p>
</div>
<div class="flex space-x-3">
<button
@click="scheduleTodo(); hapticFeedback($event.target)"
:disabled="!scheduleForm.start_date || !scheduleForm.estimated_minutes"
class="flex-1 btn-primary px-4 py-2 text-white rounded-lg disabled:opacity-50"
x-text="activeTab === 'todo' ? '지연 설정' : '일정 설정'"
>
</button>
<button
@click="closeScheduleModal(); hapticFeedback($event.target)"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
취소
</button>
</div>
</div>
</div>
</div>
<!-- 분할 모달 -->
<div x-show="showSplitModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">할일 분할</h3>
<p class="text-sm text-gray-600 mt-1">2시간 이상의 작업을 작은 단위로 나누어 관리하세요</p>
</div>
<div class="p-6">
<div class="space-y-4 mb-6">
<template x-for="(subtask, index) in splitForm.subtasks" :key="index">
<div class="flex items-center space-x-3">
<div class="flex-1">
<input
type="text"
x-model="splitForm.subtasks[index]"
:placeholder="`하위 작업 ${index + 1}`"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
</div>
<select
x-model="splitForm.estimated_minutes_per_task[index]"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="1">1분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
</select>
<button
x-show="splitForm.subtasks.length > 2"
@click="splitForm.subtasks.splice(index, 1); splitForm.estimated_minutes_per_task.splice(index, 1)"
class="px-2 py-2 text-red-600 hover:bg-red-50 rounded"
>
<i class="fas fa-trash"></i>
</button>
</div>
</template>
</div>
<div class="flex items-center justify-between mb-6">
<button
@click="splitForm.subtasks.push(''); splitForm.estimated_minutes_per_task.push(30)"
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded-md hover:bg-gray-300"
>
<i class="fas fa-plus mr-1"></i>하위 작업 추가
</button>
<span class="text-xs text-gray-500">최대 10개까지 가능</span>
</div>
<div class="flex space-x-3">
<button
@click="splitTodo()"
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
분할하기
</button>
<button
@click="closeSplitModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
취소
</button>
</div>
</div>
</div>
</div>
<!-- 댓글 모달 -->
<div x-show="showCommentModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div class="p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">메모 작성</h3>
</div>
<div class="p-6">
<!-- 기존 댓글들 -->
<div x-show="currentTodoComments.length > 0" class="mb-4 space-y-3">
<template x-for="comment in currentTodoComments" :key="comment.id">
<div class="comment-bubble">
<p class="text-gray-900 text-sm" x-text="comment.content"></p>
<p class="text-xs text-gray-500 mt-2" x-text="formatDate(comment.created_at)"></p>
</div>
</template>
</div>
<!-- 새 댓글 입력 -->
<div class="mb-4">
<textarea
x-model="commentForm.content"
placeholder="메모를 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
rows="3"
></textarea>
</div>
<div class="flex space-x-3">
<button
@click="addComment()"
:disabled="!commentForm.content.trim()"
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
메모 추가
</button>
<button
@click="closeCommentModal()"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
닫기
</button>
</div>
</div>
</div>
</div>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
<!-- API 및 앱 스크립트 -->
<script src="static/js/api.js?v=2025012627"></script>
<script src="static/js/todos.js?v=2025012627"></script>
</body>
</html>

View File

@@ -38,7 +38,7 @@
<div id="header-container"></div> <div id="header-container"></div>
<!-- 메인 컨텐츠 --> <!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 --> <!-- 페이지 헤더 -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center space-x-4 mb-4"> <div class="flex items-center space-x-4 mb-4">

View File

@@ -0,0 +1,520 @@
<!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>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
</head>
<body class="bg-gray-50 min-h-screen" x-data="userManagementApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- 페이지 제목 -->
<div class="mb-8 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">사용자 관리</h1>
<p class="text-gray-600">시스템 사용자를 관리하고 권한을 설정하세요.</p>
</div>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<i class="fas fa-plus mr-2"></i>새 사용자 추가
</button>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- 사용자 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-medium text-gray-900">사용자 목록</h2>
<div class="text-sm text-gray-500">
<span x-text="users.length"></span>명의 사용자
</div>
</div>
<!-- 사용자 테이블 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">역할</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">권한</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">가입일</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-for="user in users" :key="user.id">
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center mr-4">
<i class="fas fa-user text-blue-600"></i>
</div>
<div>
<div class="text-sm font-medium text-gray-900" x-text="user.full_name || user.email"></div>
<div class="text-sm text-gray-500" x-text="user.email"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
<span x-text="getRoleText(user.role)"></span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-1">
<span x-show="user.can_manage_books" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-book mr-1"></i>서적
</span>
<span x-show="user.can_manage_notes" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-sticky-note mr-1"></i>노트
</span>
<span x-show="user.can_manage_novels" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-feather-alt mr-1"></i>소설
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
<i :class="user.is_active ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
<span x-text="user.is_active ? '활성' : '비활성'"></span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span x-text="formatDate(user.created_at)"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button @click="editUser(user)" class="text-blue-600 hover:text-blue-900">
<i class="fas fa-edit"></i>
</button>
<button @click="confirmDeleteUser(user)"
x-show="user.role !== 'root'"
class="text-red-600 hover:text-red-900">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 사용자 생성 모달 -->
<div x-show="showCreateModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-4">새 사용자 추가</h3>
<form @submit.prevent="createUser()" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
<input type="email" x-model="createForm.email" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input type="password" x-model="createForm.password" required minlength="6"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input type="text" x-model="createForm.full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">역할</label>
<select x-model="createForm.role"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="user">사용자</option>
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
<select x-model="createForm.session_timeout_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="5">5분 (기본)</option>
<option value="15">15분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
<option value="120">2시간</option>
<option value="480">8시간</option>
<option value="1440">24시간</option>
<option value="0">무제한</option>
</select>
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" x-model="createForm.can_manage_books" class="mr-2">
<span class="text-sm">서적 관리</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="createForm.can_manage_notes" class="mr-2">
<span class="text-sm">노트 관리</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="createForm.can_manage_novels" class="mr-2">
<span class="text-sm">소설 관리</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showCreateModal = false"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" :disabled="createLoading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
<i class="fas fa-spinner fa-spin mr-2" x-show="createLoading"></i>
<span x-text="createLoading ? '생성 중...' : '사용자 생성'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 사용자 수정 모달 -->
<div x-show="showEditModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-4">사용자 수정</h3>
<form @submit.prevent="updateUser()" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
<input type="email" x-model="editForm.email" disabled
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input type="text" x-model="editForm.full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">역할</label>
<select x-model="editForm.role"
:disabled="editForm.role === 'root'"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50">
<option value="user">사용자</option>
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
<option value="root" x-show="editForm.role === 'root'">시스템 관리자</option>
</select>
</div>
<div>
<label class="flex items-center mb-2">
<input type="checkbox" x-model="editForm.is_active" class="mr-2">
<span class="text-sm font-medium text-gray-700">계정 활성화</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
<select x-model="editForm.session_timeout_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="5">5분 (기본)</option>
<option value="15">15분</option>
<option value="30">30분</option>
<option value="60">1시간</option>
<option value="120">2시간</option>
<option value="480">8시간</option>
<option value="1440">24시간</option>
<option value="0">무제한</option>
</select>
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" x-model="editForm.can_manage_books" class="mr-2">
<span class="text-sm">서적 관리</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="editForm.can_manage_notes" class="mr-2">
<span class="text-sm">노트 관리</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="editForm.can_manage_novels" class="mr-2">
<span class="text-sm">소설 관리</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showEditModal = false"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" :disabled="editLoading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
<i class="fas fa-spinner fa-spin mr-2" x-show="editLoading"></i>
<span x-text="editLoading ? '수정 중...' : '사용자 수정'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div x-show="showDeleteModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">사용자 삭제</h3>
<p class="text-sm text-gray-500 mb-4">
<span x-text="deleteTarget?.full_name || deleteTarget?.email"></span> 사용자를 삭제하시겠습니까?<br>
이 작업은 되돌릴 수 없습니다.
</p>
<div class="flex justify-center space-x-3">
<button @click="showDeleteModal = false"
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button @click="deleteUser()" :disabled="deleteLoading"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
<i class="fas fa-spinner fa-spin mr-2" x-show="deleteLoading"></i>
<span x-text="deleteLoading ? '삭제 중...' : '삭제'"></span>
</button>
</div>
</div>
</div>
</div>
<!-- 공통 스크립트 -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<!-- 사용자 관리 스크립트 -->
<script>
function userManagementApp() {
return {
users: [],
currentUser: {},
showCreateModal: false,
showEditModal: false,
showDeleteModal: false,
createLoading: false,
editLoading: false,
deleteLoading: false,
deleteTarget: null,
createForm: {
email: '',
password: '',
full_name: '',
role: 'user',
can_manage_books: true,
can_manage_notes: true,
can_manage_novels: true,
session_timeout_minutes: 5
},
editForm: {},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔧 사용자 관리 앱 초기화');
await this.loadCurrentUser();
await this.loadUsers();
},
async loadCurrentUser() {
try {
this.currentUser = await api.get('/users/me');
// 관리자 권한 확인
if (!this.currentUser.is_admin) {
window.location.href = 'index.html';
return;
}
// 헤더 사용자 메뉴 업데이트
if (window.updateUserMenu) {
window.updateUserMenu(this.currentUser);
}
} catch (error) {
console.error('❌ 현재 사용자 정보 로드 실패:', error);
window.location.href = 'index.html';
}
},
async loadUsers() {
try {
this.users = await api.get('/users/');
console.log('✅ 사용자 목록 로드 완료:', this.users.length, '명');
} catch (error) {
console.error('❌ 사용자 목록 로드 실패:', error);
this.showNotification('사용자 목록을 불러올 수 없습니다.', 'error');
}
},
async createUser() {
this.createLoading = true;
try {
await api.post('/users/', this.createForm);
// 폼 초기화
this.createForm = {
email: '',
password: '',
full_name: '',
role: 'user',
can_manage_books: true,
can_manage_notes: true,
can_manage_novels: true,
session_timeout_minutes: 5
};
this.showCreateModal = false;
await this.loadUsers();
this.showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
console.log('✅ 사용자 생성 완료');
} catch (error) {
console.error('❌ 사용자 생성 실패:', error);
this.showNotification('사용자 생성에 실패했습니다.', 'error');
} finally {
this.createLoading = false;
}
},
editUser(user) {
this.editForm = { ...user };
this.showEditModal = true;
},
async updateUser() {
this.editLoading = true;
try {
await api.put(`/users/${this.editForm.id}`, {
full_name: this.editForm.full_name,
role: this.editForm.role,
is_active: this.editForm.is_active,
can_manage_books: this.editForm.can_manage_books,
can_manage_notes: this.editForm.can_manage_notes,
can_manage_novels: this.editForm.can_manage_novels,
session_timeout_minutes: this.editForm.session_timeout_minutes
});
this.showEditModal = false;
await this.loadUsers();
this.showNotification('사용자 정보가 성공적으로 수정되었습니다.', 'success');
console.log('✅ 사용자 수정 완료');
} catch (error) {
console.error('❌ 사용자 수정 실패:', error);
this.showNotification('사용자 수정에 실패했습니다.', 'error');
} finally {
this.editLoading = false;
}
},
confirmDeleteUser(user) {
this.deleteTarget = user;
this.showDeleteModal = true;
},
async deleteUser() {
this.deleteLoading = true;
try {
await api.delete(`/users/${this.deleteTarget.id}`);
this.showDeleteModal = false;
this.deleteTarget = null;
await this.loadUsers();
this.showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
console.log('✅ 사용자 삭제 완료');
} catch (error) {
console.error('❌ 사용자 삭제 실패:', error);
this.showNotification('사용자 삭제에 실패했습니다.', 'error');
} finally {
this.deleteLoading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
},
getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('ko-KR');
}
};
}
</script>
</body>
</html>

View File

@@ -266,13 +266,210 @@
</div> </div>
<!-- 문서 내용 --> <!-- 문서 내용 -->
<div x-show="!loading && !error" id="document-content" class="prose max-w-none"> <div x-show="!loading && !error">
<!-- 문서 HTML이 여기에 로드됩니다 --> <!-- HTML 문서 내용 -->
<div x-show="contentType === 'document' && document && !document.pdf_path"
id="document-content" class="prose max-w-none">
<!-- 문서 HTML이 여기에 로드됩니다 -->
</div>
<!-- PDF 뷰어 -->
<div x-show="contentType === 'document' && document && document.pdf_path"
class="pdf-viewer-container">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
<h1 class="text-2xl font-bold text-gray-900" x-text="document?.title"></h1>
</div>
<div class="flex items-center space-x-2">
<button @click="openPdfSearchModal()"
class="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2">
<i class="fas fa-search"></i>
<span>PDF에서 검색</span>
</button>
<button @click="downloadOriginalFile()"
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<i class="fas fa-download"></i>
<span>다운로드</span>
</button>
</div>
</div>
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-white relative" style="min-height: 800px;">
<!-- PDF.js 뷰어 -->
<div x-show="!pdfError && !pdfLoading && pdfSrc" class="w-full h-full">
<!-- PDF 뷰어 툴바 -->
<div class="bg-gray-100 border-b px-4 py-2 flex items-center justify-between">
<div class="flex items-center space-x-4">
<button @click="previousPage()" :disabled="currentPage <= 1"
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
<i class="fas fa-chevron-left"></i>
</button>
<span class="text-sm">
<input type="number" x-model="currentPage" @change="goToPage(currentPage)"
class="w-16 px-2 py-1 border rounded text-center" min="1" :max="totalPages">
/ <span x-text="totalPages"></span>
</span>
<button @click="nextPage()" :disabled="currentPage >= totalPages"
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="flex items-center space-x-2">
<button @click="zoomOut()" class="px-2 py-1 bg-gray-600 text-white rounded">
<i class="fas fa-search-minus"></i>
</button>
<span class="text-sm" x-text="Math.round(pdfScale * 100) + '%'"></span>
<button @click="zoomIn()" class="px-2 py-1 bg-gray-600 text-white rounded">
<i class="fas fa-search-plus"></i>
</button>
</div>
</div>
<!-- PDF 캔버스 -->
<div class="overflow-auto" style="height: 750px;">
<canvas id="pdf-canvas" class="mx-auto block"></canvas>
</div>
</div>
<!-- 기존 iframe (폴백용) -->
<iframe id="pdf-viewer-iframe"
x-show="false"
class="w-full border-0"
style="height: 800px;"
:src="pdfSrc"
@load="pdfLoaded = true; console.log('PDF iframe 로드 완료')"
@error="handlePdfError()"
allow="fullscreen">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfLoading" class="flex items-center justify-center h-full" style="min-height: 400px;">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
</div>
</div>
<!-- PDF 에러 상태 -->
<div x-show="pdfError" class="flex items-center justify-center h-full text-gray-500" style="min-height: 400px;">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
<button @click="retryPdfLoad()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
다시 시도
</button>
<button @click="downloadOriginalFile()"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
파일 다운로드
</button>
</div>
</div>
</div>
</div>
<!-- 노트 문서 내용 -->
<div x-show="contentType === 'note'"
id="note-content" class="prose max-w-none">
<!-- 노트 내용이 여기에 로드됩니다 -->
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<!-- PDF 검색 모달 -->
<div x-show="showPdfSearchModal"
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 z-50 flex items-center justify-center p-4"
@click.self="showPdfSearchModal = false">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<!-- 헤더 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h3 class="text-xl font-bold text-gray-900 flex items-center space-x-2">
<i class="fas fa-search text-green-600"></i>
<span>PDF에서 검색</span>
</h3>
<button @click="showPdfSearchModal = false"
class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 내용 -->
<div class="p-6">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">검색어</label>
<div class="relative">
<input type="text"
x-model="pdfSearchQuery"
@keydown.enter="searchInPdf()"
@input="pdfSearchResults = []"
placeholder="예: pressure, vessel, design..."
class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
x-ref="searchInput">
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
<i class="fas fa-search text-gray-400"></i>
</div>
</div>
<div class="mt-1 text-xs text-gray-500">
Enter 키를 누르거나 검색 버튼을 클릭하세요
</div>
</div>
<!-- 검색 결과 -->
<div x-show="pdfSearchResults.length > 0" class="mb-4">
<div class="text-sm text-gray-600 mb-2">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<span x-text="pdfSearchResults.length"></span>개의 결과를 찾았습니다.
</div>
<div class="max-h-40 overflow-y-auto space-y-2">
<template x-for="(result, index) in pdfSearchResults" :key="index">
<div class="p-3 bg-gray-50 rounded cursor-pointer hover:bg-green-50 border hover:border-green-200 transition-colors"
@click="jumpToPdfResult(result)">
<div class="text-sm font-medium text-green-700">
<i class="fas fa-file-pdf mr-1"></i>
페이지 <span x-text="result.page"></span>
</div>
<div class="text-xs text-gray-600 mt-1" x-text="result.context"></div>
</div>
</template>
</div>
</div>
<!-- 검색 결과 없음 -->
<div x-show="pdfSearchQuery.trim() && !pdfSearchLoading && pdfSearchResults.length === 0" class="mb-4">
<div class="text-center py-4 text-gray-500">
<i class="fas fa-search text-2xl mb-2"></i>
<p class="text-sm">검색 결과가 없습니다.</p>
<p class="text-xs mt-1">다른 검색어로 시도해보세요.</p>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3">
<button @click="searchInPdf()"
:disabled="!pdfSearchQuery.trim() || pdfSearchLoading"
class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
<i :class="pdfSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'" class="mr-2"></i>
<span x-text="pdfSearchLoading ? '검색 중...' : '검색'"></span>
</button>
<button @click="showPdfSearchModal = false"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors">
닫기
</button>
</div>
</div>
</div>
</div>
<!-- 링크 모달 --> <!-- 링크 모달 -->
<div x-show="showLinksModal" <div x-show="showLinksModal"
x-transition:enter="transition ease-out duration-300" x-transition:enter="transition ease-out duration-300"
@@ -311,29 +508,45 @@
<template x-for="link in documentLinks" :key="link.id"> <template x-for="link in documentLinks" :key="link.id">
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors" <div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
@click="navigateToLink(link)"> @click="navigateToLink(link)">
<div class="flex items-center justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div> <!-- 대상 문서 제목 -->
<div class="font-medium text-purple-700 mb-2 flex items-center">
<!-- 선택된 텍스트 또는 문서 전체 링크 --> <span x-text="link.target_document_title || link.target_note_title"></span>
<div x-show="link.selected_text" class="mb-2"> <span x-show="link.target_content_type === 'note'" class="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">노트</span>
<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> <span x-show="link.target_content_type === 'document'" class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-1 rounded">문서</span>
</div> </div>
<div x-show="!link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div> <!-- 현재 문서에서 선택한 텍스트 (출발점) -->
<div x-show="link.selected_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">📍 현재 문서에서 선택한 텍스트:</div>
<div class="text-sm text-gray-700 bg-purple-50 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
</div>
<!-- 대상 문서의 텍스트 (도착점) -->
<div x-show="link.target_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">🎯 대상 문서의 텍스트:</div>
<div class="text-sm text-gray-700 bg-blue-50 px-3 py-2 rounded border-l-4 border-blue-500" x-text="link.target_text"></div>
</div>
<!-- 문서 전체 링크인 경우 -->
<div x-show="!link.selected_text && !link.target_text" class="mb-3">
<div class="text-sm text-gray-600 italic bg-gray-50 px-3 py-2 rounded">📄 문서 전체 링크</div>
</div> </div>
<!-- 설명 --> <!-- 설명 -->
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div> <div x-show="link.description" class="mb-3">
<div class="text-xs text-gray-500 mb-1">💬 설명:</div>
<div class="text-sm text-gray-600 bg-yellow-50 px-3 py-2 rounded" x-text="link.description"></div>
</div>
<!-- 링크 타입과 날짜 --> <!-- 링크 타입과 날짜 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between text-xs text-gray-500">
<span class="text-xs text-gray-500" <span x-text="link.link_type === 'text_fragment' ? '🔗 텍스트 조각 링크' : '📄 문서 링크'"></span>
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span> <span x-text="formatDate(link.created_at)"></span>
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
</div> </div>
</div> </div>
<div class="ml-3"> <div class="ml-3 flex-shrink-0">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> <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> </svg>
@@ -376,15 +589,39 @@
<p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p> <p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p>
</div> </div>
<!-- 서적 선택 --> <!-- 링크 대상 타입 선택 -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">서적 선택</label> <label class="block text-sm font-semibold text-gray-700 mb-3">링크 대상 타입</label>
<div class="flex space-x-4">
<label class="flex items-center">
<input type="radio"
x-model="linkForm.target_type"
value="document"
@change="onTargetTypeChange()"
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="linkForm.target_type"
value="note"
@change="onTargetTypeChange()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">📝 노트북 노트</span>
</label>
</div>
</div>
<!-- 서적/노트북 선택 -->
<div class="mb-6" x-show="linkForm.target_type">
<label class="block text-sm font-semibold text-gray-700 mb-3"
x-text="linkForm.target_type === 'note' ? '노트북 선택' : '서적 선택'"></label>
<select <select
x-model="linkForm.target_book_id" x-model="linkForm.target_book_id"
@change="loadDocumentsFromBook()" @change="loadDocumentsFromBook()"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
> >
<option value="">서적을 선택하세요</option> <option value="" x-text="linkForm.target_type === 'note' ? '노트북을 선택하세요' : '서적을 선택하세요'"></option>
<template x-for="book in availableBooks" :key="book.id"> <template x-for="book in availableBooks" :key="book.id">
<option :value="book.id" x-text="book.title"></option> <option :value="book.id" x-text="book.title"></option>
</template> </template>
@@ -404,8 +641,10 @@
:disabled="!linkForm.target_book_id" :disabled="!linkForm.target_book_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed"> class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value=""> <option value="">
<span x-show="!linkForm.target_book_id">먼저 서적을 선택하세요</span> <span x-show="!linkForm.target_book_id"
<span x-show="linkForm.target_book_id">문서를 선택하세요</span> x-text="linkForm.target_type === 'note' ? '먼저 노트북을 선택하세요' : '먼저 서적을 선택하세요'"></span>
<span x-show="linkForm.target_book_id"
x-text="linkForm.target_type === 'note' ? '노트를 선택하세요' : '문서를 선택하세요'"></span>
</option> </option>
<template x-for="doc in filteredDocuments" :key="doc.id"> <template x-for="doc in filteredDocuments" :key="doc.id">
<option :value="doc.id" x-text="doc.title"></option> <option :value="doc.id" x-text="doc.title"></option>
@@ -501,8 +740,8 @@
<div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer" <div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer"
@click="scrollToHighlight(note.highlight.id)"> @click="scrollToHighlight(note.highlight.id)">
<!-- 선택된 텍스트 --> <!-- 선택된 텍스트 -->
<div class="bg-blue-50 rounded-md p-2 mb-3"> <div class="bg-blue-50 rounded-md p-2 mb-3" x-show="note.highlight?.selected_text">
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight.selected_text"></p> <p class="text-sm text-blue-800 font-medium" x-text="note.highlight?.selected_text || ''"></p>
</div> </div>
<!-- 메모 내용 --> <!-- 메모 내용 -->
@@ -731,22 +970,25 @@
</div> </div>
<!-- 스크립트 --> <!-- 스크립트 -->
<script src="/static/js/api.js?v=2025012614"></script> <script src="/static/js/api.js?v=2025012618"></script>
<!-- 캐시 및 성능 최적화 시스템 --> <!-- 캐시 및 성능 최적화 시스템 -->
<script src="/static/js/viewer/utils/cache-manager.js?v=2025012607"></script> <script src="/static/js/viewer/utils/cache-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/utils/cached-api.js?v=2025012607"></script> <script src="/static/js/viewer/utils/cached-api.js?v=2025012618"></script>
<script src="/static/js/viewer/utils/module-loader.js?v=2025012607"></script> <script src="/static/js/viewer/utils/module-loader.js?v=2025012607"></script>
<!-- 모든 모듈들 직접 로드 --> <!-- 모든 모듈들 직접 로드 -->
<script src="/static/js/viewer/core/document-loader.js?v=2025012607"></script> <script src="/static/js/viewer/core/document-loader.js?v=2025012624"></script>
<script src="/static/js/viewer/features/ui-manager.js?v=2025012607"></script> <script src="/static/js/viewer/features/ui-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012607"></script> <script src="/static/js/viewer/features/highlight-manager.js?v=2025012619"></script>
<script src="/static/js/viewer/features/link-manager.js?v=2025012607"></script> <script src="/static/js/viewer/features/link-manager.js?v=2025012622"></script>
<script src="/static/js/viewer/features/bookmark-manager.js?v=2025012607"></script> <script src="/static/js/viewer/features/bookmark-manager.js?v=2025012607"></script>
<!-- ViewerCore (Alpine.js 컴포넌트) --> <!-- ViewerCore (Alpine.js 컴포넌트) -->
<script src="/static/js/viewer/viewer-core.js?v=2025012607"></script> <script src="/static/js/viewer/viewer-core.js?v=2025012626"></script>
<!-- PDF.js 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<!-- Alpine.js 프레임워크 --> <!-- Alpine.js 프레임워크 -->
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>

View File

@@ -11,6 +11,36 @@ server {
access_log off; access_log off;
} }
# PDF 파일 요청 (iframe 허용)
location ~ ^/api/documents/[^/]+/pdf$ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 타임아웃 설정
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 버퍼링 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 리다이렉트 방지
proxy_redirect off;
# PDF iframe 허용 및 인라인 표시 설정
add_header X-Frame-Options "SAMEORIGIN" always;
# PDF 파일이 다운로드되지 않고 브라우저에서 표시되도록 설정
location ~ \.pdf$ {
add_header Content-Disposition "inline";
}
}
# API 요청을 백엔드로 프록시 # API 요청을 백엔드로 프록시
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
@@ -28,6 +58,24 @@ server {
proxy_buffering on; proxy_buffering on;
proxy_buffer_size 4k; proxy_buffer_size 4k;
proxy_buffers 8 4k; proxy_buffers 8 4k;
# 리다이렉트 방지
proxy_redirect off;
# CORS 헤더 추가
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
# OPTIONS 요청 처리
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
} }
# 업로드된 문서 파일 서빙 # 업로드된 문서 파일 서빙

View File

@@ -45,8 +45,7 @@ http {
application/atom+xml application/atom+xml
image/svg+xml; image/svg+xml;
# 보안 헤더 # 보안 헤더 (PDF 파일은 제외)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;

33
scripts/backup.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Document Server 데이터베이스 백업 스크립트
# 시놀로지 NAS 환경에서 사용
BACKUP_DIR="/volume1/docker/document-server/backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
CONTAINER_NAME="document-server-db"
# 백업 디렉토리 생성
mkdir -p "$BACKUP_DIR"
echo "🔄 데이터베이스 백업 시작: $TIMESTAMP"
# PostgreSQL 백업
docker exec $CONTAINER_NAME pg_dump -U docuser -d document_db > "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
# 압축
gzip "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
# 7일 이상 된 백업 파일 삭제
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
echo "✅ 백업 완료: $BACKUP_DIR/document_db_$TIMESTAMP.sql.gz"
# 업로드 파일 백업 (선택사항)
if [ "$1" = "--include-uploads" ]; then
echo "🔄 업로드 파일 백업 시작..."
tar -czf "$BACKUP_DIR/uploads_$TIMESTAMP.tar.gz" -C /volume1/docker/document-server uploads/
echo "✅ 업로드 파일 백업 완료: $BACKUP_DIR/uploads_$TIMESTAMP.tar.gz"
fi
echo "🎉 전체 백업 작업 완료"

198
scripts/cleanup-for-production.sh Executable file
View File

@@ -0,0 +1,198 @@
#!/bin/bash
# =============================================================================
# Document Server - 프로덕션 배포용 정리 스크립트
# 테스트 파일, 개발용 데이터, 로그 파일 등을 정리합니다
# =============================================================================
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# 환경 설정
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "=== 🧹 프로덕션 배포용 정리 시작 ==="
echo ""
# 1. 테스트 파일 제거
log_info "🗑️ 테스트 파일 제거 중..."
# 테스트 HTML 파일들
TEST_FILES=(
"test-document.html"
"test-upload.html"
"test.html"
"cache-buster.html"
"frontend/test-upload.html"
"frontend/test.html"
)
for file in "${TEST_FILES[@]}"; do
if [ -f "$file" ]; then
rm "$file"
log_success "제거: $file"
fi
done
# 2. 개발용 이미지 파일 제거
log_info "🖼️ 테스트 이미지 파일 제거 중..."
# RAF 이미지 파일들 (테스트용)
find . -name "*.RAF_compressed.JPEG" -delete 2>/dev/null || true
find . -name "*.RAF" -delete 2>/dev/null || true
log_success "테스트 이미지 파일 제거 완료"
# 3. 로그 파일 정리
log_info "📝 로그 파일 정리 중..."
LOG_FILES=(
"backend.log"
"frontend.log"
)
for file in "${LOG_FILES[@]}"; do
if [ -f "$file" ]; then
rm "$file"
log_success "제거: $file"
fi
done
# 4. Python 캐시 파일 정리
log_info "🐍 Python 캐시 파일 정리 중..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true
find . -name "*.pyo" -delete 2>/dev/null || true
log_success "Python 캐시 파일 정리 완료"
# 5. 개발용 업로드 파일 정리
log_info "📁 개발용 업로드 파일 정리 중..."
# 백엔드 uploads 디렉토리
if [ -d "backend/uploads" ]; then
rm -rf backend/uploads/documents/* 2>/dev/null || true
rm -rf backend/uploads/thumbnails/* 2>/dev/null || true
log_success "백엔드 업로드 파일 정리 완료"
fi
# 프론트엔드 uploads 디렉토리
if [ -d "frontend/uploads" ]; then
rm -rf frontend/uploads/* 2>/dev/null || true
log_success "프론트엔드 업로드 파일 정리 완료"
fi
# 루트 uploads 디렉토리
if [ -d "uploads" ]; then
rm -rf uploads/documents/* 2>/dev/null || true
rm -rf uploads/pdfs/* 2>/dev/null || true
rm -rf uploads/thumbnails/* 2>/dev/null || true
log_success "루트 업로드 파일 정리 완료"
fi
# 6. 가상환경 제거 (프로덕션에서는 Docker 사용)
log_info "🐍 가상환경 제거 중..."
if [ -d "backend/venv" ]; then
rm -rf backend/venv
log_success "가상환경 제거 완료"
fi
# 7. 개발용 설정 파일 정리
log_info "⚙️ 개발용 설정 파일 정리 중..."
# 환경 변수 파일들 (프로덕션에서 새로 생성)
DEV_CONFIG_FILES=(
".env"
".env.local"
".env.development"
"backend/.env"
)
for file in "${DEV_CONFIG_FILES[@]}"; do
if [ -f "$file" ]; then
rm "$file"
log_success "제거: $file"
fi
done
# 8. 불필요한 문서 파일 정리
log_info "📚 개발용 문서 파일 정리 중..."
DEV_DOCS=(
"VIEWER_REFACTORING.md"
)
for file in "${DEV_DOCS[@]}"; do
if [ -f "$file" ]; then
rm "$file"
log_success "제거: $file"
fi
done
# 9. 빈 디렉토리 정리
log_info "📂 빈 디렉토리 정리 중..."
find . -type d -empty -delete 2>/dev/null || true
# 10. 권한 정리
log_info "🔐 파일 권한 정리 중..."
# 스크립트 파일 실행 권한 확인
chmod +x scripts/*.sh 2>/dev/null || true
# 설정 파일 권한 설정
find . -name "*.conf" -exec chmod 644 {} \; 2>/dev/null || true
find . -name "*.yml" -exec chmod 644 {} \; 2>/dev/null || true
find . -name "*.yaml" -exec chmod 644 {} \; 2>/dev/null || true
log_success "파일 권한 정리 완료"
# 11. 정리 결과 요약
echo ""
echo "=== 📊 정리 결과 ==="
# 현재 디렉토리 크기
TOTAL_SIZE=$(du -sh . | cut -f1)
echo "전체 크기: $TOTAL_SIZE"
# 주요 디렉토리 크기
echo ""
echo "주요 디렉토리:"
du -sh backend frontend scripts nginx 2>/dev/null | while read size dir; do
echo " $dir: $size"
done
echo ""
echo "=== ✅ 정리 완료 ==="
echo ""
echo "🚀 이제 프로덕션 배포 준비가 완료되었습니다!"
echo ""
echo "다음 단계:"
echo "1. NAS에 업로드"
echo "2. ./scripts/deploy-synology.sh 실행"
echo "3. 배포 완료!"
log_success "프로덕션 정리 스크립트 완료"

399
scripts/deploy-synology.sh Executable file
View File

@@ -0,0 +1,399 @@
#!/bin/bash
# =============================================================================
# Document Server - Synology DS1525+ 최적화 배포 스크립트
#
# 하드웨어 사양:
# - CPU: AMD Ryzen R1600 (4코어/8스레드)
# - RAM: 32GB DDR4 ECC
# - SSD: 읽기/쓰기 캐시 활성화
# - Storage: Volume1(SSD), Volume2(HDD)
# =============================================================================
set -e # 에러 발생 시 스크립트 중단
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 로그 함수
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 환경 변수 설정
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="docker-compose.synology.yml"
# 환경 변수 설정 확인
ENV_FILE="$PROJECT_DIR/.env.synology"
if [ ! -f "$ENV_FILE" ]; then
log_info "환경 변수 파일이 없습니다. 설정을 시작합니다..."
"$SCRIPT_DIR/setup-env.sh"
fi
# 환경 변수 로드
if [ -f "$ENV_FILE" ]; then
log_info "환경 변수 파일을 로드합니다: $ENV_FILE"
set -a # 자동으로 export
source "$ENV_FILE"
set +a
else
log_error "환경 변수 파일을 찾을 수 없습니다"
exit 1
fi
log_info "🚀 Synology DS1525+ 최적화 배포 시작"
log_info "📁 프로젝트 디렉토리: $PROJECT_DIR"
# 1. 시스템 요구사항 확인
log_info "🔍 시스템 요구사항 확인 중..."
# Docker 및 Docker Compose 확인
if ! command -v docker &> /dev/null; then
log_error "Docker가 설치되지 않았습니다."
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
log_error "Docker Compose가 설치되지 않았습니다."
exit 1
fi
# 메모리 확인 (최소 16GB 권장)
TOTAL_MEM=$(free -g | awk '/^Mem:/{print $2}')
if [ "$TOTAL_MEM" -lt 16 ]; then
log_warning "메모리가 ${TOTAL_MEM}GB입니다. 최소 16GB를 권장합니다."
fi
log_success "시스템 요구사항 확인 완료"
# 2. 디렉토리 구조 생성
log_info "📂 디렉토리 구조 생성 중..."
# SSD 디렉토리 (성능 최우선) - Volume3
SSD_DIRS=(
"/volume3/docker/document-server/database"
"/volume3/docker/document-server/redis"
"/volume3/docker/document-server/logs"
"/volume3/docker/document-server/logs/nginx"
"/volume3/docker/document-server/config"
"/volume3/docker/document-server/nginx/conf.d"
"/volume3/docker/document-server/nginx/cache"
"/volume3/docker/document-server/cache"
)
# HDD 디렉토리 (대용량 저장) - Volume1
HDD_DIRS=(
"/volume1/document-storage/uploads"
"/volume1/document-storage/documents"
"/volume1/document-storage/thumbnails"
"/volume1/document-storage/backups"
"/volume1/document-storage/archives"
)
# SSD 디렉토리 생성
for dir in "${SSD_DIRS[@]}"; do
if [ ! -d "$dir" ]; then
sudo mkdir -p "$dir"
log_info "SSD 디렉토리 생성: $dir"
fi
done
# HDD 디렉토리 생성
for dir in "${HDD_DIRS[@]}"; do
if [ ! -d "$dir" ]; then
sudo mkdir -p "$dir"
log_info "HDD 디렉토리 생성: $dir"
fi
done
# 권한 설정
sudo chown -R 1000:1000 /volume3/docker/document-server/
sudo chown -R 1000:1000 /volume1/document-storage/
log_success "디렉토리 구조 생성 완료"
# 3. 설정 파일 복사
log_info "⚙️ 설정 파일 생성 중..."
# PostgreSQL 설정 (32GB RAM 최적화)
cat > /volume3/docker/document-server/config/postgresql.synology.conf << 'EOF'
# PostgreSQL 설정 - Synology DS1525+ 32GB RAM 최적화
# 메모리 설정 (32GB RAM 기준)
shared_buffers = 8GB # RAM의 25%
effective_cache_size = 24GB # RAM의 75%
work_mem = 512MB # 복잡한 쿼리용 (증가)
maintenance_work_mem = 4GB # 인덱스 구축용 (증가)
# 체크포인트 설정 (SSD 최적화)
checkpoint_completion_target = 0.9
wal_buffers = 128MB # WAL 버퍼 (증가)
checkpoint_timeout = 15min
max_wal_size = 4GB
min_wal_size = 1GB
# SSD 최적화
random_page_cost = 1.1 # SSD 환경
effective_io_concurrency = 200 # SSD 동시 I/O
seq_page_cost = 1.0
# 병렬 처리 (4코어/8스레드 최적화)
max_worker_processes = 8
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_parallel_maintenance_workers = 4
# 연결 설정
max_connections = 200
shared_preload_libraries = 'pg_stat_statements'
# 로깅 설정
log_min_duration_statement = 1000 # 1초 이상 쿼리 로깅
log_checkpoints = on
log_connections = on
log_disconnections = on
log_lock_waits = on
# 자동 VACUUM 설정
autovacuum = on
autovacuum_max_workers = 4
autovacuum_naptime = 30s
EOF
# Nginx 설정 (SSD 캐시 최적화)
cat > /volume3/docker/document-server/nginx/conf.d/default.conf << 'EOF'
# Nginx 설정 - SSD 캐시 최적화
# 업스트림 백엔드
upstream backend {
server backend:8000;
keepalive 32;
}
# 캐시 존 정의 (SSD에 저장)
proxy_cache_path /var/cache/nginx/documents
levels=1:2
keys_zone=documents:100m
max_size=2g
inactive=60m
use_temp_path=off;
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api:50m
max_size=500m
inactive=10m
use_temp_path=off;
server {
listen 80;
server_name _;
# 클라이언트 설정
client_max_body_size 500M;
client_body_timeout 300s;
client_header_timeout 300s;
# Gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# 정적 파일 (SSD에서 서빙)
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# 정적 파일 캐시
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# 업로드된 문서 (HDD에서 서빙, SSD 캐시)
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
# 문서 캐시 (자주 접근하는 문서는 SSD에 캐시)
proxy_cache documents;
proxy_cache_valid 200 60m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status;
expires 1h;
}
# API 요청
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# API 응답 캐시 (GET 요청만)
proxy_cache api;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 5m;
proxy_cache_bypass $http_pragma $http_authorization;
add_header X-Cache-Status $upstream_cache_status;
# 타임아웃 설정
proxy_connect_timeout 30s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# 헬스체크
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
EOF
log_success "설정 파일 생성 완료"
# 4. 환경 변수 파일 생성
log_info "🔐 환경 변수 설정 중..."
cat > "$PROJECT_DIR/.env.synology" << EOF
# Synology DS1525+ 배포 환경 변수
DB_PASSWORD=$DB_PASSWORD
SECRET_KEY=$SECRET_KEY
ADMIN_EMAIL=$ADMIN_EMAIL
ADMIN_PASSWORD=$ADMIN_PASSWORD
DOMAIN_NAME=$DOMAIN_NAME
# 성능 최적화 설정
POSTGRES_SHARED_BUFFERS=8GB
POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
REDIS_MAXMEMORY=8gb
# 경로 설정
SSD_PATH=/volume1/docker/document-server
HDD_PATH=/volume2/document-storage
EOF
log_success "환경 변수 설정 완료"
# 5. Docker Compose 배포
log_info "🐳 Docker 컨테이너 배포 중..."
cd "$PROJECT_DIR"
# 기존 컨테이너 중지 및 제거 (있는 경우)
if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then
log_warning "기존 컨테이너를 중지합니다..."
docker-compose -f "$COMPOSE_FILE" down
fi
# 이미지 빌드 및 컨테이너 시작
log_info "이미지 빌드 중..."
docker-compose -f "$COMPOSE_FILE" build --no-cache
log_info "컨테이너 시작 중..."
docker-compose -f "$COMPOSE_FILE" up -d
# 6. 서비스 상태 확인
log_info "🔍 서비스 상태 확인 중..."
# 컨테이너 시작 대기
sleep 30
# 헬스체크
services=("database" "redis" "backend" "nginx")
for service in "${services[@]}"; do
if docker-compose -f "$COMPOSE_FILE" ps "$service" | grep -q "Up"; then
log_success "$service 서비스 정상 실행 중"
else
log_error "$service 서비스 실행 실패"
docker-compose -f "$COMPOSE_FILE" logs "$service"
fi
done
# 7. 백업 스크립트 설정
log_info "💾 백업 스크립트 설정 중..."
cat > /volume1/docker/document-server/backup.sh << 'EOF'
#!/bin/bash
# 자동 백업 스크립트
BACKUP_DIR="/volume2/document-storage/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# 데이터베이스 백업
docker exec document-server-db pg_dump -U docuser document_db > "$BACKUP_DIR/db_backup_$DATE.sql"
# 설정 파일 백업
tar -czf "$BACKUP_DIR/config_backup_$DATE.tar.gz" /volume1/docker/document-server/config/
# 7일 이상 된 백업 파일 삭제
find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete
echo "백업 완료: $DATE"
EOF
chmod +x /volume1/docker/document-server/backup.sh
log_success "백업 스크립트 설정 완료"
# 8. 배포 완료 정보 출력
log_success "🎉 Synology DS1525+ 배포 완료!"
echo ""
echo "=== 배포 정보 ==="
echo "🌐 웹 인터페이스: http://localhost:24100"
echo "🔧 API 서버: http://localhost:24102"
echo "🗄️ 데이터베이스: localhost:24101"
echo "💾 Redis: localhost:24103"
echo ""
echo "=== 관리자 계정 ==="
echo "📧 이메일: $ADMIN_EMAIL"
echo "🔑 비밀번호: $ADMIN_PASSWORD"
echo ""
echo "=== 스토리지 구성 ==="
echo "💿 SSD (성능): /volume1/docker/document-server/"
echo "💾 HDD (용량): /volume2/document-storage/"
echo ""
echo "=== 자동 백업 ==="
echo "📅 매일 새벽 2시 자동 백업 (Synology 작업 스케줄러에서 설정)"
echo "📂 백업 위치: /volume2/document-storage/backups/"
echo ""
echo "=== 모니터링 명령어 ==="
echo "docker-compose -f $COMPOSE_FILE ps"
echo "docker-compose -f $COMPOSE_FILE logs -f"
echo "docker stats"
log_info "배포 스크립트 실행 완료"

249
scripts/monitor-synology.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/bash
# =============================================================================
# Document Server - Synology DS1525+ 모니터링 스크립트
# 시스템 리소스 및 서비스 상태 모니터링
# =============================================================================
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# 로그 함수
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 환경 설정
COMPOSE_FILE="docker-compose.synology.yml"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "=== 📊 Synology DS1525+ Document Server 모니터링 ==="
echo "$(date '+%Y-%m-%d %H:%M:%S')"
echo ""
# 1. 시스템 리소스 확인
log_info "🖥️ 시스템 리소스 상태"
# CPU 사용률
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | awk -F'%' '{print $1}')
echo -e "CPU 사용률: ${CYAN}${CPU_USAGE}%${NC}"
# 메모리 사용률 (32GB 기준)
MEMORY_INFO=$(free -h | grep "Mem:")
TOTAL_MEM=$(echo $MEMORY_INFO | awk '{print $2}')
USED_MEM=$(echo $MEMORY_INFO | awk '{print $3}')
AVAILABLE_MEM=$(echo $MEMORY_INFO | awk '{print $7}')
MEM_PERCENT=$(free | grep "Mem:" | awk '{printf "%.1f", ($3/$2) * 100.0}')
echo -e "메모리: ${CYAN}${USED_MEM}${NC}/${CYAN}${TOTAL_MEM}${NC} (${CYAN}${MEM_PERCENT}%${NC}) | 사용 가능: ${GREEN}${AVAILABLE_MEM}${NC}"
# 디스크 사용률
echo -e "\n${BLUE}💾 디스크 사용률:${NC}"
df -h /volume1 /volume2 | grep -E "(volume1|volume2)" | while read line; do
USAGE=$(echo $line | awk '{print $5}' | sed 's/%//')
MOUNT=$(echo $line | awk '{print $6}')
USED=$(echo $line | awk '{print $3}')
TOTAL=$(echo $line | awk '{print $2}')
if [ "$USAGE" -gt 90 ]; then
echo -e " ${RED}${MOUNT}${NC}: ${RED}${USED}${NC}/${TOTAL} (${RED}${USAGE}%${NC}) ⚠️"
elif [ "$USAGE" -gt 80 ]; then
echo -e " ${YELLOW}${MOUNT}${NC}: ${YELLOW}${USED}${NC}/${TOTAL} (${YELLOW}${USAGE}%${NC})"
else
echo -e " ${GREEN}${MOUNT}${NC}: ${CYAN}${USED}${NC}/${TOTAL} (${GREEN}${USAGE}%${NC})"
fi
done
echo ""
# 2. Docker 컨테이너 상태
log_info "🐳 Docker 컨테이너 상태"
if [ -f "$COMPOSE_FILE" ]; then
# 컨테이너 상태 확인
CONTAINERS=$(docker-compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.State}}\t{{.Ports}}")
echo "$CONTAINERS"
echo ""
# 각 서비스별 상태 확인
SERVICES=("database" "redis" "backend" "nginx")
for service in "${SERVICES[@]}"; do
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service" 2>/dev/null)
if [ -n "$STATUS" ]; then
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker-compose -f "$COMPOSE_FILE" ps -q "$service") 2>/dev/null || echo "no-healthcheck")
if [ "$HEALTH" = "healthy" ]; then
log_success "$service: 정상 (healthy)"
elif [ "$HEALTH" = "unhealthy" ]; then
log_error "$service: 비정상 (unhealthy)"
else
log_warning "$service: 헬스체크 없음"
fi
else
log_error "$service: 실행 중이지 않음"
fi
done
else
log_error "Docker Compose 파일을 찾을 수 없습니다: $COMPOSE_FILE"
fi
echo ""
# 3. 리소스 사용량 (컨테이너별)
log_info "📈 컨테이너별 리소스 사용량"
if command -v docker &> /dev/null; then
# Docker stats 정보 (1회성)
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" | head -10
else
log_error "Docker가 설치되지 않았습니다"
fi
echo ""
# 4. 네트워크 연결 상태
log_info "🌐 네트워크 연결 상태"
# 포트 확인
PORTS=("24100:nginx" "24101:database" "24102:backend" "24103:redis")
for port_info in "${PORTS[@]}"; do
PORT=$(echo $port_info | cut -d: -f1)
SERVICE=$(echo $port_info | cut -d: -f2)
if netstat -tuln | grep -q ":$PORT "; then
log_success "$SERVICE (포트 $PORT): 리스닝 중"
else
log_error "$SERVICE (포트 $PORT): 리스닝하지 않음"
fi
done
echo ""
# 5. 로그 파일 크기 확인
log_info "📝 로그 파일 상태"
LOG_DIRS=(
"/volume1/docker/document-server/logs"
"/volume1/docker/document-server/logs/nginx"
)
for log_dir in "${LOG_DIRS[@]}"; do
if [ -d "$log_dir" ]; then
LOG_SIZE=$(du -sh "$log_dir" 2>/dev/null | cut -f1)
echo -e " ${CYAN}${log_dir}${NC}: ${LOG_SIZE}"
# 큰 로그 파일 경고 (1GB 이상)
LOG_SIZE_MB=$(du -sm "$log_dir" 2>/dev/null | cut -f1)
if [ "$LOG_SIZE_MB" -gt 1024 ]; then
log_warning "로그 디렉토리가 1GB를 초과했습니다: $log_dir"
fi
else
log_warning "로그 디렉토리가 존재하지 않습니다: $log_dir"
fi
done
echo ""
# 6. 데이터베이스 연결 테스트
log_info "🗄️ 데이터베이스 연결 테스트"
if docker-compose -f "$COMPOSE_FILE" ps -q database >/dev/null 2>&1; then
DB_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T database pg_isready -U docuser -d document_db 2>/dev/null)
if echo "$DB_STATUS" | grep -q "accepting connections"; then
log_success "PostgreSQL: 연결 가능"
# 데이터베이스 크기 확인
DB_SIZE=$(docker-compose -f "$COMPOSE_FILE" exec -T database psql -U docuser -d document_db -t -c "SELECT pg_size_pretty(pg_database_size('document_db'));" 2>/dev/null | xargs)
echo -e " 데이터베이스 크기: ${CYAN}${DB_SIZE}${NC}"
else
log_error "PostgreSQL: 연결 실패"
fi
else
log_error "데이터베이스 컨테이너가 실행 중이지 않습니다"
fi
echo ""
# 7. Redis 연결 테스트
log_info "💾 Redis 연결 테스트"
if docker-compose -f "$COMPOSE_FILE" ps -q redis >/dev/null 2>&1; then
REDIS_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli ping 2>/dev/null)
if [ "$REDIS_STATUS" = "PONG" ]; then
log_success "Redis: 연결 가능"
# Redis 메모리 사용량
REDIS_MEMORY=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli info memory | grep "used_memory_human" | cut -d: -f2 | tr -d '\r')
echo -e " 메모리 사용량: ${CYAN}${REDIS_MEMORY}${NC}"
else
log_error "Redis: 연결 실패"
fi
else
log_error "Redis 컨테이너가 실행 중이지 않습니다"
fi
echo ""
# 8. 백업 상태 확인
log_info "💾 백업 상태 확인"
BACKUP_DIR="/volume2/document-storage/backups"
if [ -d "$BACKUP_DIR" ]; then
BACKUP_COUNT=$(find "$BACKUP_DIR" -name "*.sql" -mtime -1 | wc -l)
LATEST_BACKUP=$(find "$BACKUP_DIR" -name "*.sql" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- | xargs basename 2>/dev/null || echo "없음")
echo -e " 백업 디렉토리: ${CYAN}${BACKUP_DIR}${NC}"
echo -e " 최근 24시간 백업: ${CYAN}${BACKUP_COUNT}${NC}"
echo -e " 최신 백업: ${CYAN}${LATEST_BACKUP}${NC}"
if [ "$BACKUP_COUNT" -eq 0 ]; then
log_warning "최근 24시간 내 백업이 없습니다"
fi
else
log_error "백업 디렉토리가 존재하지 않습니다: $BACKUP_DIR"
fi
echo ""
# 9. 권장 사항
log_info "💡 권장 사항"
# 메모리 사용률이 높은 경우
if [ "${MEM_PERCENT%.*}" -gt 80 ]; then
log_warning "메모리 사용률이 높습니다 (${MEM_PERCENT}%). 모니터링이 필요합니다."
fi
# 디스크 사용률 확인
HIGH_DISK_USAGE=$(df /volume1 /volume2 | awk 'NR>1 {gsub(/%/, "", $5); if ($5 > 85) print $6 " (" $5 "%)"}')
if [ -n "$HIGH_DISK_USAGE" ]; then
log_warning "디스크 사용률이 높은 볼륨: $HIGH_DISK_USAGE"
fi
echo ""
echo "=== 모니터링 완료 ==="
echo "다음 명령어로 실시간 모니터링 가능:"
echo " watch -n 5 '$0'"
echo " docker-compose -f $COMPOSE_FILE logs -f"
echo " docker stats"

58
scripts/restore.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Document Server 데이터베이스 복원 스크립트
# 시놀로지 NAS 환경에서 사용
if [ $# -eq 0 ]; then
echo "사용법: $0 <백업파일명>"
echo "예시: $0 document_db_20241201_143000.sql.gz"
exit 1
fi
BACKUP_FILE="$1"
BACKUP_DIR="/volume1/docker/document-server/backups"
CONTAINER_NAME="document-server-db"
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
echo "❌ 백업 파일을 찾을 수 없습니다: $BACKUP_DIR/$BACKUP_FILE"
exit 1
fi
echo "⚠️ 주의: 현재 데이터베이스의 모든 데이터가 삭제됩니다!"
read -p "계속하시겠습니까? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "복원이 취소되었습니다."
exit 1
fi
echo "🔄 데이터베이스 복원 시작..."
# 압축 해제 (필요한 경우)
if [[ $BACKUP_FILE == *.gz ]]; then
echo "📦 백업 파일 압축 해제 중..."
gunzip -c "$BACKUP_DIR/$BACKUP_FILE" > "/tmp/restore_temp.sql"
SQL_FILE="/tmp/restore_temp.sql"
else
SQL_FILE="$BACKUP_DIR/$BACKUP_FILE"
fi
# 기존 데이터베이스 삭제 및 재생성
echo "🗑️ 기존 데이터베이스 삭제 중..."
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "DROP DATABASE IF EXISTS document_db;"
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "CREATE DATABASE document_db;"
# 백업 복원
echo "📥 데이터베이스 복원 중..."
docker exec -i $CONTAINER_NAME psql -U docuser -d document_db < "$SQL_FILE"
# 임시 파일 정리
if [ -f "/tmp/restore_temp.sql" ]; then
rm "/tmp/restore_temp.sql"
fi
echo "✅ 데이터베이스 복원 완료"
echo "🔄 백엔드 서비스 재시작 중..."
docker restart document-server-backend
echo "🎉 복원 작업 완료"

257
scripts/setup-env.sh Executable file
View File

@@ -0,0 +1,257 @@
#!/bin/bash
# =============================================================================
# Document Server - 환경 변수 설정 스크립트
# 대화형으로 환경 변수를 설정하고 .env 파일을 생성합니다
# =============================================================================
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# 로그 함수
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# 보안 키 생성 함수
generate_secure_key() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
}
generate_jwt_key() {
openssl rand -base64 64 | tr -d "=+/" | cut -c1-64
}
generate_password() {
openssl rand -base64 16 | tr -d "=+/" | cut -c1-12
}
# 환경 설정
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_DIR/.env.synology"
cd "$PROJECT_DIR"
echo "=== 🔧 Document Server 환경 변수 설정 ==="
echo ""
# 기존 .env 파일 확인
if [ -f "$ENV_FILE" ]; then
log_warning "기존 환경 변수 파일이 있습니다: $ENV_FILE"
echo ""
read -p "기존 설정을 덮어쓰시겠습니까? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "기존 설정을 유지합니다"
exit 0
fi
# 기존 파일 백업
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%Y%m%d_%H%M%S)"
log_info "기존 파일을 백업했습니다"
fi
echo ""
log_info "환경 변수를 설정합니다. 엔터를 누르면 기본값/자동생성값을 사용합니다."
echo ""
# 1. 데이터베이스 비밀번호
echo -e "${CYAN}1. 데이터베이스 비밀번호${NC}"
DEFAULT_DB_PASSWORD=$(generate_password)
echo " 기본값: $DEFAULT_DB_PASSWORD (자동생성)"
read -p " 입력: " DB_PASSWORD
DB_PASSWORD=${DB_PASSWORD:-$DEFAULT_DB_PASSWORD}
# 2. JWT 시크릿 키
echo ""
echo -e "${CYAN}2. JWT 시크릿 키 (보안용)${NC}"
DEFAULT_SECRET_KEY=$(generate_jwt_key)
echo " 기본값: ${DEFAULT_SECRET_KEY:0:20}... (자동생성)"
read -p " 입력: " SECRET_KEY
SECRET_KEY=${SECRET_KEY:-$DEFAULT_SECRET_KEY}
# 3. 관리자 이메일
echo ""
echo -e "${CYAN}3. 관리자 이메일${NC}"
DEFAULT_ADMIN_EMAIL="admin@document-server.local"
echo " 기본값: $DEFAULT_ADMIN_EMAIL"
read -p " 입력: " ADMIN_EMAIL
ADMIN_EMAIL=${ADMIN_EMAIL:-$DEFAULT_ADMIN_EMAIL}
# 4. 관리자 비밀번호
echo ""
echo -e "${CYAN}4. 관리자 비밀번호${NC}"
DEFAULT_ADMIN_PASSWORD=$(generate_password)
echo " 기본값: $DEFAULT_ADMIN_PASSWORD (자동생성)"
read -p " 입력: " ADMIN_PASSWORD
ADMIN_PASSWORD=${ADMIN_PASSWORD:-$DEFAULT_ADMIN_PASSWORD}
# 5. 도메인 이름
echo ""
echo -e "${CYAN}5. 도메인 이름 (외부 접속용)${NC}"
DEFAULT_DOMAIN="localhost"
echo " 기본값: $DEFAULT_DOMAIN"
echo " 예시: mydomain.com, nas.mydomain.com"
read -p " 입력: " DOMAIN_NAME
DOMAIN_NAME=${DOMAIN_NAME:-$DEFAULT_DOMAIN}
# 6. 외부 포트 (선택사항)
echo ""
echo -e "${CYAN}6. 외부 포트 (기본: 24100)${NC}"
DEFAULT_PORT="24100"
echo " 기본값: $DEFAULT_PORT"
read -p " 입력: " EXTERNAL_PORT
EXTERNAL_PORT=${EXTERNAL_PORT:-$DEFAULT_PORT}
# .env 파일 생성
echo ""
log_info "환경 변수 파일 생성 중..."
cat > "$ENV_FILE" << EOF
# =============================================================================
# Document Server - Synology DS1525+ 환경 변수
# 생성일: $(date '+%Y-%m-%d %H:%M:%S')
# =============================================================================
# 데이터베이스 설정
DB_PASSWORD=$DB_PASSWORD
POSTGRES_PASSWORD=$DB_PASSWORD
# 보안 설정
SECRET_KEY=$SECRET_KEY
JWT_SECRET_KEY=$SECRET_KEY
# 관리자 계정
ADMIN_EMAIL=$ADMIN_EMAIL
ADMIN_PASSWORD=$ADMIN_PASSWORD
# 네트워크 설정
DOMAIN_NAME=$DOMAIN_NAME
EXTERNAL_PORT=$EXTERNAL_PORT
# CORS 설정 (도메인에 따라 자동 설정)
ALLOWED_ORIGINS=http://localhost:$EXTERNAL_PORT,http://$DOMAIN_NAME:$EXTERNAL_PORT
# 성능 최적화 설정 (DS1525+ 32GB RAM)
POSTGRES_SHARED_BUFFERS=8GB
POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
POSTGRES_WORK_MEM=512MB
POSTGRES_MAINTENANCE_WORK_MEM=4GB
# Redis 설정
REDIS_MAXMEMORY=8gb
REDIS_MAXMEMORY_POLICY=allkeys-lru
# 로그 레벨
LOG_LEVEL=INFO
DEBUG=false
# 파일 업로드 설정
MAX_FILE_SIZE=500000000
UPLOAD_DIR=/app/uploads
# 백업 설정
BACKUP_RETENTION_DAYS=30
AUTO_BACKUP_ENABLED=true
# 모니터링 설정
HEALTH_CHECK_INTERVAL=30s
HEALTH_CHECK_TIMEOUT=10s
HEALTH_CHECK_RETRIES=3
# 스토리지 경로 (Synology 최적화)
SSD_PATH=/volume3/docker/document-server
HDD_PATH=/volume1/document-storage
# 타임존 설정
TZ=Asia/Seoul
EOF
# 파일 권한 설정 (보안)
chmod 600 "$ENV_FILE"
log_success "환경 변수 파일이 생성되었습니다: $ENV_FILE"
# 설정 요약 출력
echo ""
echo "=== 📋 설정 요약 ==="
echo -e "데이터베이스 비밀번호: ${CYAN}$DB_PASSWORD${NC}"
echo -e "관리자 이메일: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "관리자 비밀번호: ${CYAN}$ADMIN_PASSWORD${NC}"
echo -e "도메인: ${CYAN}$DOMAIN_NAME${NC}"
echo -e "포트: ${CYAN}$EXTERNAL_PORT${NC}"
echo ""
# 보안 정보 저장
SECURITY_INFO_FILE="/volume1/document-storage/backups/security-info-$(date +%Y%m%d_%H%M%S).txt"
mkdir -p "$(dirname "$SECURITY_INFO_FILE")" 2>/dev/null || true
cat > "$SECURITY_INFO_FILE" << EOF
Document Server 보안 정보
생성일: $(date '+%Y-%m-%d %H:%M:%S')
=== 관리자 계정 ===
이메일: $ADMIN_EMAIL
비밀번호: $ADMIN_PASSWORD
=== 데이터베이스 ===
사용자: docuser
비밀번호: $DB_PASSWORD
=== 접속 정보 ===
웹 인터페이스: http://$DOMAIN_NAME:$EXTERNAL_PORT
API 문서: http://$DOMAIN_NAME:$((EXTERNAL_PORT + 2))/docs
=== 중요 안내 ===
- 이 파일은 안전한 곳에 보관하세요
- 비밀번호는 정기적으로 변경하세요
- 외부 접속 시 HTTPS 사용을 권장합니다
EOF
chmod 600 "$SECURITY_INFO_FILE" 2>/dev/null || true
log_success "보안 정보가 저장되었습니다: $SECURITY_INFO_FILE"
# 다음 단계 안내
echo ""
echo "=== 🚀 다음 단계 ==="
echo "1. 배포 실행:"
echo " ${CYAN}./scripts/deploy-synology.sh${NC}"
echo ""
echo "2. 상태 확인:"
echo " ${CYAN}./scripts/monitor-synology.sh${NC}"
echo ""
echo "3. 웹 접속:"
echo " ${CYAN}http://$DOMAIN_NAME:$EXTERNAL_PORT${NC}"
echo ""
# SSL 설정 권장사항
if [ "$DOMAIN_NAME" != "localhost" ]; then
echo "=== 🔒 보안 권장사항 ==="
echo "외부 도메인을 사용하시는 경우 SSL 인증서 설정을 권장합니다:"
echo ""
echo "1. Let's Encrypt 인증서 발급:"
echo " ${CYAN}certbot certonly --webroot -w /volume2/document-storage/documents -d $DOMAIN_NAME${NC}"
echo ""
echo "2. Nginx SSL 설정 추가"
echo "3. 방화벽에서 HTTPS(443) 포트 개방"
echo ""
fi
log_success "환경 변수 설정이 완료되었습니다!"

303
scripts/update-synology.sh Executable file
View File

@@ -0,0 +1,303 @@
#!/bin/bash
# =============================================================================
# Document Server - Synology 업데이트 스크립트
# Git을 통한 무중단 업데이트 및 롤백 지원
# =============================================================================
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 로그 함수
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 환경 설정
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="docker-compose.synology.yml"
BACKUP_DIR="/volume2/document-storage/backups"
UPDATE_LOG="/volume1/docker/document-server/logs/update.log"
# 업데이트 모드 설정
UPDATE_MODE="${1:-safe}" # safe, force, rollback
log_info "🔄 Document Server 업데이트 시작 (모드: $UPDATE_MODE)"
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 시작: $UPDATE_MODE" >> "$UPDATE_LOG"
cd "$PROJECT_DIR"
# 현재 상태 확인
log_info "📊 현재 상태 확인 중..."
# Git 상태 확인
if [ ! -d ".git" ]; then
log_error "Git 저장소가 아닙니다. Git 클론으로 설치해주세요."
exit 1
fi
# 현재 커밋 해시 저장 (롤백용)
CURRENT_COMMIT=$(git rev-parse HEAD)
CURRENT_BRANCH=$(git branch --show-current)
echo "이전 커밋: $CURRENT_COMMIT" >> "$UPDATE_LOG"
log_info "현재 브랜치: $CURRENT_BRANCH"
log_info "현재 커밋: ${CURRENT_COMMIT:0:8}"
# 컨테이너 상태 확인
if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then
CONTAINERS_RUNNING=true
log_info "컨테이너가 실행 중입니다"
else
CONTAINERS_RUNNING=false
log_warning "컨테이너가 실행 중이지 않습니다"
fi
# 롤백 모드
if [ "$UPDATE_MODE" = "rollback" ]; then
log_warning "🔙 롤백 모드 실행"
# 마지막 성공한 커밋으로 롤백
LAST_SUCCESS=$(tail -n 20 "$UPDATE_LOG" | grep "업데이트 성공" | tail -n 1 | awk '{print $6}')
if [ -z "$LAST_SUCCESS" ]; then
log_error "롤백할 커밋을 찾을 수 없습니다"
exit 1
fi
log_info "롤백 대상 커밋: $LAST_SUCCESS"
# 롤백 실행
git reset --hard "$LAST_SUCCESS"
# 컨테이너 재시작
if [ "$CONTAINERS_RUNNING" = true ]; then
log_info "컨테이너 재시작 중..."
docker-compose -f "$COMPOSE_FILE" down
docker-compose -f "$COMPOSE_FILE" up -d --build
fi
log_success "롤백 완료: $LAST_SUCCESS"
echo "$(date '+%Y-%m-%d %H:%M:%S') - 롤백 완료: $LAST_SUCCESS" >> "$UPDATE_LOG"
exit 0
fi
# 업데이트 가능 여부 확인
log_info "🔍 업데이트 확인 중..."
# 원격 저장소에서 최신 정보 가져오기
git fetch origin
# 업데이트 가능한 커밋 수 확인
COMMITS_BEHIND=$(git rev-list --count HEAD..origin/$CURRENT_BRANCH)
if [ "$COMMITS_BEHIND" -eq 0 ]; then
log_success "이미 최신 버전입니다"
exit 0
fi
log_info "업데이트 가능한 커밋: $COMMITS_BEHIND개"
# 변경사항 미리보기
log_info "📝 변경사항 미리보기:"
git log --oneline HEAD..origin/$CURRENT_BRANCH | head -10
# Safe 모드에서 사용자 확인
if [ "$UPDATE_MODE" = "safe" ]; then
echo ""
read -p "업데이트를 진행하시겠습니까? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "업데이트가 취소되었습니다"
exit 0
fi
fi
# 백업 생성
log_info "💾 백업 생성 중..."
BACKUP_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="pre_update_${BACKUP_TIMESTAMP}"
# 데이터베이스 백업
if [ "$CONTAINERS_RUNNING" = true ]; then
docker-compose -f "$COMPOSE_FILE" exec -T database pg_dump -U docuser document_db > "$BACKUP_DIR/db_${BACKUP_NAME}.sql"
log_success "데이터베이스 백업 완료"
fi
# 설정 파일 백업
tar -czf "$BACKUP_DIR/config_${BACKUP_NAME}.tar.gz" \
/volume1/docker/document-server/config/ \
.env.synology 2>/dev/null || true
log_success "설정 파일 백업 완료"
# Git 업데이트 실행
log_info "📥 코드 업데이트 중..."
# 로컬 변경사항 임시 저장 (있는 경우)
if ! git diff --quiet; then
log_warning "로컬 변경사항을 임시 저장합니다"
git stash push -m "Auto-stash before update $BACKUP_TIMESTAMP"
fi
# 업데이트 실행
git pull origin "$CURRENT_BRANCH"
NEW_COMMIT=$(git rev-parse HEAD)
log_success "코드 업데이트 완료: ${NEW_COMMIT:0:8}"
# Docker 이미지 업데이트 확인
log_info "🐳 Docker 이미지 업데이트 확인 중..."
# Dockerfile이나 requirements.txt 변경 확인
NEED_REBUILD=false
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(Dockerfile|requirements.txt|pyproject.toml|package.json)" > /dev/null; then
NEED_REBUILD=true
log_info "의존성 변경 감지 - 이미지 재빌드 필요"
fi
# 컨테이너 업데이트
if [ "$CONTAINERS_RUNNING" = true ]; then
log_info "🔄 서비스 업데이트 중..."
if [ "$NEED_REBUILD" = true ]; then
log_info "이미지 재빌드 중..."
# 무중단 업데이트를 위한 단계별 재시작
docker-compose -f "$COMPOSE_FILE" build --no-cache backend
docker-compose -f "$COMPOSE_FILE" up -d --no-deps backend
# 헬스체크 대기
log_info "백엔드 헬스체크 대기 중..."
sleep 30
# Nginx 업데이트 (필요시)
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(nginx|frontend)" > /dev/null; then
docker-compose -f "$COMPOSE_FILE" build --no-cache nginx
docker-compose -f "$COMPOSE_FILE" up -d --no-deps nginx
fi
else
log_info "설정 파일만 업데이트 - 재시작 중..."
docker-compose -f "$COMPOSE_FILE" restart backend nginx
fi
# 서비스 상태 확인
sleep 10
# 헬스체크
HEALTH_CHECK_FAILED=false
# 백엔드 헬스체크
if ! curl -f http://localhost:24102/health > /dev/null 2>&1; then
log_error "백엔드 헬스체크 실패"
HEALTH_CHECK_FAILED=true
fi
# 프론트엔드 헬스체크
if ! curl -f http://localhost:24100/ > /dev/null 2>&1; then
log_error "프론트엔드 헬스체크 실패"
HEALTH_CHECK_FAILED=true
fi
# 헬스체크 실패 시 롤백
if [ "$HEALTH_CHECK_FAILED" = true ]; then
log_error "헬스체크 실패 - 자동 롤백 실행"
# 이전 커밋으로 롤백
git reset --hard "$CURRENT_COMMIT"
# 컨테이너 롤백
docker-compose -f "$COMPOSE_FILE" down
docker-compose -f "$COMPOSE_FILE" up -d --build
log_error "업데이트 실패 - 이전 버전으로 롤백됨"
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 실패 (롤백): $NEW_COMMIT -> $CURRENT_COMMIT" >> "$UPDATE_LOG"
exit 1
fi
log_success "서비스 업데이트 완료"
else
log_info "컨테이너가 실행 중이지 않아 서비스 업데이트를 건너뜁니다"
fi
# 데이터베이스 마이그레이션 확인
log_info "🗄️ 데이터베이스 마이그레이션 확인 중..."
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(migrations|models)" > /dev/null; then
log_warning "데이터베이스 스키마 변경 감지"
if [ "$CONTAINERS_RUNNING" = true ]; then
log_info "마이그레이션 실행 중..."
docker-compose -f "$COMPOSE_FILE" exec -T backend python -m alembic upgrade head || true
log_success "마이그레이션 완료"
else
log_warning "컨테이너가 실행 중이지 않아 마이그레이션을 건너뜁니다"
fi
fi
# 업데이트 완료
log_success "🎉 업데이트 완료!"
echo ""
echo "=== 업데이트 정보 ==="
echo "이전 커밋: ${CURRENT_COMMIT:0:8}"
echo "새 커밋: ${NEW_COMMIT:0:8}"
echo "업데이트된 커밋 수: $COMMITS_BEHIND"
echo "백업 위치: $BACKUP_DIR/*_${BACKUP_NAME}.*"
echo ""
# 변경사항 요약
echo "=== 주요 변경사항 ==="
git log --oneline "$CURRENT_COMMIT".."$NEW_COMMIT" | head -5
echo ""
echo "=== 서비스 상태 ==="
if [ "$CONTAINERS_RUNNING" = true ]; then
docker-compose -f "$COMPOSE_FILE" ps
echo ""
echo "🌐 웹 인터페이스: http://localhost:24100"
echo "🔧 API 문서: http://localhost:24102/docs"
fi
# 성공 로그 기록
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 성공: $CURRENT_COMMIT -> $NEW_COMMIT" >> "$UPDATE_LOG"
# 정리 작업
log_info "🧹 정리 작업 중..."
# 오래된 백업 파일 정리 (30일 이상)
find "$BACKUP_DIR" -name "pre_update_*" -mtime +30 -delete 2>/dev/null || true
# Docker 이미지 정리
docker system prune -f > /dev/null 2>&1 || true
log_success "업데이트 프로세스 완료"
# 모니터링 실행 제안
echo ""
echo "💡 업데이트 후 시스템 상태를 확인하려면:"
echo " ./scripts/monitor-synology.sh"
echo ""
echo "🔙 문제가 있으면 롤백하려면:"
echo " ./scripts/update-synology.sh rollback"

View File

@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>테스트 문서</title>
</head>
<body>
<h1>Document Server 테스트 문서</h1>
<h2>1. 소개</h2>
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플 문서입니다.
텍스트를 선택하면 하이라이트를 추가할 수 있고, 하이라이트에 메모를 연결할 수 있습니다.</p>
<h2>2. 주요 기능</h2>
<ul>
<li><strong>스마트 하이라이트</strong>: 텍스트를 선택하면 하이라이트 버튼이 나타납니다</li>
<li><strong>연결된 메모</strong>: 하이라이트에 직접 메모를 추가할 수 있습니다</li>
<li><strong>메모 관리</strong>: 사이드 패널에서 모든 메모를 확인하고 관리할 수 있습니다</li>
<li><strong>책갈피</strong>: 중요한 위치에 책갈피를 추가하여 빠르게 이동할 수 있습니다</li>
<li><strong>통합 검색</strong>: 문서 내용과 메모를 함께 검색할 수 있습니다</li>
</ul>
<h2>3. 사용 방법</h2>
<h3>3.1 하이라이트 추가</h3>
<p>원하는 텍스트를 마우스로 드래그하여 선택하세요. 선택된 텍스트 아래에 "하이라이트" 버튼이 나타납니다.
버튼을 클릭하면 해당 텍스트가 하이라이트됩니다.</p>
<h3>3.2 메모 추가</h3>
<p>하이라이트를 생성할 때 메모를 함께 추가할 수 있습니다. 또는 기존 하이라이트를 클릭하여
메모를 추가하거나 편집할 수 있습니다.</p>
<h3>3.3 책갈피 사용</h3>
<p>상단 도구 모음의 "책갈피" 버튼을 클릭하여 책갈피 패널을 열고,
"현재 위치에 책갈피 추가" 버튼을 클릭하여 책갈피를 생성하세요.</p>
<h2>4. 고급 기능</h2>
<blockquote>
<p>이 부분은 인용문입니다. 중요한 내용을 강조할 때 사용됩니다.
이런 텍스트도 하이라이트하고 메모를 추가할 수 있습니다.</p>
</blockquote>
<h3>4.1 검색 기능</h3>
<p>상단의 검색창을 사용하여 문서 내용을 검색할 수 있습니다.
검색 결과는 노란색으로 하이라이트됩니다.</p>
<h3>4.2 태그 시스템</h3>
<p>메모에 태그를 추가하여 분류할 수 있습니다. 태그는 쉼표로 구분하여 입력하세요.
예: "중요, 질문, 아이디어"</p>
<h2>5. 결론</h2>
<p>Document Server는 HTML 문서를 효율적으로 관리하고 주석을 달 수 있는 강력한 도구입니다.
하이라이트, 메모, 책갈피 기능을 활용하여 문서를 더욱 효과적으로 활용해보세요.</p>
<hr>
<h2>6. 추가 테스트 내용</h2>
<p>이 섹션은 추가적인 테스트를 위한 내용입니다. 다양한 길이의 텍스트를 선택하여
하이라이트 기능이 올바르게 작동하는지 확인해보세요.</p>
<p><em>기울임체 텍스트</em><strong>굵은 텍스트</strong>, 그리고
<code>코드 텍스트</code>도 모두 하이라이트할 수 있습니다.</p>
<ol>
<li>첫 번째 항목 - 이것도 하이라이트 가능</li>
<li>두 번째 항목 - 메모를 추가해보세요</li>
<li>세 번째 항목 - 책갈피를 만들어보세요</li>
</ol>
<p>마지막으로, 이 문서의 다양한 부분에 하이라이트와 메모를 추가한 후
메모 패널에서 어떻게 표시되는지 확인해보세요. 검색 기능도 테스트해보시기 바랍니다.</p>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More