Fix: 업로드 및 API 연결 문제 해결

- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정
- Nginx 프록시 설정에서 경로 중복 문제 해결
- 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정)
- 노트북 연결 기능 수정 (notebook_id 필드 추가)
- 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거)
- 헤더 UI 개선 및 고정 위치 설정
- 백업/복원 스크립트 추가
- PDF 미리보기 토큰 인증 지원
This commit is contained in:
Hyungi Ahn
2025-09-03 15:58:10 +09:00
parent d4b10b16b1
commit 6e01dbdeb3
47 changed files with 3672 additions and 398 deletions

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>
<!-- 공통 스타일 -->
<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="accountSettingsApp()" x-init="init()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-2">
<i class="fas fa-cog 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-user text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">프로필 정보</h2>
</div>
<form @submit.prevent="updateProfile()" 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="email" x-model="profile.email" disabled
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
<p class="mt-1 text-xs text-gray-500">이메일은 변경할 수 없습니다</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">이름</label>
<input type="text" x-model="profile.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>
<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 space-x-3 mb-6">
<i class="fas fa-lock text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">비밀번호 변경</h2>
</div>
<form @submit.prevent="changePassword()" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
<input type="password" x-model="passwordForm.currentPassword" 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 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="password" x-model="passwordForm.newPassword" 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 class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
<input type="password" x-model="passwordForm.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>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-key"></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 space-x-3 mb-6">
<i class="fas fa-palette text-xl text-blue-600"></i>
<h2 class="text-xl font-semibold text-gray-900">환경 설정</h2>
</div>
<form @submit.prevent="updateSettings()" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">테마</label>
<select x-model="settings.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 class="block text-sm font-medium text-gray-700 mb-2">언어</label>
<select x-model="settings.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 class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
<select x-model="settings.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>
<div class="flex justify-end">
<button type="submit" :disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
<i class="fas fa-cog"></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 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 gap-4 text-sm">
<div>
<span class="font-medium text-gray-700">계정 ID:</span>
<span class="text-gray-600 ml-2" x-text="profile.id"></span>
</div>
<div>
<span class="font-medium text-gray-700">역할:</span>
<span class="text-gray-600 ml-2" x-text="getRoleText(profile.role)"></span>
</div>
<div>
<span class="font-medium text-gray-700">계정 생성일:</span>
<span class="text-gray-600 ml-2" x-text="formatDate(profile.created_at)"></span>
</div>
<div>
<span class="font-medium text-gray-700">마지막 로그인:</span>
<span class="text-gray-600 ml-2" x-text="formatDate(profile.last_login)"></span>
</div>
<div>
<span class="font-medium text-gray-700">세션 타임아웃:</span>
<span class="text-gray-600 ml-2" x-text="profile.session_timeout_minutes === 0 ? '무제한' : profile.session_timeout_minutes + '분'"></span>
</div>
<div>
<span class="font-medium text-gray-700">계정 상태:</span>
<span class="ml-2" :class="profile.is_active ? 'text-green-600' : 'text-red-600'" x-text="profile.is_active ? '활성' : '비활성'"></span>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- API 스크립트 -->
<script src="static/js/api.js"></script>
<!-- 계정 설정 스크립트 -->
<script>
function accountSettingsApp() {
return {
loading: false,
profile: {
id: '',
email: '',
full_name: '',
role: '',
is_active: false,
created_at: '',
last_login: '',
session_timeout_minutes: 5
},
passwordForm: {
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
settings: {
theme: 'light',
language: 'ko',
timezone: 'Asia/Seoul'
},
notification: {
show: false,
message: '',
type: 'success'
},
async init() {
console.log('🔧 계정 설정 앱 초기화');
await this.loadProfile();
},
async loadProfile() {
try {
const user = await api.getCurrentUser();
this.profile = { ...user };
this.settings = {
theme: user.theme || 'light',
language: user.language || 'ko',
timezone: user.timezone || 'Asia/Seoul'
};
console.log('✅ 프로필 로드 완료:', user);
} catch (error) {
console.error('❌ 프로필 로드 실패:', error);
this.showNotification('프로필을 불러올 수 없습니다.', 'error');
}
},
async updateProfile() {
this.loading = true;
try {
const response = await api.put(`/users/${this.profile.id}`, {
full_name: this.profile.full_name
});
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
await this.loadProfile(); // 프로필 다시 로드
} catch (error) {
console.error('❌ 프로필 업데이트 실패:', error);
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async changePassword() {
if (this.passwordForm.newPassword !== this.passwordForm.confirmPassword) {
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.loading = true;
try {
await api.post('/auth/change-password', {
current_password: this.passwordForm.currentPassword,
new_password: this.passwordForm.newPassword
});
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
this.passwordForm = {
currentPassword: '',
newPassword: '',
confirmPassword: ''
};
} catch (error) {
console.error('❌ 비밀번호 변경 실패:', error);
this.showNotification('비밀번호 변경에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
async updateSettings() {
this.loading = true;
try {
await api.put(`/users/${this.profile.id}`, {
theme: this.settings.theme,
language: this.settings.language,
timezone: this.settings.timezone
});
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
await this.loadProfile(); // 프로필 다시 로드
} catch (error) {
console.error('❌ 환경 설정 저장 실패:', error);
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
} finally {
this.loading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
message: message,
type: type
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
},
getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
},
formatDate(dateString) {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('ko-KR');
}
};
}
</script>
</body>
</html>

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="flex justify-between items-center h-16">
<!-- 로고 -->
@@ -8,107 +8,230 @@
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
</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">
<a href="index.html" class="nav-link" id="doc-nav-link">
<i class="fas fa-folder-open"></i>
<button class="nav-link-modern" id="doc-nav-link">
<i class="fas fa-folder-open text-blue-600"></i>
<span>문서 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
</a>
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<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">
<div class="grid grid-cols-2 gap-2">
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-th-large text-blue-600"></i>
</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>
<!-- 통합 검색 -->
<a href="search.html" class="nav-link" id="search-nav-link">
<i class="fas fa-search"></i>
<a href="search.html" class="nav-link-modern" id="search-nav-link">
<i class="fas fa-search text-green-600"></i>
<span>통합 검색</span>
</a>
<!-- 소설 관리 시스템 -->
<!-- 소설 관리 -->
<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">
<i class="fas fa-feather-alt"></i>
<button class="nav-link-modern" id="novel-nav-link">
<i class="fas fa-feather-alt text-purple-600"></i>
<span>소설 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
</a>
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<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">
<div class="grid grid-cols-2 gap-2">
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-sitemap text-purple-600"></i>
</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 class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="notes.html" class="nav-link" id="notes-nav-link">
<i class="fas fa-sticky-note"></i>
<button class="nav-link-modern" id="notes-nav-link">
<i class="fas fa-sticky-note text-yellow-600"></i>
<span>노트 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
</a>
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
</a>
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<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">
<div class="grid grid-cols-3 gap-2">
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book text-blue-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="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>
</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">
<!-- 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="hidden" id="logged-in-menu">
<span class="text-sm text-gray-600" id="user-name">User</span>
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
<i class="fas fa-sign-out-alt"></i> 로그아웃
<!-- 로그인된 사용자 드롭다운 -->
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="user-menu-btn">
<div class="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
<i class="fas fa-user text-white text-sm"></i>
</div>
<div class="hidden sm:block text-left">
<div class="text-sm font-semibold text-gray-900" id="user-name">User</div>
<div class="text-xs text-gray-500" id="user-role">사용자</div>
</div>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<!-- 드롭다운 메뉴 -->
<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="user-dropdown">
<!-- 사용자 정보 -->
<div class="px-4 py-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-md">
<i class="fas fa-user text-white"></i>
</div>
<div>
<div class="font-semibold text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-600" id="dropdown-user-email">user@example.com</div>
<div class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 mt-1" id="dropdown-user-role">사용자</div>
</div>
</div>
</div>
<!-- 메뉴 항목들 -->
<div class="py-2">
<a href="profile.html" class="user-menu-item">
<i class="fas fa-user-edit text-blue-500"></i>
<div>
<div class="font-medium">프로필 관리</div>
<div class="text-xs text-gray-500">개인 정보 수정</div>
</div>
</a>
<a href="account-settings.html" class="user-menu-item">
<i class="fas fa-cog text-gray-500"></i>
<div>
<div class="font-medium">계정 설정</div>
<div class="text-xs text-gray-500">환경 설정 및 보안</div>
</div>
</a>
<!-- 관리자 메뉴 (관리자만 표시) -->
<div class="hidden" id="admin-menu-section">
<div class="border-t border-gray-100 my-2"></div>
<div class="px-4 py-2">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider">관리자 메뉴</div>
</div>
<a href="user-management.html" class="user-menu-item">
<i class="fas fa-users text-indigo-500"></i>
<div>
<div class="font-medium">사용자 관리</div>
<div class="text-xs text-gray-500">계정 및 권한 관리</div>
</div>
</a>
<a href="system-settings.html" class="user-menu-item">
<i class="fas fa-server text-green-500"></i>
<div>
<div class="font-medium">시스템 설정</div>
<div class="text-xs text-gray-500">시스템 전체 설정</div>
</div>
</a>
</div>
<!-- 로그아웃 -->
<div class="border-t border-gray-100 my-2"></div>
<button onclick="handleLogout()" class="user-menu-item text-red-600 hover:bg-red-50 w-full">
<i class="fas fa-sign-out-alt text-red-500"></i>
<div>
<div class="font-medium">로그아웃</div>
<div class="text-xs text-gray-500">계정에서 로그아웃</div>
</div>
</button>
</div>
</div>
</div>
<!-- 로그인 버튼 -->
<div class="hidden" id="login-button">
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
<i class="fas fa-sign-in-alt"></i> 로그인
<div class="" id="login-button">
<button id="login-btn" class="login-btn-modern">
<i class="fas fa-sign-in-alt"></i>
<span>로그인</span>
</button>
</div>
</div>
@@ -119,35 +242,128 @@
<!-- 헤더 관련 스타일 -->
<style>
/* 네비게이션 링크 스타일 */
.nav-link {
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
/* 모던 네비게이션 링크 스타일 */
.nav-link-modern {
@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 {
@apply text-blue-600 bg-blue-50 font-medium;
.nav-link-modern:hover {
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.nav-link:hover {
@apply bg-gray-50;
.nav-link-modern.active {
@apply text-blue-700 bg-blue-50 border-blue-200;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
}
/* 드롭다운 메뉴 스타일 */
.nav-dropdown {
@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;
/* 와이드 드롭다운 메뉴 스타일 */
.nav-dropdown-wide {
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 {
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
.nav-dropdown-card {
@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 {
@apply text-blue-600 bg-blue-50 font-medium;
.nav-dropdown-card:hover {
@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;
}
/* 사용자 메뉴 스타일 */
.user-menu-btn {
@apply flex items-center space-x-3 px-3 py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
}
.user-menu-btn:hover {
@apply shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.user-dropdown {
@apply absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl py-0 z-50;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.user-menu-item {
@apply flex items-center space-x-3 px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-all duration-150 cursor-pointer;
}
.user-menu-item:hover {
@apply bg-gradient-to-r from-gray-50 to-blue-50 text-gray-900;
transform: translateX(2px);
}
.user-menu-item i {
@apply w-5 h-5 flex items-center justify-center;
}
/* 로그인 버튼 모던 스타일 */
.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 {
@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;
}
/* 언어 전환 스타일 */
@@ -218,34 +434,9 @@
// 로그인 관련 함수들
window.handleLogin = () => {
console.log('🔐 handleLogin 호출됨');
// Alpine.js 컨텍스트에서 함수 찾기
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'));
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
const currentUrl = encodeURIComponent(window.location.href);
window.location.href = `login.html?redirect=${currentUrl}`;
};
window.handleLogout = () => {
@@ -259,22 +450,73 @@
// 사용자 상태 업데이트 함수
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');
console.log('🔍 요소 찾기:', {
loggedInMenu: !!loggedInMenu,
loginButton: !!loginButton,
adminMenuSection: !!adminMenuSection
});
// 사용자 정보 요소들
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 (loggedInMenu) loggedInMenu.classList.remove('hidden');
if (loginButton) loginButton.classList.add('hidden');
if (userName) userName.textContent = user.username || user.full_name || user.email || '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 = 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 {
// 로그아웃된 상태
if (loggedInMenu) loggedInMenu.classList.add('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) => {
@@ -335,9 +577,27 @@
// 향후 다국어 지원 시 구현
};
// 헤더 로드 완료 후 언어 설정 적용
// 헤더 로드 완료 후 이벤트 바인딩
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';
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="/static/css/main.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
</head>

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>

View File

@@ -780,7 +780,7 @@
class="absolute tree-diagram-node"
:style="getNodePosition(node)"
:data-node-id="node.id"
@mousedown="startDragNode($event, node)"
@click="selectNode(node)"
>
<div
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
@@ -788,7 +788,6 @@
'tree-node-canonical': node.is_canonical,
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
}"
@click="selectNode(node)"
@dblclick="editNodeInline(node)"
>
<!-- 정사 경로 배지 -->

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>

View File

@@ -8,6 +8,9 @@
<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>
@@ -251,6 +254,25 @@
</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>
@@ -314,7 +336,7 @@
<!-- 검색 결과 -->
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
<template x-for="result in filteredResults" :key="result.id">
<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">
@@ -323,6 +345,29 @@
<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>
@@ -483,7 +528,8 @@
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
<!-- PDF iframe 뷰어 -->
<iframe x-show="!pdfError && !pdfLoading"
<iframe id="pdf-preview-iframe"
x-show="!pdfError && !pdfLoading"
class="w-full h-full border-0"
:src="pdfSrc"
@load="pdfLoaded = true"

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 {
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

@@ -201,7 +201,7 @@ class DocumentServerAPI {
}
async getCurrentUser() {
return await this.get('/auth/me');
return await this.get('/users/me');
}
async refreshToken(refreshToken) {

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

@@ -155,9 +155,97 @@ document.addEventListener('headerLoaded', () => {
setTimeout(() => {
window.headerLoader.updateActiveStates();
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
if (typeof window.updateUserMenu === 'function') {
window.updateUserMenu(null);
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
if (typeof window.updateUserMenu === 'undefined') {
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;
// 관리자 메뉴 표시/숨김
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
adminMenuSection.classList.remove('hidden');
} else {
adminMenuSection.classList.add('hidden');
}
}
} 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);
});

View File

@@ -65,6 +65,15 @@ window.documentApp = () => ({
this.isAuthenticated = false;
this.currentUser = null;
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 상태 동기화
}
},

View File

@@ -63,9 +63,6 @@ window.memoTreeApp = function() {
treePanX: 0,
treePanY: 0,
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
isDragging: false,
dragNode: null,
dragOffset: { x: 0, y: 0 },
// 로그인 관련 함수들
openLoginModal() {
@@ -290,7 +287,15 @@ window.memoTreeApp = function() {
const node = await window.api.createMemoNode(nodeData);
this.treeNodes.push(node);
this.selectNode(node);
// 노드 위치 재계산 (새 노드 추가 후)
this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 루트 노드 생성 완료');
} catch (error) {
@@ -301,6 +306,11 @@ window.memoTreeApp = function() {
// 노드 선택
selectNode(node) {
// 현재 팬 값 저장 (위치 변경 방지)
const currentPanX = this.treePanX;
const currentPanY = this.treePanY;
const currentZoom = this.treeZoom;
// 이전 노드 저장
if (this.selectedNode && this.isEditorDirty) {
this.saveNode();
@@ -323,6 +333,11 @@ window.memoTreeApp = function() {
}, 100);
}
// 팬 값 복원 (위치 변경 방지)
this.treePanX = currentPanX;
this.treePanY = currentPanY;
this.treeZoom = currentZoom;
console.log('📝 노드 선택:', node.title);
},
@@ -552,8 +567,14 @@ window.memoTreeApp = function() {
// 부모 노드 펼치기
this.expandedNodes.add(parentNode.id);
// 새 노드 선택
this.selectNode(node);
// 노드 위치 재계산 (새 노드 추가 후)
this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 자식 노드 생성 완료');
} catch (error) {
@@ -623,123 +644,9 @@ window.memoTreeApp = function() {
}
},
// 노드 드래그 시작
startDragNode(event, node) {
event.stopPropagation();
event.preventDefault();
console.log('🎯 노드 드래그 시작:', node.title);
this.isDragging = true;
this.dragNode = node;
// 드래그 시작 위치 계산
const rect = event.target.getBoundingClientRect();
this.dragOffset = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// 드래그 중인 노드 스타일 적용
event.target.style.opacity = '0.7';
event.target.style.transform = 'scale(1.05)';
event.target.style.zIndex = '1000';
// 이벤트 리스너 등록
document.addEventListener('mousemove', this.handleNodeDrag.bind(this));
document.addEventListener('mouseup', this.endNodeDrag.bind(this));
},
// 노드 드래그 처리
handleNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
event.preventDefault();
// 드래그 중인 노드 위치 업데이트
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
const newX = event.clientX - this.dragOffset.x;
const newY = event.clientY - this.dragOffset.y;
dragElement.style.position = 'fixed';
dragElement.style.left = `${newX}px`;
dragElement.style.top = `${newY}px`;
dragElement.style.pointerEvents = 'none';
}
// 드롭 대상 하이라이트
this.highlightDropTarget(event);
},
// 드롭 대상 하이라이트
highlightDropTarget(event) {
// 모든 노드에서 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 현재 마우스 위치의 노드 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetNode = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
if (targetNode) {
targetNode.classList.add('drop-target-highlight');
}
},
// 노드 드래그 종료
endNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
console.log('🎯 노드 드래그 종료');
// 드롭 대상 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetElement = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
let targetNodeId = null;
if (targetElement) {
targetNodeId = targetElement.getAttribute('data-node-id');
}
// 드래그 중인 노드 스타일 복원
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
dragElement.style.opacity = '';
dragElement.style.transform = '';
dragElement.style.zIndex = '';
dragElement.style.position = '';
dragElement.style.left = '';
dragElement.style.top = '';
dragElement.style.pointerEvents = '';
}
// 모든 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 실제 노드 이동 처리
if (targetNodeId && targetNodeId !== this.dragNode.id) {
this.moveNodeToParent(this.dragNode.id, targetNodeId);
}
// 상태 초기화
this.isDragging = false;
this.dragNode = null;
this.dragOffset = { x: 0, y: 0 };
// 이벤트 리스너 제거
document.removeEventListener('mousemove', this.handleNodeDrag);
document.removeEventListener('mouseup', this.endNodeDrag);
},
// 노드를 다른 부모로 이동
async moveNodeToParent(nodeId, newParentId) {
@@ -783,15 +690,12 @@ window.memoTreeApp = function() {
// 노드 위치 계산 및 반환
getNodePosition(node) {
if (!this.nodePositions.has(node.id)) {
this.calculateNodePositions();
}
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
return `left: ${pos.x}px; top: ${pos.y}px;`;
},
// 트리 노드 위치 자동 계산
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
calculateNodePositions() {
const canvas = document.getElementById('tree-canvas');
if (!canvas) return;
@@ -802,11 +706,11 @@ window.memoTreeApp = function() {
// 노드 크기 설정
const nodeWidth = 200;
const nodeHeight = 80;
const levelHeight = 150; // 레벨 간 간격
const nodeSpacing = 50; // 노드 간 간격
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
const nodeSpacing = 100; // 노드 간 세로 간격
const margin = 100; // 여백
// 레벨별 노드 그룹화
// 레벨별 노드 그룹화 (가로 방향)
const levels = new Map();
// 루트 노드들 찾기
@@ -814,7 +718,7 @@ window.memoTreeApp = function() {
if (rootNodes.length === 0) return;
// BFS로 레벨별 노드 배치
// BFS로 레벨별 노드 배치 (가로 방향)
const queue = [];
rootNodes.forEach(node => {
queue.push({ node, level: 0 });
@@ -835,25 +739,25 @@ window.memoTreeApp = function() {
});
}
// 트리 전체 크기 계산
// 트리 전체 크기 계산 (가로 방향)
const maxLevel = Math.max(...levels.keys());
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
const treeHeight = (maxLevel + 1) * levelHeight;
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
// 각 레벨의 노드들 위치 계산
// 각 레벨의 노드들 위치 계산 (가로 방향)
levels.forEach((nodes, level) => {
const y = offsetY + level * levelHeight;
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
const startX = offsetX + (treeWidth - levelWidth) / 2;
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
nodes.forEach((node, index) => {
const x = startX + index * (nodeWidth + nodeSpacing);
const y = startY + index * (nodeHeight + nodeSpacing);
this.nodePositions.set(node.id, { x, y });
});
});
@@ -893,17 +797,17 @@ window.memoTreeApp = function() {
// 연결선 생성
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// 부모 노드 하단 중앙에서 시작
const startX = parentPos.x + 100; // 노드 중앙
const startY = parentPos.y + 80; // 노드 하단
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
const startX = parentPos.x + 200; // 노드 오른쪽 끝
const startY = parentPos.y + 40; // 노드 세로 중앙
// 자식 노드 상단 중앙으로 연결
const endX = childPos.x + 100; // 노드 중앙
const endY = childPos.y; // 노드 상단
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
const endX = childPos.x; // 노드 왼쪽 끝
const endY = childPos.y + 40; // 노드 세로 중앙
// 곡선 경로 생성 (베지어 곡선)
const midY = startY + (endY - startY) / 2;
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
const midX = startX + (endX - startX) / 2;
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
line.setAttribute('d', path);
line.setAttribute('stroke', '#9CA3AF');

View File

@@ -15,6 +15,7 @@ window.searchApp = function() {
// 필터링
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
fileTypeFilter: '', // '', 'PDF', 'HTML'
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
// 검색 디바운스
@@ -157,9 +158,43 @@ window.searchApp = function() {
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 => result.type === 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;
});
}
// 정렬
@@ -179,7 +214,7 @@ window.searchApp = function() {
});
this.filteredResults = results;
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
},
// URL 업데이트
@@ -339,22 +374,23 @@ window.searchApp = function() {
this.pdfLoaded = false;
try {
// PDF 파일 존재 여부 먼저 확인
const response = await fetch(`/api/documents/${documentId}/pdf`, {
method: 'HEAD',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
// 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 (response.ok) {
// PDF 파일이 존재하면 src 설정
const token = localStorage.getItem('token');
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
console.log('PDF 미리보기 준비 완료:', this.pdfSrc);
} else {
throw new Error(`PDF 파일을 찾을 수 없습니다 (${response.status})`);
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;
@@ -370,6 +406,50 @@ window.searchApp = function() {
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;
@@ -385,7 +465,12 @@ window.searchApp = function() {
const iframe = document.getElementById('htmlPreviewFrame');
if (iframe) {
// iframe src를 직접 설정 (인증 헤더 포함)
const token = localStorage.getItem('token');
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 로드 완료 후 검색어 하이라이트

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

@@ -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>