soyailabs / templates /admin.html
SOY NV AI
Add Hugging Face Spaces deployment support and file public/private feature
ae31891
raw
history blame
25.5 kB
<!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="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</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()">&times;</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>