You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3662 lines
160 KiB
3662 lines
160 KiB
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>HikeMap Admin</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0f0f23;
|
|
color: #fff;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.admin-container {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.admin-sidebar {
|
|
width: 250px;
|
|
background: #16213e;
|
|
padding: 20px 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: fixed;
|
|
height: 100vh;
|
|
border-right: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.admin-logo {
|
|
padding: 20px;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
color: #4CAF50;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 15px 20px;
|
|
color: #aaa;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-item:hover {
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
}
|
|
|
|
.nav-item.active {
|
|
background: rgba(76, 175, 80, 0.2);
|
|
color: #4CAF50;
|
|
border-left: 3px solid #4CAF50;
|
|
}
|
|
|
|
.nav-item .icon {
|
|
margin-right: 12px;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.nav-spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
/* Main Content */
|
|
.admin-main {
|
|
flex: 1;
|
|
margin-left: 250px;
|
|
padding: 30px;
|
|
}
|
|
|
|
.section {
|
|
display: none;
|
|
}
|
|
|
|
.section.active {
|
|
display: block;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.section-header h2 {
|
|
font-size: 1.8rem;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.95rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #4CAF50;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #45a049;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: rgba(255,255,255,0.1);
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #f44336;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #d32f2f;
|
|
}
|
|
|
|
.btn-warning {
|
|
background: #ff9800;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-warning:hover {
|
|
background: #f57c00;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Tables */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: 14px 16px;
|
|
text-align: left;
|
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.data-table th {
|
|
background: rgba(255,255,255,0.05);
|
|
font-weight: 600;
|
|
color: #aaa;
|
|
text-transform: uppercase;
|
|
font-size: 0.8rem;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.data-table tr:hover {
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
|
|
.data-table .actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Toggle Switch */
|
|
.toggle {
|
|
position: relative;
|
|
width: 50px;
|
|
height: 26px;
|
|
}
|
|
|
|
.toggle input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255,255,255,0.2);
|
|
border-radius: 26px;
|
|
transition: 0.3s;
|
|
}
|
|
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 20px;
|
|
width: 20px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background: #fff;
|
|
border-radius: 50%;
|
|
transition: 0.3s;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider {
|
|
background: #4CAF50;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider:before {
|
|
transform: translateX(24px);
|
|
}
|
|
|
|
/* Badges */
|
|
.badge {
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-admin {
|
|
background: rgba(255, 193, 7, 0.2);
|
|
color: #ffc107;
|
|
}
|
|
|
|
.badge-user {
|
|
background: rgba(100, 181, 246, 0.2);
|
|
color: #64b5f6;
|
|
}
|
|
|
|
.badge-level {
|
|
background: rgba(76, 175, 80, 0.2);
|
|
color: #4CAF50;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal {
|
|
background: #1a1a2e;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
width: 90%;
|
|
max-width: 600px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
font-size: 1.4rem;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: #aaa;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
/* Forms */
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-group select option,
|
|
select option {
|
|
background: #2d2d2d;
|
|
color: #fff;
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group select:focus,
|
|
.form-group textarea:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
}
|
|
|
|
.form-group textarea {
|
|
resize: vertical;
|
|
min-height: 100px;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.form-row-3 {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.form-row-4 {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.modal-wide {
|
|
max-width: 700px;
|
|
}
|
|
|
|
.status-effect-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.status-effect-section h4 {
|
|
margin-bottom: 5px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Skill Type Badges */
|
|
.skill-type-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.skill-type-damage {
|
|
background: rgba(244, 67, 54, 0.2);
|
|
color: #f44336;
|
|
}
|
|
|
|
.skill-type-heal {
|
|
background: rgba(76, 175, 80, 0.2);
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.skill-type-buff {
|
|
background: rgba(33, 150, 243, 0.2);
|
|
color: #2196F3;
|
|
}
|
|
|
|
.skill-type-debuff {
|
|
background: rgba(156, 39, 176, 0.2);
|
|
color: #9C27B0;
|
|
}
|
|
|
|
.skill-type-status {
|
|
background: rgba(255, 152, 0, 0.2);
|
|
color: #FF9800;
|
|
}
|
|
|
|
.skill-type-utility {
|
|
background: rgba(0, 188, 212, 0.2);
|
|
color: #00BCD4;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
margin-top: 30px;
|
|
}
|
|
|
|
/* Settings Section */
|
|
.settings-card {
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.settings-card h3 {
|
|
margin-bottom: 20px;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
/* Loading State */
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #aaa;
|
|
}
|
|
|
|
/* Error/Success Messages */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 30px;
|
|
right: 30px;
|
|
padding: 15px 25px;
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
font-weight: 500;
|
|
z-index: 2000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
.toast.success {
|
|
background: #4CAF50;
|
|
}
|
|
|
|
.toast.error {
|
|
background: #f44336;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Dialogue Editor */
|
|
.dialogue-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.dialogue-section h4 {
|
|
margin-bottom: 15px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.dialogue-phase {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.dialogue-phase label {
|
|
font-weight: 600;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.dialogue-items {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.dialogue-item {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.dialogue-item input {
|
|
flex: 1;
|
|
}
|
|
|
|
.dialogue-item .btn {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
/* Skills Editor */
|
|
.skills-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.skills-section h4 {
|
|
margin-bottom: 15px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.add-skill-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.add-skill-row select {
|
|
flex: 1;
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
}
|
|
|
|
.add-skill-row select option {
|
|
background: #2d2d2d;
|
|
color: #fff;
|
|
}
|
|
|
|
.monster-skill-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
background: rgba(255,255,255,0.05);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.monster-skill-item .skill-name {
|
|
flex: 1;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.monster-skill-item .skill-weight,
|
|
.monster-skill-item .skill-min-level {
|
|
width: 60px;
|
|
text-align: center;
|
|
}
|
|
|
|
.monster-skill-item .skill-animation {
|
|
width: 100px;
|
|
padding: 4px;
|
|
font-size: 12px;
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
border-radius: 4px;
|
|
color: inherit;
|
|
}
|
|
|
|
.monster-skill-item label {
|
|
font-size: 11px;
|
|
color: #888;
|
|
}
|
|
|
|
.skill-icon-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
border: 2px dashed #555;
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,0.05);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.skill-icon-btn:hover {
|
|
border-color: #4CAF50;
|
|
background: rgba(76, 175, 80, 0.1);
|
|
}
|
|
|
|
.skill-icon-btn.has-icon {
|
|
border-style: solid;
|
|
border-color: #4CAF50;
|
|
}
|
|
|
|
.skill-icon-btn img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.skill-icon-btn .icon-placeholder {
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
|
|
.skill-name-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.skill-custom-name {
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
font-size: 13px;
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
}
|
|
|
|
.skill-custom-name:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
}
|
|
|
|
.skill-custom-name::placeholder {
|
|
color: #888;
|
|
font-style: italic;
|
|
}
|
|
|
|
.skill-base-name {
|
|
font-size: 10px;
|
|
color: #666;
|
|
}
|
|
|
|
/* Stats Display */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
|
gap: 10px;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.stat-item {
|
|
background: rgba(255,255,255,0.05);
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-item .value {
|
|
font-size: 1.2rem;
|
|
font-weight: bold;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.stat-item .label {
|
|
font-size: 0.75rem;
|
|
color: #aaa;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Login Screen */
|
|
.login-screen {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
background: #0f0f23;
|
|
}
|
|
|
|
.login-box {
|
|
background: #1a1a2e;
|
|
padding: 40px;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.login-box h2 {
|
|
margin-bottom: 20px;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.login-box p {
|
|
color: #aaa;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Animation preview styles */
|
|
.animation-preview-container {
|
|
background: #2a2a2a;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 150px;
|
|
margin-bottom: 15px;
|
|
}
|
|
.animation-preview-icon {
|
|
width: 100px;
|
|
height: 100px;
|
|
object-fit: contain;
|
|
}
|
|
.animation-test-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
.animation-test-row select {
|
|
flex: 1;
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
}
|
|
|
|
.animation-test-row select option {
|
|
background: #2d2d2d;
|
|
color: #fff;
|
|
}
|
|
|
|
/* Monster skill inline selects */
|
|
.monster-skill-item select,
|
|
.skill-animation {
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
.monster-skill-item select option,
|
|
.skill-animation option {
|
|
background: #2d2d2d;
|
|
color: #fff;
|
|
}
|
|
</style>
|
|
<!-- Monster Animation Definitions -->
|
|
<script src="/animations.js"></script>
|
|
</head>
|
|
<body>
|
|
<!-- Login Screen (shown if not authenticated) -->
|
|
<div id="loginScreen" class="login-screen" style="display: none;">
|
|
<div class="login-box">
|
|
<h2>HikeMap Admin</h2>
|
|
<p>You must be logged in as an admin to access this page.</p>
|
|
<a href="/" class="btn btn-primary">Go to Login</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin Container -->
|
|
<div id="adminContainer" class="admin-container" style="display: none;">
|
|
<!-- Sidebar -->
|
|
<nav class="admin-sidebar">
|
|
<div class="admin-logo">HikeMap Admin</div>
|
|
<a class="nav-item active" data-section="monsters">
|
|
<span class="icon">👾</span> Monsters
|
|
</a>
|
|
<a class="nav-item" data-section="skills">
|
|
<span class="icon">⚡</span> Skills
|
|
</a>
|
|
<a class="nav-item" data-section="classes">
|
|
<span class="icon">🏆</span> Classes
|
|
</a>
|
|
<a class="nav-item" data-section="users">
|
|
<span class="icon">👤</span> Users
|
|
</a>
|
|
<a class="nav-item" data-section="settings">
|
|
<span class="icon">⚙</span> Settings
|
|
</a>
|
|
<a class="nav-item" data-section="osm-tags">
|
|
<span class="icon">🌏</span> OSM Tags
|
|
</a>
|
|
<div class="nav-spacer"></div>
|
|
<a class="nav-item" href="/">
|
|
<span class="icon">←</span> Back to App
|
|
</a>
|
|
</nav>
|
|
|
|
<!-- Main Content -->
|
|
<main class="admin-main">
|
|
<!-- Monsters Section -->
|
|
<section id="monsters-section" class="section active">
|
|
<div class="section-header">
|
|
<h2>Monster Types</h2>
|
|
<button class="btn btn-primary" id="addMonsterBtn">+ Add Monster</button>
|
|
</div>
|
|
<table class="data-table" id="monsterTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Key</th>
|
|
<th>Level</th>
|
|
<th>HP</th>
|
|
<th>MP</th>
|
|
<th>ATK</th>
|
|
<th>DEF</th>
|
|
<th>XP</th>
|
|
<th>Enabled</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="monsterTableBody">
|
|
<tr><td colspan="9" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<!-- Skills Section -->
|
|
<section id="skills-section" class="section">
|
|
<div class="section-header">
|
|
<h2>Skills Database</h2>
|
|
<button class="btn btn-primary" id="addSkillBtn">+ Add Skill</button>
|
|
</div>
|
|
<table class="data-table" id="skillTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>ID</th>
|
|
<th>Type</th>
|
|
<th>Power</th>
|
|
<th>Accuracy</th>
|
|
<th>MP</th>
|
|
<th>Target</th>
|
|
<th>Player</th>
|
|
<th>Monster</th>
|
|
<th>Enabled</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="skillTableBody">
|
|
<tr><td colspan="11" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Utility Skills Subsection -->
|
|
<div style="margin-top: 40px;">
|
|
<div class="section-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
|
|
<div>
|
|
<h3>Utility Skills</h3>
|
|
<p style="font-size: 12px; color: #888; margin-top: 5px; margin-bottom: 15px;">
|
|
Skills that provide passive buffs outside of combat (MP/HP regen, stat boosts, XP multipliers)
|
|
</p>
|
|
</div>
|
|
<button class="btn btn-primary" id="addUtilitySkillBtn">+ Add Utility Skill</button>
|
|
</div>
|
|
<table class="data-table" id="utilitySkillTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>ID</th>
|
|
<th>Effect Type</th>
|
|
<th>Value</th>
|
|
<th>Duration</th>
|
|
<th>Cooldown</th>
|
|
<th>Enabled</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="utilitySkillTableBody">
|
|
<tr><td colspan="8" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Classes Section -->
|
|
<section id="classes-section" class="section">
|
|
<div class="section-header">
|
|
<h2>Character Classes</h2>
|
|
<button class="btn btn-primary" id="addClassBtn">+ Add Class</button>
|
|
</div>
|
|
<table class="data-table" id="classTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>ID</th>
|
|
<th>Base Stats</th>
|
|
<th>Growth/Level</th>
|
|
<th>Skills</th>
|
|
<th>Enabled</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="classTableBody">
|
|
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<!-- Users Section -->
|
|
<section id="users-section" class="section">
|
|
<div class="section-header">
|
|
<h2>Users</h2>
|
|
</div>
|
|
<table class="data-table" id="userTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Character</th>
|
|
<th>Race/Class</th>
|
|
<th>Level</th>
|
|
<th>Stats</th>
|
|
<th>Role</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="userTableBody">
|
|
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<!-- Settings Section -->
|
|
<section id="settings-section" class="section">
|
|
<div class="section-header">
|
|
<h2>Game Settings</h2>
|
|
</div>
|
|
<div class="settings-card">
|
|
<h3>Monster Spawning</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Spawn Interval (seconds)</label>
|
|
<input type="number" id="setting-monsterSpawnInterval" placeholder="20" min="5" step="1">
|
|
<small style="color: #888; font-size: 11px;">How often spawn attempts occur</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spawn Chance (%)</label>
|
|
<input type="number" id="setting-monsterSpawnChance" placeholder="50" min="1" max="100">
|
|
<small style="color: #888; font-size: 11px;">Percent chance per interval</small>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Movement Distance (meters)</label>
|
|
<input type="number" id="setting-monsterSpawnDistance" placeholder="10" min="1">
|
|
<small style="color: #888; font-size: 11px;">Distance player must move for new spawns</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Max Monsters Per Player</label>
|
|
<input type="number" id="setting-maxMonstersPerPlayer" placeholder="10" min="1">
|
|
<small style="color: #888; font-size: 11px;">Maximum monsters following player</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="settings-card">
|
|
<h3>Game Balance</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>XP Multiplier</label>
|
|
<input type="number" step="0.1" id="setting-xpMultiplier" placeholder="1.0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Combat Enabled</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" id="setting-combatEnabled">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>MP Regen Distance (meters)</label>
|
|
<input type="number" id="setting-mpRegenDistance" placeholder="5" min="0" step="1">
|
|
<small style="color: #888; font-size: 11px;">Meters walked to regen 1 MP (0 = disabled)</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>MP Regen Amount</label>
|
|
<input type="number" id="setting-mpRegenAmount" placeholder="1" min="1" step="1">
|
|
<small style="color: #888; font-size: 11px;">MP gained per distance threshold</small>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">HP Regeneration</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>HP Regen Interval (seconds)</label>
|
|
<input type="number" id="setting-hpRegenInterval" placeholder="10" min="1" step="1">
|
|
<small style="color: #888; font-size: 11px;">Time between HP regen ticks</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>HP Regen Percent</label>
|
|
<input type="number" step="0.1" id="setting-hpRegenPercent" placeholder="1" min="0.1">
|
|
<small style="color: #888; font-size: 11px;">% of max HP restored per tick</small>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Home Base Bonuses</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Home HP Regen Multiplier</label>
|
|
<input type="number" step="0.5" id="setting-homeHpMultiplier" placeholder="3" min="1">
|
|
<small style="color: #888; font-size: 11px;">HP regen multiplier when at home (e.g., 3 = 3x faster)</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Home Regen Percent</label>
|
|
<input type="number" step="1" id="setting-homeRegenPercent" placeholder="5" min="1">
|
|
<small style="color: #888; font-size: 11px;">% of max HP/MP per tick when at home base</small>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Home Base Radius (meters)</label>
|
|
<input type="number" id="setting-homeBaseRadius" placeholder="20" min="5" step="1">
|
|
<small style="color: #888; font-size: 11px;">Distance from home to get bonuses</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<!-- empty for alignment -->
|
|
</div>
|
|
</div>
|
|
|
|
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Session Settings</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Inactivity Timeout (minutes)</label>
|
|
<input type="number" id="setting-inactivityTimeout" placeholder="10" min="1" step="1">
|
|
<small style="color: #888; font-size: 11px;">Minutes of inactivity before auto-logout</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Logout Warning Time (seconds)</label>
|
|
<input type="number" id="setting-inactivityWarningTime" placeholder="60" min="10" step="5">
|
|
<small style="color: #888; font-size: 11px;">Warning shown this many seconds before logout</small>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Combat UI Settings</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Combat Monster Icon Scale (Mobile)</label>
|
|
<input type="number" id="setting-combatIconScale" placeholder="0.7" min="0.3" max="1.5" step="0.1">
|
|
<small style="color: #888; font-size: 11px;">Scale for monster icons on mobile only (0.3 = 30px, 0.7 = 70px, 1.0 = 100px). Desktop always uses 100px.</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<!-- empty for alignment -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- OSM Tags Section -->
|
|
<section id="osm-tags-section" class="section">
|
|
<div class="section-header">
|
|
<h2>OSM Tag Prefixes</h2>
|
|
<button class="btn btn-primary" id="addOsmTagBtn">+ Add Tag</button>
|
|
</div>
|
|
|
|
<!-- Global Prefix Settings -->
|
|
<div class="settings-card" style="margin-bottom: 30px;">
|
|
<h3>Prefix Settings</h3>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Base Prefix Chance (%)</label>
|
|
<input type="number" id="osm-basePrefixChance" min="0" max="100" value="25">
|
|
<small style="color: #888; font-size: 11px;">Chance to spawn with any prefix when in a tag zone</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Double Prefix Chance (%)</label>
|
|
<input type="number" id="osm-doublePrefixChance" min="0" max="100" value="10">
|
|
<small style="color: #888; font-size: 11px;">Chance for two prefixes when eligible</small>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" id="saveOsmSettingsBtn">Save Prefix Settings</button>
|
|
</div>
|
|
|
|
<!-- Tags Table -->
|
|
<table class="data-table" id="osmTagTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Tag ID</th>
|
|
<th>Artwork</th>
|
|
<th>Prefixes</th>
|
|
<th>Visibility</th>
|
|
<th>Spawn Radius</th>
|
|
<th>Enabled</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="osmTagTableBody">
|
|
<tr><td colspan="7" class="loading">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- OSM Tag Edit Modal -->
|
|
<div class="modal-overlay" id="osmTagModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3 id="osmTagModalTitle">Edit OSM Tag</h3>
|
|
<button class="modal-close" onclick="closeOsmTagModal()">×</button>
|
|
</div>
|
|
<form id="osmTagForm">
|
|
<input type="hidden" id="osmTagIdField">
|
|
<div class="form-group">
|
|
<label>Tag ID</label>
|
|
<input type="text" id="osmTagIdInput" required placeholder="e.g., grocery, restaurant">
|
|
<small style="color: #888; font-size: 11px;">Matches geocache tags array values</small>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Artwork Number</label>
|
|
<input type="number" id="osmTagArtwork" value="1" min="1" placeholder="1" onchange="updateArtworkPreview()">
|
|
<small style="color: #888; font-size: 11px;">Maps to cacheIcon100-{number}.png</small>
|
|
<div id="artworkPreviewContainer" style="margin-top: 8px; position: relative; width: 80px; height: 80px;">
|
|
<img id="artworkPreviewShadow" src="" style="position: absolute; width: 64px; height: 64px; top: 8px; left: 8px; opacity: 0.5; display: none;">
|
|
<img id="artworkPreview" src="" style="position: absolute; width: 64px; height: 64px; top: 4px; left: 4px;">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Main Animation</label>
|
|
<select id="osmTagAnimation">
|
|
<option value="">None</option>
|
|
</select>
|
|
<label style="margin-top: 10px;">Shadow Animation</label>
|
|
<select id="osmTagAnimationShadow">
|
|
<option value="">None</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Visibility Distance (m)</label>
|
|
<input type="number" id="osmTagVisibility" value="400" min="50">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spawn Radius (m)</label>
|
|
<input type="number" id="osmTagSpawnRadius" value="400" min="50">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Prefixes (one per line)</label>
|
|
<textarea id="osmTagPrefixes" rows="6" placeholder="Cart Wranglin' Stock-Boy Aisle Runner"></textarea>
|
|
<small style="color: #888; font-size: 11px;">Each prefix on its own line. Tag is enabled when at least one prefix exists.</small>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeOsmTagModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monster Edit Modal -->
|
|
<div class="modal-overlay" id="monsterModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3 id="monsterModalTitle">Edit Monster</h3>
|
|
<button class="modal-close" onclick="closeMonsterModal()">×</button>
|
|
</div>
|
|
<form id="monsterForm">
|
|
<input type="hidden" id="monsterId">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Monster Name</label>
|
|
<input type="text" id="monsterName" required placeholder="e.g., Moop">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Key (unique identifier)</label>
|
|
<input type="text" id="monsterKey" required placeholder="e.g., moop">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Min Level</label>
|
|
<input type="number" id="monsterMinLevel" required value="1" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Max Level</label>
|
|
<input type="number" id="monsterMaxLevel" required value="5" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base HP</label>
|
|
<input type="number" id="monsterHp" required value="25" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Base ATK</label>
|
|
<input type="number" id="monsterAtk" required value="8" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base DEF</label>
|
|
<input type="number" id="monsterDef" required value="3" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base MP</label>
|
|
<input type="number" id="monsterMp" required value="20" min="0">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Base XP</label>
|
|
<input type="number" id="monsterXp" required value="10" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>MP/Level</label>
|
|
<input type="number" id="monsterMpScale" required value="5" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<!-- empty for alignment -->
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spawn Weight (higher = more common)</label>
|
|
<input type="number" id="monsterWeight" required value="100" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spawn Location</label>
|
|
<select id="monsterSpawnLocation">
|
|
<option value="anywhere">Anywhere</option>
|
|
<option value="grocery">Grocery Stores</option>
|
|
<option value="restaurant">Restaurants</option>
|
|
<option value="fastfood">Fast Food</option>
|
|
<option value="park">Parks</option>
|
|
<option value="bank">Banks</option>
|
|
<option value="cafe">Cafes</option>
|
|
<option value="bar">Bars</option>
|
|
<option value="pharmacy">Pharmacies</option>
|
|
<option value="convenience">Convenience Stores</option>
|
|
<option value="gasstation">Gas Stations</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="monsterEnabled" checked> Enabled
|
|
</label>
|
|
</div>
|
|
|
|
<div class="skills-section">
|
|
<h4>Monster Skills</h4>
|
|
<div id="monsterSkillsList"></div>
|
|
<div class="add-skill-row">
|
|
<select id="addSkillSelect" onchange="onSkillSelectChange()">
|
|
<option value="">-- Select a skill --</option>
|
|
</select>
|
|
<input type="text" id="addSkillCustomName" placeholder="Custom name (optional)" style="width: 150px;">
|
|
<input type="number" id="addSkillWeight" placeholder="Weight" value="10" min="1" style="width: 70px;">
|
|
<input type="number" id="addSkillMinLevel" placeholder="Min Lvl" value="1" min="1" style="width: 70px;">
|
|
<button type="button" class="btn btn-secondary btn-small" onclick="addMonsterSkill()">Add</button>
|
|
</div>
|
|
<p style="font-size: 11px; color: #666; margin-top: 5px;">Custom name overrides skill name for this monster. Weight = selection probability.</p>
|
|
</div>
|
|
|
|
<div class="dialogue-section">
|
|
<h4>Dialogues (one per line)</h4>
|
|
<div class="form-group">
|
|
<label>Annoyed (0-5 min)</label>
|
|
<textarea id="dialogueAnnoyed" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Frustrated (5-10 min)</label>
|
|
<textarea id="dialogueFrustrated" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Desperate (10-30 min)</label>
|
|
<textarea id="dialogueDesperate" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Philosophical (30-60 min)</label>
|
|
<textarea id="dialoguePhilosophical" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Existential (60+ min)</label>
|
|
<textarea id="dialogueExistential" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="animation-section">
|
|
<h4>Animation Overrides</h4>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Attack Animation</label>
|
|
<select id="monsterAttackAnim">
|
|
<!-- Populated dynamically -->
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Death Animation</label>
|
|
<select id="monsterDeathAnim">
|
|
<!-- Populated dynamically -->
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Idle Animation</label>
|
|
<select id="monsterIdleAnim">
|
|
<!-- Populated dynamically -->
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<h4>Animation Preview</h4>
|
|
<div class="animation-preview-container">
|
|
<img id="animPreviewIcon" class="animation-preview-icon" src="/mapgameimgs/monsters/default100.png" alt="Preview">
|
|
</div>
|
|
<div class="animation-test-row">
|
|
<select id="testAnimationSelect">
|
|
<!-- Populated dynamically -->
|
|
</select>
|
|
<button type="button" class="btn btn-secondary" onclick="testMonsterAnimation()">Test Animation</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeMonsterModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Monster</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Edit Modal -->
|
|
<div class="modal-overlay" id="userModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Edit User</h3>
|
|
<button class="modal-close" onclick="closeUserModal()">×</button>
|
|
</div>
|
|
<form id="userForm">
|
|
<input type="hidden" id="userId">
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="userUsername" disabled>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Character Name</label>
|
|
<input type="text" id="userCharacterName">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Level</label>
|
|
<input type="number" id="userLevel" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>XP</label>
|
|
<input type="number" id="userXp" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>HP / Max HP</label>
|
|
<div class="form-row">
|
|
<input type="number" id="userHp" min="0">
|
|
<input type="number" id="userMaxHp" min="1">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>MP / Max MP</label>
|
|
<div class="form-row">
|
|
<input type="number" id="userMp" min="0">
|
|
<input type="number" id="userMaxMp" min="0">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>ATK / DEF</label>
|
|
<div class="form-row">
|
|
<input type="number" id="userAtk" min="1">
|
|
<input type="number" id="userDef" min="0">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-danger" onclick="resetUserProgress()">Reset Progress</button>
|
|
<button type="button" class="btn btn-warning" onclick="resetUserHomeBase()">Reset Home Base</button>
|
|
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skill Edit Modal -->
|
|
<div class="modal-overlay" id="skillModal">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h3 id="skillModalTitle">Edit Skill</h3>
|
|
<button class="modal-close" onclick="closeSkillModal()">×</button>
|
|
</div>
|
|
<form id="skillForm">
|
|
<input type="hidden" id="skillEditId">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Skill Name</label>
|
|
<input type="text" id="skillName" required placeholder="e.g., Double Attack">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>ID (unique identifier)</label>
|
|
<input type="text" id="skillId" required placeholder="e.g., double_attack">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<input type="text" id="skillDescription" placeholder="e.g., Attack twice with reduced power">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Type</label>
|
|
<select id="skillType" required onchange="handleSkillTypeChange()">
|
|
<option value="damage">Damage</option>
|
|
<option value="heal">Heal</option>
|
|
<option value="buff">Buff</option>
|
|
<option value="debuff">Debuff</option>
|
|
<option value="status">Status Effect</option>
|
|
<option value="utility">Utility (Out-of-Combat)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Target</label>
|
|
<select id="skillTarget">
|
|
<option value="enemy">Enemy</option>
|
|
<option value="self">Self</option>
|
|
<option value="all_enemies">All Enemies</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row-4">
|
|
<div class="form-group">
|
|
<label>MP Cost</label>
|
|
<input type="number" id="skillMpCost" value="0" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base Power</label>
|
|
<input type="number" id="skillBasePower" value="100" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Accuracy (%)</label>
|
|
<input type="number" id="skillAccuracy" value="95" min="0" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Hit Count</label>
|
|
<input type="number" id="skillHitCount" value="1" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-2" id="targetingModeRow">
|
|
<div class="form-group">
|
|
<label>Targeting Mode (Multi-Hit)</label>
|
|
<select id="skillTargetingMode">
|
|
<option value="same_target">Same Target (all hits on selected enemy)</option>
|
|
<option value="per_hit">Per-Hit Selection (player chooses each hit)</option>
|
|
<option value="random">Random (each hit targets random enemy)</option>
|
|
</select>
|
|
<small style="color: #666; font-size: 11px;">Only applies when Hit Count > 1 and multiple enemies present</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-effect-section">
|
|
<h4>Status Effect (optional)</h4>
|
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">For skills that apply effects over time (poison, burn, etc.)</p>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Effect Type</label>
|
|
<select id="skillStatusType">
|
|
<option value="">None</option>
|
|
<option value="poison">Poison</option>
|
|
<option value="burn">Burn</option>
|
|
<option value="bleed">Bleed</option>
|
|
<option value="regen">Regeneration</option>
|
|
<option value="stun">Stun</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Damage/Turn</label>
|
|
<input type="number" id="skillStatusDamage" value="5" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Duration (turns)</label>
|
|
<input type="number" id="skillStatusDuration" value="3" min="1">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Utility Skill Configuration (hidden by default) -->
|
|
<div class="status-effect-section" id="utilityConfigSection" style="display: none;">
|
|
<h4>Utility Skill Configuration</h4>
|
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
|
|
Configure the buff effect for this utility skill (e.g., Second Wind, XP Boost)
|
|
</p>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Effect Type</label>
|
|
<select id="utilityEffectType">
|
|
<option value="hp_regen_multiplier">HP Regen Multiplier</option>
|
|
<option value="mp_regen_multiplier">MP Regen Multiplier</option>
|
|
<option value="atk_boost_flat">ATK Boost (Flat)</option>
|
|
<option value="atk_boost_percent">ATK Boost (%)</option>
|
|
<option value="def_boost_flat">DEF Boost (Flat)</option>
|
|
<option value="def_boost_percent">DEF Boost (%)</option>
|
|
<option value="xp_multiplier">XP Multiplier</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Effect Value</label>
|
|
<input type="number" id="utilityEffectValue" value="2.0" min="0" step="0.1"
|
|
placeholder="e.g., 2.0 for 2x multiplier">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Duration (hours)</label>
|
|
<input type="number" id="utilityDurationHours" value="1" min="0.1" step="0.1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Cooldown (hours)</label>
|
|
<input type="number" id="utilityCooldownHours" value="24" min="0" step="0.5">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row-3" style="margin-top: 15px;">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillPlayerUsable" checked> Player Can Use
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillMonsterUsable" checked> Monster Can Use
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillEnabled" checked> Enabled
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skill Icon Upload -->
|
|
<div class="skill-icon-section" style="margin-top: 15px; padding: 15px; border: 1px solid #333; border-radius: 8px;">
|
|
<h4 style="margin: 0 0 10px 0; color: #4CAF50;">Skill Icon</h4>
|
|
<div style="display: flex; align-items: center; gap: 20px;">
|
|
<div id="skillIconPreview" style="width: 64px; height: 64px; border: 2px dashed #555; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: #1a1a1a; overflow: hidden;">
|
|
<span style="color: #666; font-size: 11px;">No icon</span>
|
|
</div>
|
|
<div>
|
|
<input type="file" id="skillIconFile" accept="image/png,image/jpeg,image/gif,image/webp" style="display: none;">
|
|
<button type="button" class="btn btn-secondary" style="margin-bottom: 5px;" onclick="document.getElementById('skillIconFile').click()">Upload Icon</button>
|
|
<button type="button" class="btn btn-danger" id="removeSkillIconBtn" style="display: none; margin-left: 5px;" onclick="removeSkillIcon()">Remove</button>
|
|
<p style="font-size: 11px; color: #666; margin: 5px 0 0 0;">Recommended: 64x64 PNG. Max 500KB. Will be auto-renamed to skill ID.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeSkillModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Skill</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Class Edit Modal -->
|
|
<div class="modal-overlay" id="classModal">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h3 id="classModalTitle">Edit Class</h3>
|
|
<button class="modal-close" onclick="closeClassModal()">×</button>
|
|
</div>
|
|
<form id="classForm">
|
|
<input type="hidden" id="classEditId">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Class Name</label>
|
|
<input type="text" id="className" required placeholder="e.g., Trail Runner">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>ID (unique identifier)</label>
|
|
<input type="text" id="classId" required placeholder="e.g., trail_runner">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<textarea id="classDescription" placeholder="Class description..."></textarea>
|
|
</div>
|
|
|
|
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Base Stats (Level 1)</h4>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Base HP</label>
|
|
<input type="number" id="classBaseHp" value="100" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base MP</label>
|
|
<input type="number" id="classBaseMp" value="50" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base ATK</label>
|
|
<input type="number" id="classBaseAtk" value="12" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-4">
|
|
<div class="form-group">
|
|
<label>Base DEF</label>
|
|
<input type="number" id="classBaseDef" value="8" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base Accuracy</label>
|
|
<input type="number" id="classBaseAccuracy" value="90" min="1" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base Dodge</label>
|
|
<input type="number" id="classBaseDodge" value="10" min="0" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="classEnabled"> Enabled
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Stat Growth (Per Level)</h4>
|
|
<div class="form-row-4">
|
|
<div class="form-group">
|
|
<label>HP/Level</label>
|
|
<input type="number" id="classHpPerLevel" value="10" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>MP/Level</label>
|
|
<input type="number" id="classMpPerLevel" value="5" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>ATK/Level</label>
|
|
<input type="number" id="classAtkPerLevel" value="2" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>DEF/Level</label>
|
|
<input type="number" id="classDefPerLevel" value="1" min="0">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="skills-section">
|
|
<h4>Class Skills</h4>
|
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
|
|
Assign skills to this class. Set unlock level and choice group for skill selection at level-up.
|
|
<br>Skills with the same choice group at the same level = player picks one.
|
|
</p>
|
|
<div id="classSkillsList"></div>
|
|
<div class="add-skill-row">
|
|
<select id="addClassSkillSelect">
|
|
<option value="">-- Select a skill --</option>
|
|
</select>
|
|
<input type="number" id="addClassSkillLevel" placeholder="Lvl" value="1" min="1" style="width: 60px;">
|
|
<input type="number" id="addClassSkillChoiceGroup" placeholder="Group" style="width: 70px;" title="Choice group (blank for auto-learn)">
|
|
<input type="text" id="addClassSkillName" placeholder="Custom name" style="width: 120px;">
|
|
<button type="button" class="btn btn-secondary btn-small" onclick="addClassSkill()">Add</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeClassModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Class</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// State
|
|
let accessToken = localStorage.getItem('accessToken');
|
|
let monsters = [];
|
|
let users = [];
|
|
let settings = {};
|
|
let allSkills = [];
|
|
let currentMonsterSkills = []; // Skills for the monster being edited
|
|
let allClasses = [];
|
|
let currentClassSkills = []; // Skills for the class being edited
|
|
let pendingSkillIcon = null; // Pending icon file for upload
|
|
|
|
// API Helper
|
|
async function api(endpoint, options = {}) {
|
|
const url = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
...options.headers
|
|
}
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
showLoginScreen();
|
|
throw new Error('Not authorized');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'API error');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Toast notifications
|
|
function showToast(message, type = 'success') {
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) existing.remove();
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
// Show login screen
|
|
function showLoginScreen() {
|
|
document.getElementById('loginScreen').style.display = 'flex';
|
|
document.getElementById('adminContainer').style.display = 'none';
|
|
}
|
|
|
|
// Show admin container
|
|
function showAdminContainer() {
|
|
document.getElementById('loginScreen').style.display = 'none';
|
|
document.getElementById('adminContainer').style.display = 'flex';
|
|
}
|
|
|
|
// Check auth and admin status
|
|
async function checkAuth() {
|
|
if (!accessToken) {
|
|
showLoginScreen();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Try to fetch admin data - will fail if not admin
|
|
await api('/api/admin/settings');
|
|
showAdminContainer();
|
|
loadAllData();
|
|
} catch (e) {
|
|
showLoginScreen();
|
|
}
|
|
}
|
|
|
|
// Navigation
|
|
document.querySelectorAll('.nav-item[data-section]').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const section = item.dataset.section;
|
|
|
|
// Update nav
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
item.classList.add('active');
|
|
|
|
// Update sections
|
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
document.getElementById(`${section}-section`).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Load all data
|
|
async function loadAllData() {
|
|
loadMonsters();
|
|
loadUsers();
|
|
loadSettings();
|
|
loadSkillsAdmin(); // Load skills for admin (includes disabled)
|
|
loadClasses();
|
|
}
|
|
|
|
// ============= MONSTERS =============
|
|
async function loadMonsters() {
|
|
try {
|
|
const data = await api('/api/admin/monster-types');
|
|
monsters = data.monsterTypes || [];
|
|
renderMonsterTable();
|
|
} catch (e) {
|
|
showToast('Failed to load monsters: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadSkills() {
|
|
try {
|
|
const data = await api('/api/skills');
|
|
allSkills = data.skills || [];
|
|
populateSkillSelect();
|
|
} catch (e) {
|
|
console.error('Failed to load skills:', e);
|
|
}
|
|
}
|
|
|
|
function populateSkillSelect() {
|
|
const select = document.getElementById('addSkillSelect');
|
|
select.innerHTML = '<option value="">-- Select a skill --</option>';
|
|
// Support both snake_case (admin API) and camelCase (public API)
|
|
allSkills.filter(s => s.monster_usable || s.monsterUsable).forEach(skill => {
|
|
const opt = document.createElement('option');
|
|
opt.value = skill.id;
|
|
opt.textContent = `${skill.name} (${skill.type})`;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function loadMonsterSkills(monsterTypeId) {
|
|
if (!monsterTypeId) {
|
|
currentMonsterSkills = [];
|
|
renderMonsterSkills();
|
|
return;
|
|
}
|
|
try {
|
|
const data = await api('/api/admin/monster-skills');
|
|
currentMonsterSkills = (data.monsterSkills || []).filter(ms => ms.monster_type_id === monsterTypeId);
|
|
renderMonsterSkills();
|
|
} catch (e) {
|
|
console.error('Failed to load monster skills:', e);
|
|
currentMonsterSkills = [];
|
|
renderMonsterSkills();
|
|
}
|
|
}
|
|
|
|
function renderMonsterSkills() {
|
|
const container = document.getElementById('monsterSkillsList');
|
|
if (currentMonsterSkills.length === 0) {
|
|
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Monster will only use basic attack.</p>';
|
|
return;
|
|
}
|
|
// Build animation options once
|
|
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
|
|
const animOptions = '<option value="">Default</option>' + Object.entries(animations).map(([id, anim]) =>
|
|
`<option value="${id}">${anim.name}</option>`
|
|
).join('');
|
|
|
|
const monsterId = document.getElementById('monsterId').value;
|
|
container.innerHTML = currentMonsterSkills.map(ms => {
|
|
const skill = allSkills.find(s => s.id === ms.skill_id);
|
|
const baseName = skill ? skill.name : ms.skill_id;
|
|
const displayName = ms.custom_name || baseName;
|
|
const hasCustomName = !!ms.custom_name;
|
|
const currentAnim = ms.animation || '';
|
|
const hasIcon = !!ms.custom_icon;
|
|
const iconSrc = hasIcon ? `/mapgameimgs/skills/${ms.custom_icon}` : '';
|
|
return `
|
|
<div class="monster-skill-item" data-id="${ms.id}">
|
|
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
|
|
onclick="uploadMonsterSkillIcon('${monsterId}', '${ms.skill_id}')"
|
|
title="Click to upload custom icon">
|
|
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
|
|
</button>
|
|
<div class="skill-name-section">
|
|
<input type="text" class="skill-custom-name" value="${escapeHtml(ms.custom_name || '')}"
|
|
placeholder="${escapeHtml(baseName)}"
|
|
onchange="updateMonsterSkillName(${ms.id}, this.value)"
|
|
title="Custom name (leave empty for default: ${escapeHtml(baseName)})">
|
|
<span class="skill-base-name">${escapeHtml(baseName)}</span>
|
|
</div>
|
|
<div>
|
|
<label>Wt</label>
|
|
<input type="number" class="skill-weight" value="${ms.weight}" min="1"
|
|
onchange="updateMonsterSkill(${ms.id}, 'weight', this.value)">
|
|
</div>
|
|
<div>
|
|
<label>Lvl</label>
|
|
<input type="number" class="skill-min-level" value="${ms.min_level}" min="1"
|
|
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
|
|
</div>
|
|
<div>
|
|
<label>Anim</label>
|
|
<select class="skill-animation" onchange="updateMonsterSkillAnimation(${ms.id}, this.value)">
|
|
${animOptions.replace(`value="${currentAnim}"`, `value="${currentAnim}" selected`)}
|
|
</select>
|
|
</div>
|
|
<button type="button" class="btn btn-danger btn-small" onclick="removeMonsterSkill(${ms.id})">✕</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function onSkillSelectChange() {
|
|
// Clear custom name when selecting a new skill
|
|
document.getElementById('addSkillCustomName').value = '';
|
|
}
|
|
|
|
async function addMonsterSkill() {
|
|
const monsterId = document.getElementById('monsterId').value;
|
|
const skillId = document.getElementById('addSkillSelect').value;
|
|
const customName = document.getElementById('addSkillCustomName').value.trim();
|
|
const weight = parseInt(document.getElementById('addSkillWeight').value) || 10;
|
|
const minLevel = parseInt(document.getElementById('addSkillMinLevel').value) || 1;
|
|
|
|
if (!skillId) {
|
|
showToast('Please select a skill', 'error');
|
|
return;
|
|
}
|
|
if (!monsterId) {
|
|
showToast('Please save the monster first before adding skills', 'error');
|
|
return;
|
|
}
|
|
|
|
// Check if skill already assigned
|
|
if (currentMonsterSkills.some(ms => ms.skill_id === skillId)) {
|
|
showToast('This skill is already assigned to this monster', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api('/api/admin/monster-skills', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
monster_type_id: monsterId,
|
|
skill_id: skillId,
|
|
custom_name: customName || null,
|
|
weight: weight,
|
|
min_level: minLevel
|
|
})
|
|
});
|
|
// Add to local array immediately (optimistic update)
|
|
const skill = allSkills.find(s => s.id === skillId);
|
|
currentMonsterSkills.push({
|
|
id: result?.id || Date.now(), // Use response ID or temp ID
|
|
monster_type_id: monsterId,
|
|
skill_id: skillId,
|
|
skill_name: skill?.name || skillId,
|
|
custom_name: customName || null,
|
|
weight: weight,
|
|
min_level: minLevel
|
|
});
|
|
showToast('Skill added');
|
|
renderMonsterSkills();
|
|
loadMonsterSkills(monsterId); // Background refresh for consistency
|
|
document.getElementById('addSkillSelect').value = '';
|
|
document.getElementById('addSkillCustomName').value = '';
|
|
} catch (e) {
|
|
showToast('Failed to add skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateMonsterSkillName(id, value) {
|
|
try {
|
|
await api(`/api/admin/monster-skills/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ custom_name: value.trim() || null })
|
|
});
|
|
// Update local state
|
|
const ms = currentMonsterSkills.find(s => s.id === id);
|
|
if (ms) ms.custom_name = value.trim() || null;
|
|
} catch (e) {
|
|
showToast('Failed to update skill name: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateMonsterSkill(id, field, value) {
|
|
try {
|
|
await api(`/api/admin/monster-skills/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ [field]: parseInt(value) })
|
|
});
|
|
// Update local state immediately
|
|
const ms = currentMonsterSkills.find(s => s.id === id);
|
|
if (ms) ms[field] = parseInt(value);
|
|
} catch (e) {
|
|
showToast('Failed to update skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateMonsterSkillAnimation(id, value) {
|
|
try {
|
|
await api(`/api/admin/monster-skills/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ animation: value || null })
|
|
});
|
|
// Update local state
|
|
const ms = currentMonsterSkills.find(s => s.id === id);
|
|
if (ms) ms.animation = value || null;
|
|
} catch (e) {
|
|
showToast('Failed to update skill animation: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeMonsterSkill(id) {
|
|
try {
|
|
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
currentMonsterSkills = currentMonsterSkills.filter(ms => ms.id !== id);
|
|
showToast('Skill removed');
|
|
renderMonsterSkills();
|
|
const monsterId = document.getElementById('monsterId').value;
|
|
loadMonsterSkills(monsterId); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to remove skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderMonsterTable() {
|
|
const tbody = document.getElementById('monsterTableBody');
|
|
if (monsters.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="10">No monsters found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = monsters.map(m => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(m.name)}</strong></td>
|
|
<td><code>${escapeHtml(m.key)}</code></td>
|
|
<td>${m.min_level}-${m.max_level}</td>
|
|
<td>${m.base_hp}</td>
|
|
<td>${m.base_mp || 20}</td>
|
|
<td>${m.base_atk}</td>
|
|
<td>${m.base_def}</td>
|
|
<td>${m.base_xp}</td>
|
|
<td>
|
|
<label class="toggle">
|
|
<input type="checkbox" ${m.enabled ? 'checked' : ''}
|
|
onchange="toggleMonster('${m.id}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editMonster('${m.id}')">Edit</button>
|
|
<button class="btn btn-secondary btn-small" onclick="cloneMonster('${m.id}')">Clone</button>
|
|
<button class="btn btn-danger btn-small" onclick="deleteMonster('${m.id}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function toggleMonster(id, enabled) {
|
|
try {
|
|
await api(`/api/admin/monster-types/${id}/enabled`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ enabled })
|
|
});
|
|
showToast(enabled ? 'Monster enabled' : 'Monster disabled');
|
|
loadMonsters();
|
|
} catch (e) {
|
|
showToast('Failed to toggle monster: ' + e.message, 'error');
|
|
loadMonsters();
|
|
}
|
|
}
|
|
|
|
async function deleteMonster(id) {
|
|
if (!confirm('Are you sure you want to delete this monster?')) return;
|
|
|
|
try {
|
|
await api(`/api/admin/monster-types/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
monsters = monsters.filter(m => m.id !== id);
|
|
showToast('Monster deleted');
|
|
renderMonsterTable();
|
|
loadMonsters(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to delete monster: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function editMonster(id) {
|
|
const monster = monsters.find(m => m.id === id);
|
|
if (!monster) return;
|
|
|
|
document.getElementById('monsterModalTitle').textContent = 'Edit Monster';
|
|
document.getElementById('monsterId').value = monster.id;
|
|
document.getElementById('monsterName').value = monster.name;
|
|
document.getElementById('monsterKey').value = monster.key;
|
|
document.getElementById('monsterMinLevel').value = monster.min_level;
|
|
document.getElementById('monsterMaxLevel').value = monster.max_level;
|
|
document.getElementById('monsterHp').value = monster.base_hp;
|
|
document.getElementById('monsterAtk').value = monster.base_atk;
|
|
document.getElementById('monsterDef').value = monster.base_def;
|
|
document.getElementById('monsterMp').value = monster.base_mp || 20;
|
|
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
|
|
document.getElementById('monsterXp').value = monster.base_xp;
|
|
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
|
|
document.getElementById('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
|
|
document.getElementById('monsterEnabled').checked = monster.enabled;
|
|
|
|
// Parse dialogues
|
|
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
|
|
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
|
|
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
|
|
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
|
|
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
|
|
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
|
|
|
|
// Set animation overrides
|
|
populateAnimationDropdowns();
|
|
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
|
|
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
|
|
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
|
|
|
|
// Update preview icon
|
|
document.getElementById('animPreviewIcon').src = `/mapgameimgs/monsters/${monster.key}100.png`;
|
|
document.getElementById('animPreviewIcon').onerror = function() { this.src = '/mapgameimgs/monsters/default100.png'; };
|
|
|
|
// Load monster skills
|
|
await loadMonsterSkills(monster.id);
|
|
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
}
|
|
|
|
function cloneMonster(id) {
|
|
const monster = monsters.find(m => m.id === id);
|
|
if (!monster) return;
|
|
|
|
// Generate unique key suffix
|
|
let suffix = '_copy';
|
|
let newKey = monster.key + suffix;
|
|
let counter = 1;
|
|
while (monsters.some(m => m.key === newKey)) {
|
|
newKey = monster.key + suffix + counter;
|
|
counter++;
|
|
}
|
|
|
|
document.getElementById('monsterModalTitle').textContent = 'Clone Monster';
|
|
document.getElementById('monsterId').value = ''; // Empty = create new
|
|
document.getElementById('monsterName').value = monster.name + ' (Copy)';
|
|
document.getElementById('monsterKey').value = newKey;
|
|
document.getElementById('monsterMinLevel').value = monster.min_level;
|
|
document.getElementById('monsterMaxLevel').value = monster.max_level;
|
|
document.getElementById('monsterHp').value = monster.base_hp;
|
|
document.getElementById('monsterAtk').value = monster.base_atk;
|
|
document.getElementById('monsterDef').value = monster.base_def;
|
|
document.getElementById('monsterMp').value = monster.base_mp || 20;
|
|
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
|
|
document.getElementById('monsterXp').value = monster.base_xp;
|
|
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
|
|
document.getElementById('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
|
|
document.getElementById('monsterEnabled').checked = false; // Disabled by default
|
|
|
|
// Parse and copy dialogues
|
|
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
|
|
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
|
|
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
|
|
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
|
|
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
|
|
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
|
|
|
|
// Copy animation settings
|
|
populateAnimationDropdowns();
|
|
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
|
|
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
|
|
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
|
|
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
|
|
|
|
// Clear skills (cloned monster needs to be saved first)
|
|
currentMonsterSkills = [];
|
|
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
|
|
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('addMonsterBtn').addEventListener('click', () => {
|
|
document.getElementById('monsterModalTitle').textContent = 'Add Monster';
|
|
document.getElementById('monsterForm').reset();
|
|
document.getElementById('monsterId').value = '';
|
|
document.getElementById('monsterEnabled').checked = true;
|
|
// Set default animations
|
|
populateAnimationDropdowns();
|
|
document.getElementById('monsterAttackAnim').value = 'attack';
|
|
document.getElementById('monsterDeathAnim').value = 'death';
|
|
document.getElementById('monsterIdleAnim').value = 'idle';
|
|
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
|
|
// Clear skills (new monster needs to be saved first)
|
|
currentMonsterSkills = [];
|
|
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
});
|
|
|
|
function closeMonsterModal() {
|
|
document.getElementById('monsterModal').classList.remove('active');
|
|
}
|
|
|
|
// Populate animation dropdowns from MONSTER_ANIMATIONS
|
|
function populateAnimationDropdowns() {
|
|
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
|
|
const animIds = Object.keys(animations);
|
|
|
|
const dropdowns = ['monsterAttackAnim', 'monsterDeathAnim', 'monsterIdleAnim', 'testAnimationSelect'];
|
|
|
|
dropdowns.forEach(dropdownId => {
|
|
const dropdown = document.getElementById(dropdownId);
|
|
if (!dropdown) return;
|
|
|
|
dropdown.innerHTML = animIds.map(id => {
|
|
const anim = animations[id];
|
|
return `<option value="${id}">${anim.name} - ${anim.description}</option>`;
|
|
}).join('');
|
|
});
|
|
}
|
|
|
|
// Populate a single animation dropdown (for OSM tags, etc.)
|
|
function populateAnimationDropdown(dropdownId) {
|
|
const dropdown = document.getElementById(dropdownId);
|
|
if (!dropdown) return;
|
|
|
|
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
|
|
const animIds = Object.keys(animations);
|
|
|
|
// Keep None option, add animation options
|
|
dropdown.innerHTML = '<option value="">None</option>' + animIds.map(id => {
|
|
const anim = animations[id];
|
|
return `<option value="${id}">${anim.name}</option>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Update artwork preview in OSM tag modal
|
|
function updateArtworkPreview() {
|
|
const artworkNum = parseInt(document.getElementById('osmTagArtwork').value) || 1;
|
|
const padNum = String(artworkNum).padStart(2, '0');
|
|
const basePath = '/mapgameimgs/cacheicons/cacheIcon100-';
|
|
|
|
const mainImg = document.getElementById('artworkPreview');
|
|
const shadowImg = document.getElementById('artworkPreviewShadow');
|
|
|
|
if (mainImg) {
|
|
mainImg.src = `${basePath}${padNum}.png`;
|
|
mainImg.onerror = function() { this.style.display = 'none'; };
|
|
mainImg.onload = function() { this.style.display = 'block'; };
|
|
}
|
|
|
|
if (shadowImg) {
|
|
shadowImg.src = `${basePath}${padNum}_shadow.png`;
|
|
shadowImg.onerror = function() { this.style.display = 'none'; };
|
|
shadowImg.onload = function() { this.style.display = 'block'; };
|
|
}
|
|
}
|
|
|
|
// Test animation preview
|
|
function testMonsterAnimation() {
|
|
const animId = document.getElementById('testAnimationSelect').value;
|
|
const previewIcon = document.getElementById('animPreviewIcon');
|
|
|
|
if (!previewIcon || !animId) return;
|
|
|
|
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[animId] : null;
|
|
if (!anim) {
|
|
showToast('Animation not found', 'error');
|
|
return;
|
|
}
|
|
|
|
// Reset animation
|
|
previewIcon.style.animation = 'none';
|
|
previewIcon.offsetHeight; // Force reflow
|
|
|
|
// Apply animation
|
|
const loopStr = anim.loop ? ' infinite' : '';
|
|
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
|
|
const easing = anim.easing || 'ease-out';
|
|
previewIcon.style.animation = `monster_${animId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
|
|
}
|
|
|
|
// Generate animation CSS for preview
|
|
function generateAdminAnimationCSS() {
|
|
if (typeof MONSTER_ANIMATIONS === 'undefined') return;
|
|
|
|
let css = '';
|
|
for (const [id, anim] of Object.entries(MONSTER_ANIMATIONS)) {
|
|
const loopStr = anim.loop ? ' infinite' : '';
|
|
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
|
|
const easing = anim.easing || 'ease-out';
|
|
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
|
|
}
|
|
const style = document.createElement('style');
|
|
style.id = 'monster-animations-css';
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Initialize animation CSS on page load
|
|
document.addEventListener('DOMContentLoaded', generateAdminAnimationCSS);
|
|
|
|
document.getElementById('monsterForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const id = document.getElementById('monsterId').value;
|
|
const dialogues = {
|
|
annoyed: document.getElementById('dialogueAnnoyed').value.split('\n').filter(l => l.trim()),
|
|
frustrated: document.getElementById('dialogueFrustrated').value.split('\n').filter(l => l.trim()),
|
|
desperate: document.getElementById('dialogueDesperate').value.split('\n').filter(l => l.trim()),
|
|
philosophical: document.getElementById('dialoguePhilosophical').value.split('\n').filter(l => l.trim()),
|
|
existential: document.getElementById('dialogueExistential').value.split('\n').filter(l => l.trim())
|
|
};
|
|
|
|
const data = {
|
|
name: document.getElementById('monsterName').value,
|
|
key: document.getElementById('monsterKey').value,
|
|
min_level: parseInt(document.getElementById('monsterMinLevel').value),
|
|
max_level: parseInt(document.getElementById('monsterMaxLevel').value),
|
|
base_hp: parseInt(document.getElementById('monsterHp').value),
|
|
base_atk: parseInt(document.getElementById('monsterAtk').value),
|
|
base_def: parseInt(document.getElementById('monsterDef').value),
|
|
base_mp: parseInt(document.getElementById('monsterMp').value),
|
|
base_xp: parseInt(document.getElementById('monsterXp').value),
|
|
spawn_weight: parseInt(document.getElementById('monsterWeight').value),
|
|
spawn_location: document.getElementById('monsterSpawnLocation').value,
|
|
levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 },
|
|
enabled: document.getElementById('monsterEnabled').checked,
|
|
attack_animation: document.getElementById('monsterAttackAnim').value,
|
|
death_animation: document.getElementById('monsterDeathAnim').value,
|
|
idle_animation: document.getElementById('monsterIdleAnim').value,
|
|
dialogues: JSON.stringify(dialogues)
|
|
};
|
|
|
|
try {
|
|
if (id) {
|
|
await api(`/api/admin/monster-types/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Update local array immediately (optimistic update)
|
|
const idx = monsters.findIndex(m => m.id === id);
|
|
if (idx !== -1) {
|
|
monsters[idx] = { ...monsters[idx], ...data };
|
|
}
|
|
showToast('Monster updated');
|
|
} else {
|
|
const result = await api('/api/admin/monster-types', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Add to local array immediately
|
|
if (result && result.id) {
|
|
monsters.push({ id: result.id, ...data });
|
|
}
|
|
showToast('Monster created');
|
|
}
|
|
renderMonsterTable();
|
|
closeMonsterModal();
|
|
loadMonsters(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to save monster: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= SKILLS DATABASE =============
|
|
async function loadSkillsAdmin() {
|
|
try {
|
|
const data = await api('/api/admin/skills');
|
|
allSkills = data.skills || [];
|
|
renderSkillTable();
|
|
renderUtilitySkillTable(); // Also render utility skills table
|
|
populateSkillSelect(); // Also update the monster skill dropdown
|
|
} catch (e) {
|
|
showToast('Failed to load skills: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderSkillTable() {
|
|
const tbody = document.getElementById('skillTableBody');
|
|
// Filter out utility skills - they're shown in the Utility Skills section
|
|
const combatSkills = allSkills.filter(s => s.type !== 'utility');
|
|
if (combatSkills.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="11">No combat skills found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = combatSkills.map(s => {
|
|
const statusEffect = s.status_effect ? JSON.parse(s.status_effect) : null;
|
|
return `
|
|
<tr>
|
|
<td><strong>${escapeHtml(s.name)}</strong></td>
|
|
<td><code>${escapeHtml(s.id)}</code></td>
|
|
<td><span class="skill-type-badge skill-type-${s.type}">${s.type}</span></td>
|
|
<td>${s.base_power}${s.hit_count > 1 ? ' ×' + s.hit_count : ''}</td>
|
|
<td>${s.accuracy}%</td>
|
|
<td>${s.mp_cost}</td>
|
|
<td>${s.target}</td>
|
|
<td>${s.player_usable ? '✓' : '✗'}</td>
|
|
<td>${s.monster_usable ? '✓' : '✗'}</td>
|
|
<td>
|
|
<label class="toggle">
|
|
<input type="checkbox" ${s.enabled ? 'checked' : ''}
|
|
onchange="toggleSkill('${s.id}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editSkill('${s.id}')">Edit</button>
|
|
<button class="btn btn-danger btn-small" onclick="deleteSkill('${s.id}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`}).join('');
|
|
}
|
|
|
|
function renderUtilitySkillTable() {
|
|
const utilitySkills = allSkills.filter(s => s.type === 'utility');
|
|
const tbody = document.getElementById('utilitySkillTableBody');
|
|
|
|
if (utilitySkills.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8">No utility skills found. Add a skill with type "Utility" to see it here.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const effectLabels = {
|
|
'hp_regen_multiplier': 'HP Regen',
|
|
'mp_regen_multiplier': 'MP Regen',
|
|
'atk_boost_flat': 'ATK +',
|
|
'atk_boost_percent': 'ATK %',
|
|
'def_boost_flat': 'DEF +',
|
|
'def_boost_percent': 'DEF %',
|
|
'xp_multiplier': 'XP'
|
|
};
|
|
|
|
tbody.innerHTML = utilitySkills.map(s => {
|
|
let config = { effectType: '-', effectValue: '-', durationHours: '-', cooldownHours: '-' };
|
|
if (s.status_effect) {
|
|
try {
|
|
config = JSON.parse(s.status_effect);
|
|
} catch {}
|
|
}
|
|
|
|
const valueDisplay = config.effectType?.includes('percent') || config.effectType?.includes('multiplier')
|
|
? config.effectValue + 'x'
|
|
: '+' + config.effectValue;
|
|
|
|
return `
|
|
<tr>
|
|
<td><strong>${escapeHtml(s.name)}</strong></td>
|
|
<td><code>${escapeHtml(s.id)}</code></td>
|
|
<td><span class="skill-type-badge skill-type-utility">${effectLabels[config.effectType] || config.effectType || '-'}</span></td>
|
|
<td>${valueDisplay}</td>
|
|
<td>${config.durationHours || '-'}h</td>
|
|
<td>${config.cooldownHours || '-'}h</td>
|
|
<td>
|
|
<label class="toggle">
|
|
<input type="checkbox" ${s.enabled ? 'checked' : ''}
|
|
onchange="toggleSkill('${s.id}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editSkill('${s.id}')">Edit</button>
|
|
<button class="btn btn-danger btn-small" onclick="deleteSkill('${s.id}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`}).join('');
|
|
}
|
|
|
|
function handleSkillTypeChange() {
|
|
const skillType = document.getElementById('skillType').value;
|
|
const utilitySection = document.getElementById('utilityConfigSection');
|
|
const statusEffectSection = document.querySelector('.status-effect-section:not(#utilityConfigSection)');
|
|
|
|
if (skillType === 'utility') {
|
|
utilitySection.style.display = 'block';
|
|
if (statusEffectSection) statusEffectSection.style.display = 'none';
|
|
// Auto-set defaults for utility skills
|
|
document.getElementById('skillPlayerUsable').checked = false;
|
|
document.getElementById('skillMonsterUsable').checked = false;
|
|
document.getElementById('skillTarget').value = 'self';
|
|
document.getElementById('skillMpCost').value = '0';
|
|
document.getElementById('skillBasePower').value = '0';
|
|
} else {
|
|
utilitySection.style.display = 'none';
|
|
if (statusEffectSection) statusEffectSection.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function toggleSkill(id, enabled) {
|
|
try {
|
|
await api(`/api/admin/skills/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ enabled })
|
|
});
|
|
showToast(enabled ? 'Skill enabled' : 'Skill disabled');
|
|
loadSkillsAdmin();
|
|
} catch (e) {
|
|
showToast('Failed to toggle skill: ' + e.message, 'error');
|
|
loadSkillsAdmin();
|
|
}
|
|
}
|
|
|
|
async function deleteSkill(id) {
|
|
if (!confirm('Are you sure you want to delete this skill? This will also remove it from all monsters.')) return;
|
|
|
|
try {
|
|
await api(`/api/admin/skills/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
allSkills = allSkills.filter(s => s.id !== id);
|
|
showToast('Skill deleted');
|
|
renderSkillTable();
|
|
loadSkillsAdmin(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to delete skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function editSkill(id) {
|
|
const skill = allSkills.find(s => s.id === id);
|
|
if (!skill) return;
|
|
|
|
document.getElementById('skillModalTitle').textContent = 'Edit Skill';
|
|
document.getElementById('skillEditId').value = skill.id;
|
|
document.getElementById('skillId').value = skill.id;
|
|
document.getElementById('skillId').disabled = true; // Can't change ID on edit
|
|
document.getElementById('skillName').value = skill.name;
|
|
document.getElementById('skillDescription').value = skill.description || '';
|
|
document.getElementById('skillType').value = skill.type;
|
|
document.getElementById('skillTarget').value = skill.target;
|
|
document.getElementById('skillTargetingMode').value = skill.targeting_mode || 'same_target';
|
|
document.getElementById('skillMpCost').value = skill.mp_cost;
|
|
document.getElementById('skillBasePower').value = skill.base_power;
|
|
document.getElementById('skillAccuracy').value = skill.accuracy;
|
|
document.getElementById('skillHitCount').value = skill.hit_count;
|
|
document.getElementById('skillPlayerUsable').checked = skill.player_usable;
|
|
document.getElementById('skillMonsterUsable').checked = skill.monster_usable;
|
|
document.getElementById('skillEnabled').checked = skill.enabled;
|
|
|
|
// Parse status effect based on skill type
|
|
if (skill.status_effect) {
|
|
try {
|
|
const effect = JSON.parse(skill.status_effect);
|
|
|
|
if (skill.type === 'utility') {
|
|
// Utility skill config
|
|
document.getElementById('utilityEffectType').value = effect.effectType || 'mp_regen_multiplier';
|
|
document.getElementById('utilityEffectValue').value = effect.effectValue || 2.0;
|
|
document.getElementById('utilityDurationHours').value = effect.durationHours || 1;
|
|
document.getElementById('utilityCooldownHours').value = effect.cooldownHours || 24;
|
|
// Reset combat status effect fields
|
|
document.getElementById('skillStatusType').value = '';
|
|
} else {
|
|
// Combat status effect
|
|
document.getElementById('skillStatusType').value = effect.type || '';
|
|
document.getElementById('skillStatusDamage').value = effect.damage || 5;
|
|
document.getElementById('skillStatusDuration').value = effect.duration || 3;
|
|
// Reset utility fields
|
|
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
|
|
document.getElementById('utilityEffectValue').value = 2.0;
|
|
document.getElementById('utilityDurationHours').value = 1;
|
|
document.getElementById('utilityCooldownHours').value = 24;
|
|
}
|
|
} catch {
|
|
document.getElementById('skillStatusType').value = '';
|
|
}
|
|
} else {
|
|
document.getElementById('skillStatusType').value = '';
|
|
document.getElementById('skillStatusDamage').value = 5;
|
|
document.getElementById('skillStatusDuration').value = 3;
|
|
// Reset utility fields to defaults
|
|
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
|
|
document.getElementById('utilityEffectValue').value = 2.0;
|
|
document.getElementById('utilityDurationHours').value = 1;
|
|
document.getElementById('utilityCooldownHours').value = 24;
|
|
}
|
|
|
|
// Toggle visibility of form sections based on skill type
|
|
handleSkillTypeChange();
|
|
|
|
// Load existing icon preview
|
|
if (skill.icon) {
|
|
updateSkillIconPreview(`/mapgameimgs/skills/${skill.icon}`);
|
|
} else {
|
|
resetSkillIconPreview();
|
|
}
|
|
|
|
document.getElementById('skillModal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('addSkillBtn').addEventListener('click', () => {
|
|
document.getElementById('skillModalTitle').textContent = 'Add Skill';
|
|
document.getElementById('skillForm').reset();
|
|
document.getElementById('skillEditId').value = '';
|
|
document.getElementById('skillId').disabled = false;
|
|
document.getElementById('skillPlayerUsable').checked = true;
|
|
document.getElementById('skillMonsterUsable').checked = true;
|
|
document.getElementById('skillEnabled').checked = true;
|
|
document.getElementById('skillTargetingMode').value = 'same_target';
|
|
// Reset to default (damage) type and toggle visibility
|
|
document.getElementById('skillType').value = 'damage';
|
|
handleSkillTypeChange();
|
|
resetSkillIconPreview();
|
|
document.getElementById('skillModal').classList.add('active');
|
|
});
|
|
|
|
document.getElementById('addUtilitySkillBtn').addEventListener('click', () => {
|
|
document.getElementById('skillModalTitle').textContent = 'Add Utility Skill';
|
|
document.getElementById('skillForm').reset();
|
|
document.getElementById('skillEditId').value = '';
|
|
document.getElementById('skillId').disabled = false;
|
|
// Utility skills default settings
|
|
document.getElementById('skillPlayerUsable').checked = false;
|
|
document.getElementById('skillMonsterUsable').checked = false;
|
|
document.getElementById('skillEnabled').checked = true;
|
|
document.getElementById('skillTarget').value = 'self';
|
|
// Set type to utility and toggle visibility
|
|
document.getElementById('skillType').value = 'utility';
|
|
handleSkillTypeChange();
|
|
resetSkillIconPreview();
|
|
// Set default utility values
|
|
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
|
|
document.getElementById('utilityEffectValue').value = '2.0';
|
|
document.getElementById('utilityDurationHours').value = '1';
|
|
document.getElementById('utilityCooldownHours').value = '24';
|
|
document.getElementById('skillModal').classList.add('active');
|
|
});
|
|
|
|
function closeSkillModal() {
|
|
document.getElementById('skillModal').classList.remove('active');
|
|
document.getElementById('skillId').disabled = false;
|
|
pendingSkillIcon = null;
|
|
}
|
|
|
|
// Skill icon helper functions
|
|
function updateSkillIconPreview(src) {
|
|
const preview = document.getElementById('skillIconPreview');
|
|
const removeBtn = document.getElementById('removeSkillIconBtn');
|
|
if (src) {
|
|
preview.innerHTML = `<img src="${src}" style="max-width: 100%; max-height: 100%; object-fit: contain;">`;
|
|
removeBtn.style.display = 'inline-block';
|
|
} else {
|
|
preview.innerHTML = '<span style="color: #666; font-size: 11px;">No icon</span>';
|
|
removeBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function resetSkillIconPreview() {
|
|
pendingSkillIcon = null;
|
|
updateSkillIconPreview(null);
|
|
}
|
|
|
|
async function uploadSkillIcon(skillId) {
|
|
if (!pendingSkillIcon) return;
|
|
const formData = new FormData();
|
|
formData.append('icon', pendingSkillIcon);
|
|
try {
|
|
const response = await fetch(`/api/admin/skills/${skillId}/icon`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
body: formData
|
|
});
|
|
if (!response.ok) throw new Error('Upload failed');
|
|
pendingSkillIcon = null;
|
|
} catch (err) {
|
|
console.error('Icon upload error:', err);
|
|
showToast('Failed to upload icon', 'error');
|
|
}
|
|
}
|
|
|
|
async function removeSkillIcon() {
|
|
const skillId = document.getElementById('skillEditId').value;
|
|
if (skillId) {
|
|
try {
|
|
await api(`/api/admin/skills/${skillId}/icon`, { method: 'DELETE' });
|
|
// Update local data
|
|
const skill = allSkills.find(s => s.id === skillId);
|
|
if (skill) skill.icon = null;
|
|
} catch (err) {
|
|
showToast('Failed to remove icon', 'error');
|
|
return;
|
|
}
|
|
}
|
|
pendingSkillIcon = null;
|
|
updateSkillIconPreview(null);
|
|
}
|
|
|
|
// File input change listener
|
|
document.getElementById('skillIconFile').addEventListener('change', function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
if (file.size > 500 * 1024) {
|
|
showToast('Icon file must be under 500KB', 'error');
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => updateSkillIconPreview(e.target.result);
|
|
reader.readAsDataURL(file);
|
|
pendingSkillIcon = file;
|
|
});
|
|
|
|
// Monster skill icon upload
|
|
async function uploadMonsterSkillIcon(monsterTypeId, skillId) {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
if (file.size > 500 * 1024) {
|
|
showToast('Icon must be under 500KB', 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('icon', file);
|
|
try {
|
|
const response = await fetch(`/api/admin/monster-skills/${monsterTypeId}/${skillId}/icon`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
body: formData
|
|
});
|
|
if (!response.ok) throw new Error('Upload failed');
|
|
const result = await response.json();
|
|
// Update local data
|
|
const ms = currentMonsterSkills.find(s => s.skill_id === skillId);
|
|
if (ms) ms.custom_icon = result.icon;
|
|
renderMonsterSkills();
|
|
showToast('Icon uploaded');
|
|
} catch (err) {
|
|
console.error('Monster skill icon upload error:', err);
|
|
showToast('Failed to upload icon', 'error');
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
// Class skill icon upload
|
|
async function uploadClassSkillIcon(classId, skillId) {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
if (file.size > 500 * 1024) {
|
|
showToast('Icon must be under 500KB', 'error');
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
formData.append('icon', file);
|
|
try {
|
|
const response = await fetch(`/api/admin/class-skills/${classId}/${skillId}/icon`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
body: formData
|
|
});
|
|
if (!response.ok) throw new Error('Upload failed');
|
|
const result = await response.json();
|
|
// Update local data
|
|
const cs = currentClassSkills.find(s => s.skill_id === skillId);
|
|
if (cs) cs.custom_icon = result.icon;
|
|
renderClassSkills();
|
|
showToast('Icon uploaded');
|
|
} catch (err) {
|
|
console.error('Class skill icon upload error:', err);
|
|
showToast('Failed to upload icon', 'error');
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
document.getElementById('skillForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const editId = document.getElementById('skillEditId').value;
|
|
const skillId = document.getElementById('skillId').value;
|
|
|
|
// Build status effect JSON based on skill type
|
|
let statusEffect = null;
|
|
const skillType = document.getElementById('skillType').value;
|
|
|
|
if (skillType === 'utility') {
|
|
// Utility skill - use utility config fields
|
|
statusEffect = JSON.stringify({
|
|
effectType: document.getElementById('utilityEffectType').value,
|
|
effectValue: parseFloat(document.getElementById('utilityEffectValue').value) || 2.0,
|
|
durationHours: parseFloat(document.getElementById('utilityDurationHours').value) || 1,
|
|
cooldownHours: parseFloat(document.getElementById('utilityCooldownHours').value) || 24
|
|
});
|
|
} else {
|
|
// Combat skill - use status effect fields
|
|
const statusType = document.getElementById('skillStatusType').value;
|
|
if (statusType) {
|
|
statusEffect = JSON.stringify({
|
|
type: statusType,
|
|
damage: parseInt(document.getElementById('skillStatusDamage').value) || 5,
|
|
duration: parseInt(document.getElementById('skillStatusDuration').value) || 3
|
|
});
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
id: skillId,
|
|
name: document.getElementById('skillName').value,
|
|
description: document.getElementById('skillDescription').value,
|
|
type: document.getElementById('skillType').value,
|
|
target: document.getElementById('skillTarget').value,
|
|
targeting_mode: document.getElementById('skillTargetingMode').value,
|
|
mp_cost: parseInt(document.getElementById('skillMpCost').value) || 0,
|
|
base_power: parseInt(document.getElementById('skillBasePower').value) || 100,
|
|
accuracy: parseInt(document.getElementById('skillAccuracy').value) || 95,
|
|
hit_count: parseInt(document.getElementById('skillHitCount').value) || 1,
|
|
status_effect: statusEffect,
|
|
player_usable: document.getElementById('skillPlayerUsable').checked,
|
|
monster_usable: document.getElementById('skillMonsterUsable').checked,
|
|
enabled: document.getElementById('skillEnabled').checked
|
|
};
|
|
|
|
try {
|
|
let skillId;
|
|
if (editId) {
|
|
await api(`/api/admin/skills/${editId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
skillId = editId;
|
|
// Update local array immediately (optimistic update)
|
|
const idx = allSkills.findIndex(s => s.id === editId);
|
|
if (idx !== -1) {
|
|
allSkills[idx] = { ...allSkills[idx], ...data };
|
|
}
|
|
showToast('Skill updated');
|
|
} else {
|
|
const result = await api('/api/admin/skills', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
skillId = result.id || data.id;
|
|
// Add to local array immediately
|
|
allSkills.push({ ...data, id: skillId });
|
|
showToast('Skill created');
|
|
}
|
|
|
|
// Upload icon if pending
|
|
if (pendingSkillIcon && skillId) {
|
|
await uploadSkillIcon(skillId);
|
|
}
|
|
|
|
renderSkillTable();
|
|
closeSkillModal();
|
|
loadSkillsAdmin(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to save skill: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= CLASSES =============
|
|
async function loadClasses() {
|
|
try {
|
|
const data = await api('/api/admin/classes');
|
|
allClasses = data || [];
|
|
renderClassTable();
|
|
} catch (e) {
|
|
showToast('Failed to load classes: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderClassTable() {
|
|
const tbody = document.getElementById('classTableBody');
|
|
if (allClasses.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7">No classes found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = allClasses.map(c => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(c.name)}</strong></td>
|
|
<td><code>${escapeHtml(c.id)}</code></td>
|
|
<td>
|
|
<small>HP:${c.base_hp} MP:${c.base_mp} ATK:${c.base_atk} DEF:${c.base_def}</small>
|
|
</td>
|
|
<td>
|
|
<small>+${c.hp_per_level}HP +${c.mp_per_level}MP +${c.atk_per_level}ATK +${c.def_per_level}DEF</small>
|
|
</td>
|
|
<td><span class="badge badge-level" id="class-skill-count-${c.id}">...</span></td>
|
|
<td>
|
|
<label class="toggle">
|
|
<input type="checkbox" ${c.enabled ? 'checked' : ''}
|
|
onchange="toggleClass('${c.id}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editClass('${c.id}')">Edit</button>
|
|
<button class="btn btn-danger btn-small" onclick="deleteClass('${c.id}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
// Load skill counts for each class
|
|
allClasses.forEach(async c => {
|
|
try {
|
|
const skills = await api(`/api/admin/class-skills/${c.id}`);
|
|
document.getElementById(`class-skill-count-${c.id}`).textContent = `${skills.length} skills`;
|
|
} catch (e) {
|
|
document.getElementById(`class-skill-count-${c.id}`).textContent = '?';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function toggleClass(id, enabled) {
|
|
try {
|
|
await api(`/api/admin/classes/${id}/toggle`, { method: 'PUT' });
|
|
showToast(enabled ? 'Class enabled' : 'Class disabled');
|
|
loadClasses();
|
|
} catch (e) {
|
|
showToast('Failed to toggle class: ' + e.message, 'error');
|
|
loadClasses();
|
|
}
|
|
}
|
|
|
|
async function deleteClass(id) {
|
|
if (!confirm('Are you sure you want to delete this class? This will also remove all skill assignments.')) return;
|
|
|
|
try {
|
|
await api(`/api/admin/classes/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
allClasses = allClasses.filter(c => c.id !== id);
|
|
showToast('Class deleted');
|
|
renderClassTable();
|
|
loadClasses(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to delete class: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function editClass(id) {
|
|
const classData = allClasses.find(c => c.id === id);
|
|
if (!classData) return;
|
|
|
|
document.getElementById('classModalTitle').textContent = 'Edit Class';
|
|
document.getElementById('classEditId').value = classData.id;
|
|
document.getElementById('classId').value = classData.id;
|
|
document.getElementById('classId').disabled = true;
|
|
document.getElementById('className').value = classData.name;
|
|
document.getElementById('classDescription').value = classData.description || '';
|
|
document.getElementById('classBaseHp').value = classData.base_hp;
|
|
document.getElementById('classBaseMp').value = classData.base_mp;
|
|
document.getElementById('classBaseAtk').value = classData.base_atk;
|
|
document.getElementById('classBaseDef').value = classData.base_def;
|
|
document.getElementById('classBaseAccuracy').value = classData.base_accuracy;
|
|
document.getElementById('classBaseDodge').value = classData.base_dodge;
|
|
document.getElementById('classHpPerLevel').value = classData.hp_per_level;
|
|
document.getElementById('classMpPerLevel').value = classData.mp_per_level;
|
|
document.getElementById('classAtkPerLevel').value = classData.atk_per_level;
|
|
document.getElementById('classDefPerLevel').value = classData.def_per_level;
|
|
document.getElementById('classEnabled').checked = classData.enabled;
|
|
|
|
// Populate skill dropdown for adding
|
|
populateClassSkillSelect();
|
|
|
|
// Load class skills
|
|
await loadClassSkills(classData.id);
|
|
|
|
document.getElementById('classModal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('addClassBtn').addEventListener('click', () => {
|
|
document.getElementById('classModalTitle').textContent = 'Add Class';
|
|
document.getElementById('classForm').reset();
|
|
document.getElementById('classEditId').value = '';
|
|
document.getElementById('classId').disabled = false;
|
|
document.getElementById('classEnabled').checked = false;
|
|
currentClassSkills = [];
|
|
document.getElementById('classSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save class first, then edit to add skills.</p>';
|
|
populateClassSkillSelect();
|
|
document.getElementById('classModal').classList.add('active');
|
|
});
|
|
|
|
function closeClassModal() {
|
|
document.getElementById('classModal').classList.remove('active');
|
|
document.getElementById('classId').disabled = false;
|
|
}
|
|
|
|
document.getElementById('classForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const editId = document.getElementById('classEditId').value;
|
|
const classId = document.getElementById('classId').value;
|
|
|
|
const data = {
|
|
id: classId,
|
|
name: document.getElementById('className').value,
|
|
description: document.getElementById('classDescription').value,
|
|
base_hp: parseInt(document.getElementById('classBaseHp').value) || 100,
|
|
base_mp: parseInt(document.getElementById('classBaseMp').value) || 50,
|
|
base_atk: parseInt(document.getElementById('classBaseAtk').value) || 12,
|
|
base_def: parseInt(document.getElementById('classBaseDef').value) || 8,
|
|
base_accuracy: parseInt(document.getElementById('classBaseAccuracy').value) || 90,
|
|
base_dodge: parseInt(document.getElementById('classBaseDodge').value) || 10,
|
|
hp_per_level: parseInt(document.getElementById('classHpPerLevel').value) || 10,
|
|
mp_per_level: parseInt(document.getElementById('classMpPerLevel').value) || 5,
|
|
atk_per_level: parseInt(document.getElementById('classAtkPerLevel').value) || 2,
|
|
def_per_level: parseInt(document.getElementById('classDefPerLevel').value) || 1,
|
|
enabled: document.getElementById('classEnabled').checked
|
|
};
|
|
|
|
try {
|
|
if (editId) {
|
|
await api(`/api/admin/classes/${editId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Update local array immediately (optimistic update)
|
|
const idx = allClasses.findIndex(c => c.id === editId);
|
|
if (idx !== -1) {
|
|
allClasses[idx] = { ...allClasses[idx], ...data };
|
|
}
|
|
showToast('Class updated');
|
|
} else {
|
|
await api('/api/admin/classes', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Add to local array immediately
|
|
allClasses.push({ ...data });
|
|
showToast('Class created');
|
|
}
|
|
renderClassTable();
|
|
closeClassModal();
|
|
loadClasses(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to save class: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= CLASS SKILLS =============
|
|
function populateClassSkillSelect() {
|
|
const select = document.getElementById('addClassSkillSelect');
|
|
select.innerHTML = '<option value="">-- Select a skill --</option>';
|
|
// Filter to player-usable skills
|
|
allSkills.filter(s => s.player_usable).forEach(skill => {
|
|
const opt = document.createElement('option');
|
|
opt.value = skill.id;
|
|
opt.textContent = `${skill.name} (${skill.type})`;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function loadClassSkills(classId) {
|
|
if (!classId) {
|
|
currentClassSkills = [];
|
|
renderClassSkills();
|
|
return;
|
|
}
|
|
try {
|
|
currentClassSkills = await api(`/api/admin/class-skills/${classId}`);
|
|
renderClassSkills();
|
|
} catch (e) {
|
|
console.error('Failed to load class skills:', e);
|
|
currentClassSkills = [];
|
|
renderClassSkills();
|
|
}
|
|
}
|
|
|
|
function renderClassSkills() {
|
|
const container = document.getElementById('classSkillsList');
|
|
if (currentClassSkills.length === 0) {
|
|
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Add skills below.</p>';
|
|
return;
|
|
}
|
|
|
|
const classId = document.getElementById('classEditId').value;
|
|
|
|
// Group by unlock level
|
|
const byLevel = {};
|
|
currentClassSkills.forEach(cs => {
|
|
const lvl = cs.unlock_level || 1;
|
|
if (!byLevel[lvl]) byLevel[lvl] = [];
|
|
byLevel[lvl].push(cs);
|
|
});
|
|
|
|
let html = '';
|
|
Object.keys(byLevel).sort((a, b) => a - b).forEach(level => {
|
|
const skills = byLevel[level];
|
|
html += `<div style="margin-bottom: 10px; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px;">`;
|
|
html += `<div style="font-size: 11px; color: #4CAF50; margin-bottom: 5px;">Level ${level}</div>`;
|
|
skills.forEach(cs => {
|
|
const displayName = cs.custom_name || cs.base_name || cs.skill_id;
|
|
const choiceLabel = cs.choice_group ? `<span style="color: #FF9800; font-size: 10px;">Choice ${cs.choice_group}</span>` : '<span style="color: #4CAF50; font-size: 10px;">Auto</span>';
|
|
const hasIcon = !!cs.custom_icon;
|
|
const iconSrc = hasIcon ? `/mapgameimgs/skills/${cs.custom_icon}` : '';
|
|
html += `
|
|
<div class="monster-skill-item" data-id="${cs.id}">
|
|
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
|
|
onclick="uploadClassSkillIcon('${classId}', '${cs.skill_id}')"
|
|
title="Click to upload custom icon">
|
|
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
|
|
</button>
|
|
<div class="skill-name-section">
|
|
<input type="text" class="skill-custom-name" value="${escapeHtml(cs.custom_name || '')}"
|
|
placeholder="${escapeHtml(cs.base_name || cs.skill_id)}"
|
|
onchange="updateClassSkill(${cs.id}, 'custom_name', this.value)"
|
|
title="Custom name for this class">
|
|
<span class="skill-base-name">${escapeHtml(cs.base_name || cs.skill_id)} ${choiceLabel}</span>
|
|
</div>
|
|
<div>
|
|
<label>Lvl</label>
|
|
<input type="number" class="skill-min-level" value="${cs.unlock_level}" min="1"
|
|
onchange="updateClassSkill(${cs.id}, 'unlock_level', this.value)">
|
|
</div>
|
|
<div>
|
|
<label>Grp</label>
|
|
<input type="number" class="skill-weight" value="${cs.choice_group || ''}" min="1"
|
|
onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)"
|
|
placeholder="-" title="Choice group (empty = auto-learn)">
|
|
</div>
|
|
<button type="button" class="btn btn-danger btn-small" onclick="removeClassSkill(${cs.id})">✕</button>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
async function addClassSkill() {
|
|
const classId = document.getElementById('classEditId').value;
|
|
const skillId = document.getElementById('addClassSkillSelect').value;
|
|
const unlockLevel = parseInt(document.getElementById('addClassSkillLevel').value) || 1;
|
|
const choiceGroupVal = document.getElementById('addClassSkillChoiceGroup').value;
|
|
const choiceGroup = choiceGroupVal ? parseInt(choiceGroupVal) : null;
|
|
const customName = document.getElementById('addClassSkillName').value.trim();
|
|
|
|
if (!skillId) {
|
|
showToast('Please select a skill', 'error');
|
|
return;
|
|
}
|
|
if (!classId) {
|
|
showToast('Please save the class first before adding skills', 'error');
|
|
return;
|
|
}
|
|
|
|
// Check if skill already assigned
|
|
if (currentClassSkills.some(cs => cs.skill_id === skillId)) {
|
|
showToast('This skill is already assigned to this class', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api('/api/admin/class-skills', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
class_id: classId,
|
|
skill_id: skillId,
|
|
unlock_level: unlockLevel,
|
|
choice_group: choiceGroup,
|
|
custom_name: customName || null
|
|
})
|
|
});
|
|
// Add to local array immediately (optimistic update)
|
|
const skill = allSkills.find(s => s.id === skillId);
|
|
currentClassSkills.push({
|
|
id: result?.id || Date.now(), // Use response ID or temp ID
|
|
class_id: classId,
|
|
skill_id: skillId,
|
|
skill_name: skill?.name || skillId,
|
|
unlock_level: unlockLevel,
|
|
choice_group: choiceGroup,
|
|
custom_name: customName || null
|
|
});
|
|
showToast('Skill added');
|
|
renderClassSkills();
|
|
loadClassSkills(classId); // Background refresh for consistency
|
|
document.getElementById('addClassSkillSelect').value = '';
|
|
document.getElementById('addClassSkillName').value = '';
|
|
document.getElementById('addClassSkillLevel').value = '1';
|
|
document.getElementById('addClassSkillChoiceGroup').value = '';
|
|
} catch (e) {
|
|
showToast('Failed to add skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateClassSkill(id, field, value) {
|
|
try {
|
|
const data = {};
|
|
if (field === 'choice_group') {
|
|
data[field] = value === '' || value === null ? null : parseInt(value);
|
|
} else if (field === 'unlock_level') {
|
|
data[field] = parseInt(value) || 1;
|
|
} else {
|
|
data[field] = value || null;
|
|
}
|
|
await api(`/api/admin/class-skills/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Update local state immediately
|
|
const cs = currentClassSkills.find(s => s.id === id);
|
|
if (cs) cs[field] = data[field];
|
|
} catch (e) {
|
|
showToast('Failed to update skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeClassSkill(id) {
|
|
try {
|
|
await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
currentClassSkills = currentClassSkills.filter(cs => cs.id !== id);
|
|
showToast('Skill removed');
|
|
renderClassSkills();
|
|
const classId = document.getElementById('classEditId').value;
|
|
loadClassSkills(classId); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to remove skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============= USERS =============
|
|
async function loadUsers() {
|
|
try {
|
|
const data = await api('/api/admin/users');
|
|
users = data.users || [];
|
|
renderUserTable();
|
|
} catch (e) {
|
|
showToast('Failed to load users: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderUserTable() {
|
|
const tbody = document.getElementById('userTableBody');
|
|
if (users.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7">No users found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(u.username)}</strong></td>
|
|
<td>${u.character_name ? escapeHtml(u.character_name) : '<em>No character</em>'}</td>
|
|
<td>${u.race || '-'} / ${u.class || '-'}</td>
|
|
<td><span class="badge badge-level">Lv ${u.level || 1}</span></td>
|
|
<td>
|
|
<small>HP: ${u.hp || 0}/${u.max_hp || 0} | MP: ${u.mp || 0}/${u.max_mp || 0}</small><br>
|
|
<small>ATK: ${u.atk || 0} | DEF: ${u.def || 0} | XP: ${u.xp || 0}</small>
|
|
</td>
|
|
<td>
|
|
<span class="badge ${u.is_admin ? 'badge-admin' : 'badge-user'}">
|
|
${u.is_admin ? 'Admin' : 'User'}
|
|
</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editUser(${u.id})">Edit</button>
|
|
<button class="btn btn-secondary btn-small" onclick="toggleAdmin(${u.id}, ${!u.is_admin})">
|
|
${u.is_admin ? 'Revoke Admin' : 'Grant Admin'}
|
|
</button>
|
|
<button class="btn btn-danger btn-small" onclick="deleteUser(${u.id}, '${escapeHtml(u.username).replace(/'/g, "\\'")}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function deleteUser(id, username) {
|
|
if (!confirm(`Are you sure you want to DELETE user "${username}"?\n\nThis will permanently remove:\n- User account\n- Character data\n- Monsters\n- Buffs\n- All progress\n\nThis action cannot be undone!`)) return;
|
|
|
|
// Double confirm for safety
|
|
if (!confirm(`FINAL WARNING: Delete user "${username}" permanently?`)) return;
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
|
// Remove from local array immediately
|
|
users = users.filter(u => u.id !== id);
|
|
showToast(`User "${username}" deleted`);
|
|
renderUserTable();
|
|
} catch (e) {
|
|
showToast('Failed to delete user: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function toggleAdmin(id, isAdmin) {
|
|
const action = isAdmin ? 'grant admin to' : 'revoke admin from';
|
|
if (!confirm(`Are you sure you want to ${action} this user?`)) return;
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}/admin`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ is_admin: isAdmin })
|
|
});
|
|
// Update local array immediately (optimistic update)
|
|
const idx = users.findIndex(u => u.id == id);
|
|
if (idx !== -1) {
|
|
users[idx].is_admin = isAdmin;
|
|
}
|
|
showToast(isAdmin ? 'Admin granted' : 'Admin revoked');
|
|
renderUserTable();
|
|
loadUsers(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to update admin status: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function editUser(id) {
|
|
const user = users.find(u => u.id === id);
|
|
if (!user) return;
|
|
|
|
document.getElementById('userId').value = user.id;
|
|
document.getElementById('userUsername').value = user.username;
|
|
document.getElementById('userCharacterName').value = user.character_name || '';
|
|
document.getElementById('userLevel').value = user.level || 1;
|
|
document.getElementById('userXp').value = user.xp || 0;
|
|
document.getElementById('userHp').value = user.hp || 0;
|
|
document.getElementById('userMaxHp').value = user.max_hp || 0;
|
|
document.getElementById('userMp').value = user.mp || 0;
|
|
document.getElementById('userMaxMp').value = user.max_mp || 0;
|
|
document.getElementById('userAtk').value = user.atk || 0;
|
|
document.getElementById('userDef').value = user.def || 0;
|
|
|
|
document.getElementById('userModal').classList.add('active');
|
|
}
|
|
|
|
function closeUserModal() {
|
|
document.getElementById('userModal').classList.remove('active');
|
|
}
|
|
|
|
async function resetUserProgress() {
|
|
const id = document.getElementById('userId').value;
|
|
if (!confirm('Are you sure you want to reset this user\'s RPG progress? This cannot be undone.')) return;
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}/reset`, { method: 'DELETE' });
|
|
showToast('User progress reset');
|
|
closeUserModal();
|
|
loadUsers();
|
|
} catch (e) {
|
|
showToast('Failed to reset progress: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function resetUserHomeBase() {
|
|
const id = document.getElementById('userId').value;
|
|
if (!confirm('Are you sure you want to reset this user\'s home base? They will need to set a new one.')) return;
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}/home-base`, { method: 'DELETE' });
|
|
showToast('Home base reset');
|
|
closeUserModal();
|
|
loadUsers();
|
|
} catch (e) {
|
|
showToast('Failed to reset home base: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('userForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const id = document.getElementById('userId').value;
|
|
const data = {
|
|
character_name: document.getElementById('userCharacterName').value,
|
|
level: parseInt(document.getElementById('userLevel').value),
|
|
xp: parseInt(document.getElementById('userXp').value),
|
|
hp: parseInt(document.getElementById('userHp').value),
|
|
max_hp: parseInt(document.getElementById('userMaxHp').value),
|
|
mp: parseInt(document.getElementById('userMp').value),
|
|
max_mp: parseInt(document.getElementById('userMaxMp').value),
|
|
atk: parseInt(document.getElementById('userAtk').value),
|
|
def: parseInt(document.getElementById('userDef').value)
|
|
};
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
// Update local array immediately (optimistic update)
|
|
const idx = users.findIndex(u => u.id == id);
|
|
if (idx !== -1) {
|
|
users[idx] = { ...users[idx], ...data };
|
|
}
|
|
showToast('User updated');
|
|
renderUserTable();
|
|
closeUserModal();
|
|
loadUsers(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to update user: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= SETTINGS =============
|
|
async function loadSettings() {
|
|
try {
|
|
const data = await api('/api/admin/settings');
|
|
settings = data.settings || {};
|
|
|
|
// Populate form (convert interval from ms to seconds for display)
|
|
const intervalMs = settings.monsterSpawnInterval || 20000;
|
|
document.getElementById('setting-monsterSpawnInterval').value = Math.round(intervalMs / 1000);
|
|
document.getElementById('setting-monsterSpawnChance').value = settings.monsterSpawnChance || 50;
|
|
document.getElementById('setting-monsterSpawnDistance').value = settings.monsterSpawnDistance || 10;
|
|
document.getElementById('setting-maxMonstersPerPlayer').value = settings.maxMonstersPerPlayer || 10;
|
|
document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0;
|
|
document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false;
|
|
document.getElementById('setting-mpRegenDistance').value = settings.mpRegenDistance || 5;
|
|
document.getElementById('setting-mpRegenAmount').value = settings.mpRegenAmount || 1;
|
|
// HP Regen settings (convert interval from ms to seconds)
|
|
const hpIntervalMs = settings.hpRegenInterval || 10000;
|
|
document.getElementById('setting-hpRegenInterval').value = Math.round(hpIntervalMs / 1000);
|
|
document.getElementById('setting-hpRegenPercent').value = settings.hpRegenPercent || 1;
|
|
// Home base settings
|
|
document.getElementById('setting-homeHpMultiplier').value = settings.homeHpMultiplier || 3;
|
|
document.getElementById('setting-homeRegenPercent').value = settings.homeRegenPercent || 5;
|
|
document.getElementById('setting-homeBaseRadius').value = settings.homeBaseRadius || 20;
|
|
// Session settings (convert inactivity timeout from ms to minutes)
|
|
const inactivityMs = settings.inactivityTimeout || 600000;
|
|
document.getElementById('setting-inactivityTimeout').value = Math.round(inactivityMs / 60000);
|
|
const warningMs = settings.inactivityWarningTime || 60000;
|
|
document.getElementById('setting-inactivityWarningTime').value = Math.round(warningMs / 1000);
|
|
// Combat UI settings
|
|
document.getElementById('setting-combatIconScale').value = settings.combatIconScale || 1.0;
|
|
} catch (e) {
|
|
showToast('Failed to load settings: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
|
|
// Convert interval from seconds to ms for storage
|
|
const intervalSeconds = parseInt(document.getElementById('setting-monsterSpawnInterval').value) || 20;
|
|
const hpIntervalSeconds = parseInt(document.getElementById('setting-hpRegenInterval').value) || 10;
|
|
// Convert inactivity timeout from minutes to ms, warning from seconds to ms
|
|
const inactivityMinutes = parseInt(document.getElementById('setting-inactivityTimeout').value) || 10;
|
|
const warningSeconds = parseInt(document.getElementById('setting-inactivityWarningTime').value) || 60;
|
|
const newSettings = {
|
|
monsterSpawnInterval: intervalSeconds * 1000,
|
|
monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50,
|
|
monsterSpawnDistance: parseInt(document.getElementById('setting-monsterSpawnDistance').value) || 10,
|
|
maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10,
|
|
xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0,
|
|
combatEnabled: document.getElementById('setting-combatEnabled').checked,
|
|
mpRegenDistance: parseInt(document.getElementById('setting-mpRegenDistance').value) || 5,
|
|
mpRegenAmount: parseInt(document.getElementById('setting-mpRegenAmount').value) || 1,
|
|
hpRegenInterval: hpIntervalSeconds * 1000,
|
|
hpRegenPercent: parseFloat(document.getElementById('setting-hpRegenPercent').value) || 1,
|
|
homeHpMultiplier: parseFloat(document.getElementById('setting-homeHpMultiplier').value) || 3,
|
|
homeRegenPercent: parseFloat(document.getElementById('setting-homeRegenPercent').value) || 5,
|
|
homeBaseRadius: parseInt(document.getElementById('setting-homeBaseRadius').value) || 20,
|
|
inactivityTimeout: inactivityMinutes * 60000,
|
|
inactivityWarningTime: warningSeconds * 1000,
|
|
combatIconScale: parseFloat(document.getElementById('setting-combatIconScale').value) || 1.0
|
|
};
|
|
|
|
try {
|
|
await api('/api/admin/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(newSettings)
|
|
});
|
|
// Update local settings immediately
|
|
settings = { ...settings, ...newSettings };
|
|
showToast('Settings saved');
|
|
loadSettings(); // Background refresh for consistency
|
|
} catch (e) {
|
|
showToast('Failed to save settings: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= OSM TAGS =============
|
|
let osmTags = [];
|
|
let osmTagSettings = { basePrefixChance: 25, doublePrefixChance: 10 };
|
|
|
|
async function loadOsmTags() {
|
|
try {
|
|
const response = await api('/api/admin/osm-tags');
|
|
osmTags = response.osmTags || [];
|
|
renderOsmTagTable();
|
|
} catch (e) {
|
|
console.error('Failed to load OSM tags:', e);
|
|
showToast('Failed to load OSM tags', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadOsmTagSettings() {
|
|
try {
|
|
const response = await api('/api/admin/osm-tag-settings');
|
|
osmTagSettings = response;
|
|
document.getElementById('osm-basePrefixChance').value = osmTagSettings.basePrefixChance || 25;
|
|
document.getElementById('osm-doublePrefixChance').value = osmTagSettings.doublePrefixChance || 10;
|
|
} catch (e) {
|
|
console.error('Failed to load OSM tag settings:', e);
|
|
}
|
|
}
|
|
|
|
function renderOsmTagTable() {
|
|
const tbody = document.getElementById('osmTagTableBody');
|
|
if (osmTags.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#888;">No OSM tags configured</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = osmTags.map(tag => {
|
|
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
|
|
const prefixCount = prefixes.length;
|
|
const prefixPreview = prefixes.slice(0, 2).join(', ') + (prefixes.length > 2 ? '...' : '');
|
|
const artworkNum = String(tag.artwork || 1).padStart(2, '0');
|
|
return `
|
|
<tr>
|
|
<td><strong>${escapeHtml(tag.id)}</strong></td>
|
|
<td>${artworkNum}${tag.animation ? ` <small style="color:#888">(${tag.animation})</small>` : ''}</td>
|
|
<td title="${escapeHtml(prefixes.join(', '))}">
|
|
${prefixCount > 0 ? `<span style="color:#4CAF50">${prefixCount} prefix${prefixCount > 1 ? 'es' : ''}</span>` : '<span style="color:#888">None</span>'}
|
|
${prefixPreview ? `<br><small style="color:#666">${escapeHtml(prefixPreview)}</small>` : ''}
|
|
</td>
|
|
<td>${tag.visibility_distance}m</td>
|
|
<td>${tag.spawn_radius}m</td>
|
|
<td>
|
|
<span class="status ${tag.enabled ? 'enabled' : 'disabled'}">
|
|
${tag.enabled ? 'Yes' : 'No'}
|
|
</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-small" onclick="editOsmTag('${escapeHtml(tag.id)}')">Edit</button>
|
|
<button class="btn btn-small btn-danger" onclick="deleteOsmTag('${escapeHtml(tag.id)}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function openOsmTagModal(tag = null) {
|
|
const modal = document.getElementById('osmTagModal');
|
|
const title = document.getElementById('osmTagModalTitle');
|
|
const form = document.getElementById('osmTagForm');
|
|
const idInput = document.getElementById('osmTagIdInput');
|
|
|
|
form.reset();
|
|
|
|
// Populate animation dropdowns from MONSTER_ANIMATIONS if available
|
|
populateAnimationDropdown('osmTagAnimation');
|
|
populateAnimationDropdown('osmTagAnimationShadow');
|
|
|
|
if (tag) {
|
|
title.textContent = 'Edit OSM Tag';
|
|
document.getElementById('osmTagIdField').value = tag.id;
|
|
idInput.value = tag.id;
|
|
idInput.readOnly = true;
|
|
document.getElementById('osmTagArtwork').value = tag.artwork || 1;
|
|
document.getElementById('osmTagAnimation').value = tag.animation || '';
|
|
document.getElementById('osmTagAnimationShadow').value = tag.animation_shadow || '';
|
|
document.getElementById('osmTagVisibility').value = tag.visibility_distance || 400;
|
|
document.getElementById('osmTagSpawnRadius').value = tag.spawn_radius || 400;
|
|
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
|
|
document.getElementById('osmTagPrefixes').value = prefixes.join('\n');
|
|
} else {
|
|
title.textContent = 'Add OSM Tag';
|
|
document.getElementById('osmTagIdField').value = '';
|
|
idInput.readOnly = false;
|
|
document.getElementById('osmTagArtwork').value = 1;
|
|
document.getElementById('osmTagAnimation').value = '';
|
|
document.getElementById('osmTagAnimationShadow').value = '';
|
|
document.getElementById('osmTagVisibility').value = 400;
|
|
document.getElementById('osmTagSpawnRadius').value = 400;
|
|
document.getElementById('osmTagPrefixes').value = '';
|
|
}
|
|
|
|
// Update artwork preview
|
|
updateArtworkPreview();
|
|
|
|
modal.classList.add('active');
|
|
}
|
|
|
|
function closeOsmTagModal() {
|
|
document.getElementById('osmTagModal').classList.remove('active');
|
|
}
|
|
|
|
function editOsmTag(id) {
|
|
const tag = osmTags.find(t => t.id === id);
|
|
if (tag) {
|
|
openOsmTagModal(tag);
|
|
}
|
|
}
|
|
|
|
async function deleteOsmTag(id) {
|
|
if (!confirm(`Are you sure you want to delete the OSM tag "${id}"?`)) return;
|
|
|
|
try {
|
|
await api(`/api/admin/osm-tags/${id}`, { method: 'DELETE' });
|
|
osmTags = osmTags.filter(t => t.id !== id);
|
|
renderOsmTagTable();
|
|
showToast('OSM tag deleted');
|
|
} catch (e) {
|
|
showToast('Failed to delete OSM tag: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('addOsmTagBtn').addEventListener('click', () => {
|
|
openOsmTagModal(null);
|
|
});
|
|
|
|
document.getElementById('osmTagForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const existingId = document.getElementById('osmTagIdField').value;
|
|
const newId = document.getElementById('osmTagIdInput').value.trim().toLowerCase();
|
|
const prefixesText = document.getElementById('osmTagPrefixes').value;
|
|
const prefixes = prefixesText.split('\n').map(p => p.trim()).filter(p => p.length > 0);
|
|
|
|
const data = {
|
|
id: newId,
|
|
artwork: parseInt(document.getElementById('osmTagArtwork').value) || 1,
|
|
animation: document.getElementById('osmTagAnimation').value || null,
|
|
animation_shadow: document.getElementById('osmTagAnimationShadow').value || null,
|
|
visibility_distance: parseInt(document.getElementById('osmTagVisibility').value) || 400,
|
|
spawn_radius: parseInt(document.getElementById('osmTagSpawnRadius').value) || 400,
|
|
prefixes: prefixes
|
|
};
|
|
|
|
try {
|
|
if (existingId) {
|
|
await api(`/api/admin/osm-tags/${existingId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('OSM tag updated');
|
|
} else {
|
|
await api('/api/admin/osm-tags', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('OSM tag created');
|
|
}
|
|
closeOsmTagModal();
|
|
loadOsmTags();
|
|
} catch (e) {
|
|
showToast('Failed to save OSM tag: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
document.getElementById('saveOsmSettingsBtn').addEventListener('click', async () => {
|
|
const settings = {
|
|
basePrefixChance: parseInt(document.getElementById('osm-basePrefixChance').value) || 25,
|
|
doublePrefixChance: parseInt(document.getElementById('osm-doublePrefixChance').value) || 10
|
|
};
|
|
|
|
try {
|
|
await api('/api/admin/osm-tag-settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(settings)
|
|
});
|
|
osmTagSettings = settings;
|
|
showToast('Prefix settings saved');
|
|
} catch (e) {
|
|
showToast('Failed to save prefix settings: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= UTILITIES =============
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Initialize
|
|
checkAuth();
|
|
loadOsmTags();
|
|
loadOsmTagSettings();
|
|
</script>
|
|
</body>
|
|
</html>
|