| <!DOCTYPE html> |
| <html lang="ko"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>๊ด๋ฆฌ์ ํ์ด์ง - SOY NV AI</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: #f8f9fa; |
| color: #202124; |
| } |
| |
| .header { |
| background: white; |
| border-bottom: 1px solid #dadce0; |
| padding: 16px 24px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
| } |
| |
| .header-title { |
| font-size: 20px; |
| font-weight: 500; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .header-actions { |
| display: flex; |
| gap: 12px; |
| align-items: center; |
| } |
| |
| .menu-toggle { |
| display: none; |
| background: none; |
| border: none; |
| font-size: 24px; |
| cursor: pointer; |
| padding: 8px; |
| color: #202124; |
| } |
| |
| .mobile-menu { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0, 0, 0, 0.5); |
| z-index: 1000; |
| } |
| |
| .mobile-menu.active { |
| display: block; |
| } |
| |
| .mobile-menu-content { |
| position: fixed; |
| top: 0; |
| right: -100%; |
| width: 280px; |
| max-width: 80%; |
| height: 100%; |
| background: white; |
| box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); |
| transition: right 0.3s ease; |
| overflow-y: auto; |
| z-index: 1001; |
| } |
| |
| .mobile-menu.active .mobile-menu-content { |
| right: 0; |
| } |
| |
| .mobile-menu-header { |
| padding: 16px 20px; |
| border-bottom: 1px solid #dadce0; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| background: white; |
| position: sticky; |
| top: 0; |
| z-index: 10; |
| } |
| |
| .mobile-menu-title { |
| font-size: 18px; |
| font-weight: 500; |
| } |
| |
| .mobile-menu-close { |
| background: none; |
| border: none; |
| font-size: 24px; |
| cursor: pointer; |
| color: #202124; |
| padding: 0; |
| width: 32px; |
| height: 32px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .mobile-menu-items { |
| padding: 8px 0; |
| } |
| |
| .mobile-menu-item { |
| display: block; |
| padding: 12px 20px; |
| color: #202124; |
| text-decoration: none; |
| border-bottom: 1px solid #f1f3f4; |
| transition: background 0.2s; |
| } |
| |
| .mobile-menu-item:hover { |
| background: #f8f9fa; |
| } |
| |
| .mobile-menu-user { |
| padding: 16px 20px; |
| border-bottom: 1px solid #dadce0; |
| color: #5f6368; |
| font-size: 14px; |
| } |
| |
| @media (max-width: 768px) { |
| .header { |
| padding: 12px 16px; |
| } |
| |
| .header-title { |
| font-size: 18px; |
| } |
| |
| .header-title span:first-child { |
| display: none; |
| } |
| |
| .menu-toggle { |
| display: block; |
| } |
| |
| .header-actions { |
| display: none; |
| } |
| } |
| |
| .btn { |
| padding: 8px 16px; |
| border: none; |
| border-radius: 6px; |
| font-size: 14px; |
| font-weight: 500; |
| cursor: pointer; |
| transition: all 0.2s; |
| text-decoration: none; |
| display: inline-block; |
| } |
| |
| .btn-primary { |
| background: #1a73e8; |
| color: white; |
| } |
| |
| .btn-primary:hover { |
| background: #1557b0; |
| } |
| |
| .btn-secondary { |
| background: #f1f3f4; |
| color: #202124; |
| } |
| |
| .btn-secondary:hover { |
| background: #e8eaed; |
| } |
| |
| .btn-danger { |
| background: #ea4335; |
| color: white; |
| } |
| |
| .btn-danger:hover { |
| background: #c5221f; |
| } |
| |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| padding: 24px; |
| } |
| |
| .page-header { |
| margin-bottom: 24px; |
| } |
| |
| .page-header h1 { |
| font-size: 28px; |
| font-weight: 600; |
| margin-bottom: 8px; |
| } |
| |
| .page-header p { |
| color: #5f6368; |
| } |
| |
| .card { |
| background: white; |
| border-radius: 8px; |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
| padding: 24px; |
| margin-bottom: 24px; |
| } |
| |
| .card-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 20px; |
| } |
| |
| .card-title { |
| font-size: 18px; |
| font-weight: 500; |
| } |
| |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| } |
| |
| thead { |
| background: #f8f9fa; |
| } |
| |
| th, td { |
| padding: 12px; |
| text-align: left; |
| border-bottom: 1px solid #e8eaed; |
| } |
| |
| th { |
| font-weight: 500; |
| font-size: 14px; |
| color: #5f6368; |
| } |
| |
| td { |
| font-size: 14px; |
| } |
| |
| .badge { |
| display: inline-block; |
| padding: 4px 8px; |
| border-radius: 4px; |
| font-size: 12px; |
| font-weight: 500; |
| } |
| |
| .badge-admin { |
| background: #e8f0fe; |
| color: #1967d2; |
| } |
| |
| .badge-user { |
| background: #e8f5e9; |
| color: #137333; |
| } |
| |
| .badge-inactive { |
| background: #fce8e6; |
| color: #c5221f; |
| } |
| |
| .modal { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0, 0, 0, 0.5); |
| z-index: 1000; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .modal.active { |
| display: flex; |
| } |
| |
| .modal-content { |
| background: white; |
| border-radius: 8px; |
| padding: 24px; |
| width: 90%; |
| max-width: 500px; |
| max-height: 90vh; |
| overflow-y: auto; |
| } |
| |
| .modal-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 20px; |
| } |
| |
| .modal-title { |
| font-size: 20px; |
| font-weight: 500; |
| } |
| |
| .modal-close { |
| background: none; |
| border: none; |
| font-size: 24px; |
| cursor: pointer; |
| color: #5f6368; |
| } |
| |
| .form-group { |
| margin-bottom: 16px; |
| } |
| |
| .form-group label { |
| display: block; |
| font-size: 14px; |
| font-weight: 500; |
| margin-bottom: 8px; |
| } |
| |
| .form-group input, |
| .form-group select { |
| width: 100%; |
| padding: 10px 12px; |
| border: 1px solid #dadce0; |
| border-radius: 6px; |
| font-size: 14px; |
| font-family: inherit; |
| } |
| |
| .form-group input:focus, |
| .form-group select:focus { |
| outline: none; |
| border-color: #1a73e8; |
| box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1); |
| } |
| |
| .form-group-checkbox { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .form-group-checkbox input { |
| width: auto; |
| } |
| |
| .modal-actions { |
| display: flex; |
| gap: 8px; |
| justify-content: flex-end; |
| margin-top: 24px; |
| } |
| |
| .alert { |
| padding: 12px 16px; |
| border-radius: 6px; |
| margin-bottom: 16px; |
| font-size: 14px; |
| } |
| |
| .alert.error { |
| background: #fce8e6; |
| color: #c5221f; |
| } |
| |
| .alert.success { |
| background: #e8f5e9; |
| color: #137333; |
| } |
| |
| |
| .file-upload-section { |
| margin-top: 24px; |
| } |
| |
| .file-upload-input-wrapper { |
| position: relative; |
| margin-bottom: 12px; |
| border: 2px dashed #dadce0; |
| border-radius: 8px; |
| padding: 20px; |
| text-align: center; |
| background: #f8f9fa; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| |
| .file-upload-input-wrapper:hover { |
| border-color: #1a73e8; |
| background: #e8f0fe; |
| } |
| |
| .file-upload-input-wrapper.dragover { |
| border-color: #1a73e8; |
| background: #e8f0fe; |
| } |
| |
| .file-upload-input-wrapper input[type="file"] { |
| position: absolute; |
| opacity: 0; |
| width: 100%; |
| height: 100%; |
| cursor: pointer; |
| } |
| |
| .file-upload-label { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 8px; |
| color: #5f6368; |
| font-size: 14px; |
| } |
| |
| .file-upload-label svg { |
| width: 32px; |
| height: 32px; |
| } |
| |
| .file-upload-status { |
| font-size: 12px; |
| margin-top: 8px; |
| min-height: 16px; |
| } |
| |
| .file-upload-status.success { |
| color: #137333; |
| } |
| |
| .file-upload-status.error { |
| color: #c5221f; |
| } |
| |
| .file-upload-status.progress { |
| color: #1a73e8; |
| font-weight: 500; |
| } |
| |
| .file-upload-progress { |
| margin-top: 12px; |
| padding: 12px; |
| background: #f8f9fa; |
| border-radius: 6px; |
| display: none; |
| } |
| |
| .file-upload-progress.active { |
| display: block; |
| } |
| |
| .progress-item { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 8px 0; |
| border-bottom: 1px solid #e8eaed; |
| } |
| |
| .progress-item:last-child { |
| border-bottom: none; |
| } |
| |
| .progress-item-name { |
| flex: 1; |
| font-size: 13px; |
| color: #202124; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| margin-right: 12px; |
| } |
| |
| .progress-item-status { |
| font-size: 12px; |
| font-weight: 500; |
| min-width: 80px; |
| text-align: right; |
| } |
| |
| .progress-item-status.uploading { |
| color: #1a73e8; |
| } |
| |
| .progress-item-status.success { |
| color: #137333; |
| } |
| |
| .progress-item-status.error { |
| color: #c5221f; |
| } |
| |
| .file-upload-input-wrapper.disabled { |
| opacity: 0.6; |
| pointer-events: none; |
| cursor: not-allowed; |
| } |
| |
| .spinner { |
| display: inline-block; |
| width: 12px; |
| height: 12px; |
| border: 2px solid #e8eaed; |
| border-top-color: #1a73e8; |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| margin-right: 6px; |
| vertical-align: middle; |
| } |
| |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| .files-table { |
| margin-top: 16px; |
| } |
| |
| .file-size { |
| color: #5f6368; |
| font-size: 12px; |
| } |
| |
| .file-actions { |
| display: flex; |
| gap: 4px; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="header"> |
| <div class="header-title"> |
| <span>๐ค</span> |
| <span>SOY NV AI ๊ด๋ฆฌ์ ํ์ด์ง</span> |
| </div> |
| <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ์ด๊ธฐ">โฐ</button> |
| <div class="header-actions"> |
| <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span> |
| <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์น์์ค ๊ด๋ฆฌ</a> |
| <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํ์ผ ๋ชฉ๋ก</a> |
| <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ์์ง ํ์ธ</a> |
| <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ๋กฌํํธ ๊ด๋ฆฌ</a> |
| <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์ค์ </a> |
| <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ์ธ์ผ๋ก</a> |
| <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋ก๊ทธ์์</a> |
| </div> |
| </div> |
|
|
| |
| <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)"> |
| <div class="mobile-menu-content" onclick="event.stopPropagation()"> |
| <div class="mobile-menu-header"> |
| <div class="mobile-menu-title">๋ฉ๋ด</div> |
| <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ๋ซ๊ธฐ">×</button> |
| </div> |
| <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div> |
| <div class="mobile-menu-items"> |
| <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์น์์ค ๊ด๋ฆฌ</a> |
| <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ์ผ ๋ชฉ๋ก</a> |
| <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ์์ง ํ์ธ</a> |
| <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ๋กฌํํธ ๊ด๋ฆฌ</a> |
| <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์ค์ </a> |
| <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ์ธ์ผ๋ก</a> |
| <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ก๊ทธ์์</a> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="container"> |
| <div class="page-header"> |
| <h1>์ฌ์ฉ์ ๊ด๋ฆฌ</h1> |
| <p>์์คํ
์ฌ์ฉ์๋ฅผ ๊ด๋ฆฌํ ์ ์์ต๋๋ค.</p> |
| </div> |
|
|
| <div id="alertContainer"></div> |
|
|
| <div class="card"> |
| <div class="card-header"> |
| <div class="card-title">์ฌ์ฉ์ ๋ชฉ๋ก</div> |
| <button class="btn btn-primary" onclick="openCreateModal()">์ ์ฌ์ฉ์ ์ถ๊ฐ</button> |
| </div> |
|
|
| <table> |
| <thead> |
| <tr> |
| <th>ID</th> |
| <th>์ฌ์ฉ์๋ช
</th> |
| <th>๋๋ค์</th> |
| <th>๊ถํ</th> |
| <th>์ํ</th> |
| <th>์์ฑ์ผ</th> |
| <th>๋ง์ง๋ง ๋ก๊ทธ์ธ</th> |
| <th>์์
</th> |
| </tr> |
| </thead> |
| <tbody id="usersTableBody"> |
| {% for user in users %} |
| <tr data-user-id="{{ user.id }}"> |
| <td>{{ user.id }}</td> |
| <td>{{ user.username }}</td> |
| <td>{{ user.nickname or '-' }}</td> |
| <td> |
| {% if user.is_admin %} |
| <span class="badge badge-admin">๊ด๋ฆฌ์</span> |
| {% else %} |
| <span class="badge badge-user">์ผ๋ฐ ์ฌ์ฉ์</span> |
| {% endif %} |
| </td> |
| <td> |
| {% if user.is_active %} |
| <span class="badge badge-user">ํ์ฑ</span> |
| {% else %} |
| <span class="badge badge-inactive">๋นํ์ฑ</span> |
| {% endif %} |
| </td> |
| <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</td> |
| <td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '-' }}</td> |
| <td> |
| <button class="btn btn-secondary" onclick="openEditModal({{ user.id }}, '{{ user.username }}', '{{ user.nickname or '' }}', {{ user.is_admin|lower }}, {{ user.is_active|lower }})" style="padding: 4px 8px; font-size: 12px;">์์ </button> |
| {% if user.id != current_user.id %} |
| <button class="btn btn-danger" onclick="deleteUser({{ user.id }})" style="padding: 4px 8px; font-size: 12px;">์ญ์ </button> |
| {% endif %} |
| </td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <div id="userModal" class="modal"> |
| <div class="modal-content"> |
| <div class="modal-header"> |
| <div class="modal-title" id="modalTitle">์ ์ฌ์ฉ์ ์ถ๊ฐ</div> |
| <button class="modal-close" onclick="closeModal()">×</button> |
| </div> |
| <form id="userForm" onsubmit="saveUser(event)"> |
| <input type="hidden" id="userId" name="user_id"> |
| <div class="form-group"> |
| <label for="username">์ฌ์ฉ์๋ช
</label> |
| <input type="text" id="username" name="username" required> |
| </div> |
| <div class="form-group"> |
| <label for="nickname">๋๋ค์</label> |
| <input type="text" id="nickname" name="nickname" placeholder="์ ํ์ฌํญ"> |
| </div> |
| <div class="form-group"> |
| <label for="password">๋น๋ฐ๋ฒํธ</label> |
| <input type="password" id="password" name="password" id="passwordInput"> |
| <small style="color: #5f6368; font-size: 12px;">์์ ์ ๋น๋ฐ๋ฒํธ๋ฅผ ๋ณ๊ฒฝํ์ง ์์ผ๋ ค๋ฉด ๋น์๋์ธ์.</small> |
| </div> |
| <div class="form-group-checkbox"> |
| <input type="checkbox" id="isAdmin" name="is_admin"> |
| <label for="isAdmin">๊ด๋ฆฌ์ ๊ถํ</label> |
| </div> |
| <div class="form-group-checkbox"> |
| <input type="checkbox" id="isActive" name="is_active" checked> |
| <label for="isActive">ํ์ฑ ์ํ</label> |
| </div> |
| <div class="modal-actions"> |
| <button type="button" class="btn btn-secondary" onclick="closeModal()">์ทจ์</button> |
| <button type="submit" class="btn btn-primary">์ ์ฅ</button> |
| </div> |
| </form> |
| </div> |
| </div> |
|
|
| <script> |
| let currentEditUserId = null; |
| |
| function toggleMobileMenu() { |
| const menu = document.getElementById('mobileMenu'); |
| menu.classList.toggle('active'); |
| document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : ''; |
| } |
| |
| function closeMobileMenu() { |
| const menu = document.getElementById('mobileMenu'); |
| menu.classList.remove('active'); |
| document.body.style.overflow = ''; |
| } |
| |
| function closeMobileMenuOnBackdrop(event) { |
| if (event.target.id === 'mobileMenu') { |
| closeMobileMenu(); |
| } |
| } |
| |
| function showAlert(message, type = 'success') { |
| const container = document.getElementById('alertContainer'); |
| container.innerHTML = `<div class="alert ${type}">${message}</div>`; |
| setTimeout(() => { |
| container.innerHTML = ''; |
| }, 5000); |
| } |
| |
| function openCreateModal() { |
| currentEditUserId = null; |
| document.getElementById('modalTitle').textContent = '์ ์ฌ์ฉ์ ์ถ๊ฐ'; |
| document.getElementById('userForm').reset(); |
| document.getElementById('userId').value = ''; |
| document.getElementById('password').required = true; |
| document.getElementById('isActive').checked = true; |
| document.getElementById('userModal').classList.add('active'); |
| } |
| |
| function openEditModal(userId, username, nickname, isAdmin, isActive) { |
| currentEditUserId = userId; |
| document.getElementById('modalTitle').textContent = '์ฌ์ฉ์ ์์ '; |
| document.getElementById('userId').value = userId; |
| document.getElementById('username').value = username; |
| document.getElementById('nickname').value = nickname || ''; |
| document.getElementById('password').value = ''; |
| document.getElementById('password').required = false; |
| document.getElementById('isAdmin').checked = isAdmin; |
| document.getElementById('isActive').checked = isActive; |
| document.getElementById('userModal').classList.add('active'); |
| } |
| |
| function closeModal() { |
| document.getElementById('userModal').classList.remove('active'); |
| currentEditUserId = null; |
| } |
| |
| async function saveUser(event) { |
| event.preventDefault(); |
| |
| const formData = { |
| username: document.getElementById('username').value.trim(), |
| nickname: document.getElementById('nickname').value.trim(), |
| password: document.getElementById('password').value, |
| is_admin: document.getElementById('isAdmin').checked, |
| is_active: document.getElementById('isActive').checked |
| }; |
| |
| const userId = document.getElementById('userId').value; |
| const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users'; |
| const method = userId ? 'PUT' : 'POST'; |
| |
| if (!userId && !formData.password) { |
| showAlert('๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.', 'error'); |
| return; |
| } |
| |
| try { |
| const response = await fetch(url, { |
| method: method, |
| headers: { |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify(formData) |
| }); |
| |
| const data = await response.json(); |
| |
| if (response.ok) { |
| showAlert(data.message, 'success'); |
| closeModal(); |
| setTimeout(() => { |
| location.reload(); |
| }, 1000); |
| } else { |
| showAlert(data.error || '์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.', 'error'); |
| } |
| } catch (error) { |
| showAlert(`์ค๋ฅ: ${error.message}`, 'error'); |
| } |
| } |
| |
| async function deleteUser(userId) { |
| if (!confirm('์ ๋ง ์ด ์ฌ์ฉ์๋ฅผ ์ญ์ ํ์๊ฒ ์ต๋๊น?')) { |
| return; |
| } |
| |
| try { |
| const response = await fetch(`/api/admin/users/${userId}`, { |
| method: 'DELETE' |
| }); |
| |
| const data = await response.json(); |
| |
| if (response.ok) { |
| showAlert(data.message, 'success'); |
| setTimeout(() => { |
| location.reload(); |
| }, 1000); |
| } else { |
| showAlert(data.error || '์ญ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.', 'error'); |
| } |
| } catch (error) { |
| showAlert(`์ค๋ฅ: ${error.message}`, 'error'); |
| } |
| } |
| |
| |
| document.getElementById('userModal').addEventListener('click', function(e) { |
| if (e.target === this) { |
| closeModal(); |
| } |
| }); |
| |
| |
| </script> |
| </body> |
| </html> |
|
|
|
|