Major UI overhaul and upload system improvements
- Removed hierarchy view and integrated functionality into index.html - Added book-based document grouping with dedicated book-documents.html page - Implemented comprehensive multi-file upload system with drag-and-drop reordering - Added HTML-PDF matching functionality with download capability - Enhanced upload workflow with 3-step process (File Selection, Book Settings, Order & Match) - Added book conflict resolution (existing book vs new edition) - Improved document order adjustment with one-click sort options - Added modular header component system - Updated API connectivity for Docker environment - Enhanced viewer.html with PDF download functionality - Fixed browser caching issues with version management - Improved mobile responsiveness and modern UI design
This commit is contained in:
333
frontend/static/js/story-reader.js
Normal file
333
frontend/static/js/story-reader.js
Normal file
@@ -0,0 +1,333 @@
|
||||
// 스토리 읽기 애플리케이션
|
||||
function storyReaderApp() {
|
||||
return {
|
||||
// 상태 변수들
|
||||
currentUser: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// 스토리 데이터
|
||||
selectedTree: null,
|
||||
canonicalNodes: [],
|
||||
currentChapter: null,
|
||||
currentChapterIndex: 0,
|
||||
|
||||
// URL 파라미터
|
||||
treeId: null,
|
||||
nodeId: null,
|
||||
chapterIndex: null,
|
||||
|
||||
// 편집 관련
|
||||
showEditModal: false,
|
||||
editingChapter: null,
|
||||
editEditor: null,
|
||||
saving: false,
|
||||
|
||||
// 로그인 관련
|
||||
showLoginModal: false,
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 스토리 리더 초기화 시작');
|
||||
|
||||
// URL 파라미터 파싱
|
||||
this.parseUrlParams();
|
||||
|
||||
// 사용자 인증 확인
|
||||
await this.checkAuth();
|
||||
|
||||
if (this.currentUser) {
|
||||
await this.loadStoryData();
|
||||
}
|
||||
},
|
||||
|
||||
// URL 파라미터 파싱
|
||||
parseUrlParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.treeId = urlParams.get('treeId');
|
||||
this.nodeId = urlParams.get('nodeId');
|
||||
this.chapterIndex = parseInt(urlParams.get('index')) || 0;
|
||||
|
||||
console.log('📖 URL 파라미터:', {
|
||||
treeId: this.treeId,
|
||||
nodeId: this.nodeId,
|
||||
chapterIndex: this.chapterIndex
|
||||
});
|
||||
},
|
||||
|
||||
// 인증 확인
|
||||
async checkAuth() {
|
||||
try {
|
||||
this.currentUser = await window.api.getCurrentUser();
|
||||
console.log('✅ 사용자 인증됨:', this.currentUser?.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증 실패:', error);
|
||||
this.currentUser = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 데이터 로드
|
||||
async loadStoryData() {
|
||||
if (!this.treeId) {
|
||||
this.error = '트리 ID가 필요합니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// 트리 정보 로드
|
||||
this.selectedTree = await window.api.getMemoTree(this.treeId);
|
||||
console.log('📚 트리 로드됨:', this.selectedTree.title);
|
||||
|
||||
// 트리의 모든 노드 로드
|
||||
const allNodes = await window.api.getMemoTreeNodes(this.treeId);
|
||||
|
||||
// 정사 노드만 필터링하고 순서대로 정렬
|
||||
this.canonicalNodes = allNodes
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
console.log('📝 정사 노드 수:', this.canonicalNodes.length);
|
||||
|
||||
if (this.canonicalNodes.length === 0) {
|
||||
this.error = '정사로 설정된 노드가 없습니다';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 챕터 설정
|
||||
this.setCurrentChapter();
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
this.error = '스토리를 불러오는데 실패했습니다';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 챕터 설정
|
||||
setCurrentChapter() {
|
||||
if (this.nodeId) {
|
||||
// 특정 노드 ID로 찾기
|
||||
const index = this.canonicalNodes.findIndex(node => node.id === this.nodeId);
|
||||
if (index !== -1) {
|
||||
this.currentChapterIndex = index;
|
||||
}
|
||||
} else if (this.chapterIndex >= 0 && this.chapterIndex < this.canonicalNodes.length) {
|
||||
// 인덱스로 설정
|
||||
this.currentChapterIndex = this.chapterIndex;
|
||||
} else {
|
||||
// 기본값: 첫 번째 챕터
|
||||
this.currentChapterIndex = 0;
|
||||
}
|
||||
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
console.log('📖 현재 챕터:', this.currentChapter?.title, `(${this.currentChapterIndex + 1}/${this.canonicalNodes.length})`);
|
||||
},
|
||||
|
||||
// 계산된 속성들
|
||||
get totalChapters() {
|
||||
return this.canonicalNodes.length;
|
||||
},
|
||||
|
||||
get hasPreviousChapter() {
|
||||
return this.currentChapterIndex > 0;
|
||||
},
|
||||
|
||||
get hasNextChapter() {
|
||||
return this.currentChapterIndex < this.canonicalNodes.length - 1;
|
||||
},
|
||||
|
||||
get previousChapter() {
|
||||
return this.hasPreviousChapter ? this.canonicalNodes[this.currentChapterIndex - 1] : null;
|
||||
},
|
||||
|
||||
get nextChapter() {
|
||||
return this.hasNextChapter ? this.canonicalNodes[this.currentChapterIndex + 1] : null;
|
||||
},
|
||||
|
||||
// 네비게이션 함수들
|
||||
goToPreviousChapter() {
|
||||
if (this.hasPreviousChapter) {
|
||||
this.currentChapterIndex--;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goToNextChapter() {
|
||||
if (this.hasNextChapter) {
|
||||
this.currentChapterIndex++;
|
||||
this.currentChapter = this.canonicalNodes[this.currentChapterIndex];
|
||||
this.updateUrl();
|
||||
this.scrollToTop();
|
||||
}
|
||||
},
|
||||
|
||||
goBackToStoryView() {
|
||||
window.location.href = `story-view.html?treeId=${this.treeId}`;
|
||||
},
|
||||
|
||||
editChapter() {
|
||||
if (this.currentChapter) {
|
||||
this.editingChapter = { ...this.currentChapter }; // 복사본 생성
|
||||
this.showEditModal = true;
|
||||
console.log('✅ 편집 모달 열림 (Textarea 방식)');
|
||||
}
|
||||
},
|
||||
|
||||
// 편집 취소
|
||||
cancelEdit() {
|
||||
this.showEditModal = false;
|
||||
this.editingChapter = null;
|
||||
console.log('✅ 편집 취소됨 (Textarea 방식)');
|
||||
},
|
||||
|
||||
// 편집 저장
|
||||
async saveEdit() {
|
||||
if (!this.editingChapter) {
|
||||
console.warn('⚠️ 편집 중인 챕터가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.saving = true;
|
||||
|
||||
// 단어 수 계산
|
||||
const content = this.editingChapter.content || '';
|
||||
let wordCount = 0;
|
||||
if (content && content.trim()) {
|
||||
const words = content.trim().split(/\s+/);
|
||||
wordCount = words.filter(word => word.length > 0).length;
|
||||
}
|
||||
this.editingChapter.word_count = wordCount;
|
||||
|
||||
// API로 저장
|
||||
const updateData = {
|
||||
title: this.editingChapter.title,
|
||||
content: this.editingChapter.content,
|
||||
word_count: this.editingChapter.word_count
|
||||
};
|
||||
|
||||
await window.api.updateMemoNode(this.editingChapter.id, updateData);
|
||||
|
||||
// 현재 챕터 업데이트
|
||||
this.currentChapter.title = this.editingChapter.title;
|
||||
this.currentChapter.content = this.editingChapter.content;
|
||||
this.currentChapter.word_count = this.editingChapter.word_count;
|
||||
|
||||
// 정사 노드 목록에서도 업데이트
|
||||
const nodeIndex = this.canonicalNodes.findIndex(node => node.id === this.editingChapter.id);
|
||||
if (nodeIndex !== -1) {
|
||||
this.canonicalNodes[nodeIndex].title = this.editingChapter.title;
|
||||
this.canonicalNodes[nodeIndex].content = this.editingChapter.content;
|
||||
this.canonicalNodes[nodeIndex].word_count = this.editingChapter.word_count;
|
||||
}
|
||||
|
||||
console.log('✅ 챕터 저장 완료');
|
||||
this.cancelEdit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
updateUrl() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('nodeId', this.currentChapter.id);
|
||||
url.searchParams.set('index', this.currentChapterIndex.toString());
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// 페이지 상단으로 스크롤
|
||||
scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// 인쇄
|
||||
printChapter() {
|
||||
window.print();
|
||||
},
|
||||
|
||||
// 콘텐츠 포맷팅
|
||||
formatContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// 마크다운 스타일 간단 변환
|
||||
return content
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
},
|
||||
|
||||
// 노드 타입 라벨
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'memo': '메모',
|
||||
'folder': '폴더',
|
||||
'chapter': '챕터',
|
||||
'character': '캐릭터',
|
||||
'plot': '플롯'
|
||||
};
|
||||
return labels[nodeType] || nodeType;
|
||||
},
|
||||
|
||||
// 상태 라벨
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '초안',
|
||||
'writing': '작성중',
|
||||
'review': '검토중',
|
||||
'complete': '완료'
|
||||
};
|
||||
return labels[status] || status;
|
||||
},
|
||||
|
||||
// 로그인 관련
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
try {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
const result = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (result.access_token) {
|
||||
this.currentUser = result.user;
|
||||
this.showLoginModal = false;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
|
||||
// 로그인 후 스토리 데이터 로드
|
||||
await this.loadStoryData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 실패:', error);
|
||||
this.loginError = '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user