Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
847
public/css/style.css
Normal file
847
public/css/style.css
Normal file
@@ -0,0 +1,847 @@
|
||||
/* Luanti/Minecraft-Inspired Blocky Design */
|
||||
/* Using only local resources - no external fonts or CDNs */
|
||||
|
||||
/* Modern CSS Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* CSS Variables for blocky Luanti theme */
|
||||
:root {
|
||||
/* Minecraft/Luanti color palette */
|
||||
--stone-color: #7F7F7F;
|
||||
--cobblestone-color: #5A5A5A;
|
||||
--dirt-color: #8B5A2B;
|
||||
--grass-color: #79C05A;
|
||||
--grass-dark: #5A8F40;
|
||||
--wood-color: #C4965C;
|
||||
--wood-dark: #8B6A3C;
|
||||
--diamond-color: #64FFDA;
|
||||
--emerald-color: #00C851;
|
||||
--redstone-color: #FF0000;
|
||||
--lapis-color: #1976D2;
|
||||
--iron-color: #C0C0C0;
|
||||
--gold-color: #FFD700;
|
||||
--coal-color: #2C2C2C;
|
||||
--water-color: #4FC3F7;
|
||||
|
||||
/* Theme colors using Luanti palette */
|
||||
--primary-color: var(--lapis-color);
|
||||
--primary-hover: #1565C0;
|
||||
--success-color: var(--emerald-color);
|
||||
--success-hover: #00A142;
|
||||
--danger-color: var(--redstone-color);
|
||||
--danger-hover: #CC0000;
|
||||
--warning-color: var(--gold-color);
|
||||
--warning-hover: #E6C200;
|
||||
--secondary-color: var(--stone-color);
|
||||
--secondary-hover: var(--cobblestone-color);
|
||||
|
||||
/* Blocky backgrounds */
|
||||
--bg-primary: #F5F5F5;
|
||||
--bg-secondary: #E8E8E8;
|
||||
--bg-accent: #DCDCDC;
|
||||
--bg-dark: var(--coal-color);
|
||||
--text-primary: #2C2C2C;
|
||||
--text-secondary: #5A5A5A;
|
||||
--text-light: #F5F5F5;
|
||||
--border-color: #A0A0A0;
|
||||
--border-dark: #5A5A5A;
|
||||
|
||||
/* Blocky shadows - more pronounced */
|
||||
--shadow-block: 4px 4px 0px rgba(0, 0, 0, 0.3);
|
||||
--shadow-block-hover: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
--shadow-inset: inset 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Sharp, blocky borders */
|
||||
--border-width: 3px;
|
||||
--radius-none: 0;
|
||||
--radius-small: 2px;
|
||||
}
|
||||
|
||||
/* Base styles with blocky font stack */
|
||||
body {
|
||||
font-family: 'Courier New', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: bold;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
|
||||
/* Create a subtle texture pattern */
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.02) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.02) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
|
||||
}
|
||||
|
||||
/* Container and layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Blocky Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--grass-color) 0%, var(--grass-dark) 100%);
|
||||
border: var(--border-width) solid var(--border-dark);
|
||||
border-bottom: 6px solid var(--dirt-color);
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--grass-color) 0%,
|
||||
var(--grass-dark) 25%,
|
||||
var(--grass-color) 50%,
|
||||
var(--grass-dark) 75%,
|
||||
var(--grass-color) 100%);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-light);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Blocky Navigation */
|
||||
.nav {
|
||||
display: flex;
|
||||
background: var(--stone-color);
|
||||
border: var(--border-width) solid var(--border-dark);
|
||||
border-bottom: 6px solid var(--cobblestone-color);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 16px 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.1s ease;
|
||||
border-right: var(--border-width) solid var(--border-dark);
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(0, 0, 0, 0.1) 100%);
|
||||
}
|
||||
|
||||
.nav-link:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--cobblestone-color);
|
||||
transform: translateY(2px);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-light);
|
||||
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
/* Blocky Cards */
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.3) 100%);
|
||||
}
|
||||
|
||||
.card h2, .card h3, .card h4 {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Blocky Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border: var(--border-width) solid;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-family: inherit;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(2px);
|
||||
box-shadow: var(--shadow-block-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
border-color: var(--secondary-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.8rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 16px 24px;
|
||||
font-size: 1.1rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Blocky Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 4px solid var(--border-dark);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.1s ease;
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-hover);
|
||||
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: var(--bg-accent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Blocky Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--bg-primary);
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
border-right: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table th:last-child,
|
||||
.table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--stone-color);
|
||||
color: var(--text-light);
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--bg-accent);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Blocky Status Badges */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: 2px solid;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-family: inherit;
|
||||
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.status-starting {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Blocky Logs - Terminal style */
|
||||
.logs, .log-container {
|
||||
background: var(--bg-dark);
|
||||
color: var(--emerald-color);
|
||||
padding: 16px;
|
||||
border: var(--border-width) solid var(--cobblestone-color);
|
||||
border-bottom: 6px solid var(--coal-color);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.6;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.log-line.text-muted {
|
||||
color: var(--stone-color) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for logs */
|
||||
.logs::-webkit-scrollbar,
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-track,
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: var(--cobblestone-color);
|
||||
border: 2px solid var(--coal-color);
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-thumb,
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: var(--stone-color);
|
||||
border: 2px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.logs::-webkit-scrollbar-thumb:hover,
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--iron-color);
|
||||
}
|
||||
|
||||
/* Stats Cards - Blocky style */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--wood-color);
|
||||
border: var(--border-width) solid var(--wood-dark);
|
||||
border-bottom: 6px solid var(--dirt-color);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-block);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.3) 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Blocky Alerts */
|
||||
.alert {
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: var(--border-width) solid;
|
||||
border-bottom-width: 6px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--success-color);
|
||||
border-color: var(--success-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--warning-color);
|
||||
border-color: var(--warning-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--water-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Grid layouts */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.col, .col-md-4, .col-md-6, .col-md-8 {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.nav-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border-right: none;
|
||||
border-bottom: 2px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.nav-link:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.col, .col-md-4, .col-md-6, .col-md-8 {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.btn:focus,
|
||||
.form-control:focus,
|
||||
.nav-link:focus {
|
||||
outline: 4px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.nav,
|
||||
.btn,
|
||||
.modal {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
break-inside: avoid;
|
||||
box-shadow: none;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional blocky elements */
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-wrapper input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Make everything more blocky and pronounced */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::before,
|
||||
input[type="radio"]:checked::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 2px;
|
||||
color: var(--success-color);
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Navbar pills for contentdb */
|
||||
.nav-pills .nav-link {
|
||||
background: var(--secondary-color);
|
||||
border: 3px solid var(--secondary-hover);
|
||||
border-radius: 0;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-primary);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-bottom: 6px solid var(--border-dark);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-block);
|
||||
}
|
496
public/js/main.js
Normal file
496
public/js/main.js
Normal file
@@ -0,0 +1,496 @@
|
||||
// Main JavaScript for Luanti Web Server
|
||||
class LuantiWebServer {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.serverStatus = 'stopped';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize Socket.IO
|
||||
this.initSocket();
|
||||
|
||||
// Initialize UI components
|
||||
this.initUI();
|
||||
|
||||
// Initialize forms
|
||||
this.initForms();
|
||||
|
||||
// Initialize real-time updates
|
||||
this.initRealTime();
|
||||
}
|
||||
|
||||
initSocket() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.updateConnectionStatus(true);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
this.socket.on('serverStatus', (status) => {
|
||||
this.updateServerStatus(status);
|
||||
});
|
||||
|
||||
this.socket.on('serverLog', (logEntry) => {
|
||||
this.appendLogEntry(logEntry);
|
||||
});
|
||||
}
|
||||
|
||||
initUI() {
|
||||
// Modal functionality
|
||||
this.initModals();
|
||||
|
||||
// Tooltips
|
||||
this.initTooltips();
|
||||
|
||||
// Auto-refresh toggles
|
||||
this.initAutoRefresh();
|
||||
}
|
||||
|
||||
initModals() {
|
||||
// Generic modal handling
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-modal-open]')) {
|
||||
const modalId = e.target.getAttribute('data-modal-open');
|
||||
this.openModal(modalId);
|
||||
}
|
||||
|
||||
if (e.target.matches('[data-modal-close]') || e.target.closest('[data-modal-close]')) {
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
if (e.target.matches('.modal')) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first input in modal
|
||||
const firstInput = modal.querySelector('input, textarea, select');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
initTooltips() {
|
||||
// Simple tooltip implementation
|
||||
document.querySelectorAll('[data-tooltip]').forEach(element => {
|
||||
element.addEventListener('mouseenter', (e) => {
|
||||
this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showTooltip(element, text) {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
`;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.top = rect.top - tooltip.offsetHeight - 8 + 'px';
|
||||
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px';
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
const tooltip = document.querySelector('.tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}
|
||||
|
||||
initAutoRefresh() {
|
||||
const autoRefreshElements = document.querySelectorAll('[data-auto-refresh]');
|
||||
autoRefreshElements.forEach(element => {
|
||||
const interval = parseInt(element.getAttribute('data-auto-refresh')) || 5000;
|
||||
const url = element.getAttribute('data-refresh-url') || window.location.href;
|
||||
|
||||
setInterval(async () => {
|
||||
if (element.checked || element.getAttribute('data-auto-refresh-active') === 'true') {
|
||||
await this.refreshElement(element, url);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshElement(element, url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
// This would need specific implementation per element type
|
||||
console.log('Auto-refresh triggered for', element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initForms() {
|
||||
// AJAX form submission
|
||||
document.addEventListener('submit', async (e) => {
|
||||
if (e.target.matches('[data-ajax-form]')) {
|
||||
e.preventDefault();
|
||||
await this.submitAjaxForm(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time form validation
|
||||
this.initFormValidation();
|
||||
}
|
||||
|
||||
async submitAjaxForm(form) {
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
try {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Processing...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method || 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const result = await response.json();
|
||||
this.showAlert(result.message || 'Success', 'success');
|
||||
} else {
|
||||
// Handle redirect or page reload
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
const error = await response.text();
|
||||
this.showAlert(error || 'An error occurred', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Network error: ' + error.message, 'danger');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
initFormValidation() {
|
||||
// Real-time validation for world/mod names
|
||||
document.addEventListener('input', (e) => {
|
||||
if (e.target.matches('[data-validate-name]')) {
|
||||
this.validateName(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateName(input) {
|
||||
const value = input.value;
|
||||
const isValid = /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 50;
|
||||
|
||||
input.setCustomValidity(isValid ? '' : 'Only letters, numbers, underscore and hyphen allowed (max 50 chars)');
|
||||
|
||||
// Visual feedback
|
||||
input.classList.toggle('is-invalid', !isValid && value.length > 0);
|
||||
input.classList.toggle('is-valid', isValid && value.length > 0);
|
||||
}
|
||||
|
||||
initRealTime() {
|
||||
// Auto-scroll logs
|
||||
this.initLogAutoScroll();
|
||||
}
|
||||
|
||||
initLogAutoScroll() {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (logsContainer) {
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
const observer = new MutationObserver(() => {
|
||||
if (logsContainer.scrollTop + logsContainer.clientHeight >= logsContainer.scrollHeight - 100) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(logsContainer, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = connected ? 'status status-running' : 'status status-error';
|
||||
statusElement.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
updateServerStatus(status) {
|
||||
this.serverStatus = status.status;
|
||||
|
||||
// Update status badge
|
||||
const statusElement = document.getElementById('server-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = `status status-${status.status}`;
|
||||
statusElement.textContent = status.status.charAt(0).toUpperCase() + status.status.slice(1);
|
||||
}
|
||||
|
||||
// Update PID
|
||||
const pidElement = document.getElementById('server-pid');
|
||||
if (pidElement) {
|
||||
pidElement.textContent = status.pid || 'N/A';
|
||||
}
|
||||
|
||||
// Update uptime
|
||||
const uptimeElement = document.getElementById('server-uptime');
|
||||
if (uptimeElement) {
|
||||
uptimeElement.textContent = this.formatUptime(status.uptime);
|
||||
}
|
||||
|
||||
// Update control buttons
|
||||
this.updateServerControls(status.status);
|
||||
}
|
||||
|
||||
updateServerControls(status) {
|
||||
const startBtn = document.getElementById('server-start');
|
||||
const stopBtn = document.getElementById('server-stop');
|
||||
const restartBtn = document.getElementById('server-restart');
|
||||
|
||||
if (startBtn) startBtn.disabled = status === 'running';
|
||||
if (stopBtn) stopBtn.disabled = status === 'stopped';
|
||||
if (restartBtn) restartBtn.disabled = status === 'stopped';
|
||||
}
|
||||
|
||||
appendLogEntry(logEntry) {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (!logsContainer) return;
|
||||
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = 'log-entry';
|
||||
|
||||
if (typeof logEntry === 'string') {
|
||||
logElement.textContent = logEntry;
|
||||
} else {
|
||||
logElement.innerHTML = `
|
||||
<span class="log-timestamp">[${new Date(logEntry.timestamp).toLocaleTimeString()}]</span>
|
||||
<span class="log-level-${logEntry.level}">${logEntry.message}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
logsContainer.appendChild(logElement);
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const logEntries = logsContainer.children;
|
||||
if (logEntries.length > 1000) {
|
||||
logEntries[0].remove();
|
||||
}
|
||||
}
|
||||
|
||||
formatUptime(uptime) {
|
||||
if (!uptime) return 'N/A';
|
||||
const seconds = Math.floor(uptime / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
showAlert(message, type = 'info', duration = 5000) {
|
||||
const alertsContainer = document.getElementById('alerts') || this.createAlertsContainer();
|
||||
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button type="button" class="modal-close" style="margin-left: auto;">×</button>
|
||||
`;
|
||||
|
||||
alertsContainer.appendChild(alert);
|
||||
|
||||
// Auto-remove alert
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.remove();
|
||||
}
|
||||
}, duration);
|
||||
|
||||
// Manual close
|
||||
alert.querySelector('.modal-close').addEventListener('click', () => {
|
||||
alert.remove();
|
||||
});
|
||||
}
|
||||
|
||||
createAlertsContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'alerts';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async api(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.luantiWebServer = new LuantiWebServer();
|
||||
});
|
||||
|
||||
// Global utility functions
|
||||
window.confirmDelete = function(itemType, itemName) {
|
||||
console.log(`confirmDelete called with: itemType="${itemType}", itemName="${itemName}"`);
|
||||
|
||||
if (itemType === 'world') {
|
||||
// Extra confirmation for world deletion - require typing the world name
|
||||
const message = `WARNING: You are about to permanently delete the world "${itemName}".\n\n` +
|
||||
`This will remove ALL world data including:\n` +
|
||||
`• All builds and constructions\n` +
|
||||
`• Player inventories and progress\n` +
|
||||
`• World settings and configuration\n` +
|
||||
`• All world-specific mods and data\n\n` +
|
||||
`This action cannot be undone!\n\n` +
|
||||
`Type the world name exactly to confirm:`;
|
||||
|
||||
const confirmation = prompt(message);
|
||||
console.log(`User entered: "${confirmation}", expected: "${itemName}"`);
|
||||
|
||||
if (confirmation === null) {
|
||||
console.log('User cancelled the dialog');
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = confirmation === itemName;
|
||||
console.log(`Confirmation ${matches ? 'matches' : 'does not match'}`);
|
||||
|
||||
if (!matches && confirmation !== null) {
|
||||
alert('Deletion cancelled - world name did not match exactly.');
|
||||
}
|
||||
|
||||
return matches;
|
||||
} else {
|
||||
// Standard confirmation for other items
|
||||
return confirm(`Are you sure you want to delete the ${itemType} "${itemName}"? This action cannot be undone.`);
|
||||
}
|
||||
};
|
||||
|
||||
window.showLoading = function(element, text = 'Loading...') {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
window.hideLoading = function(element) {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
};
|
629
public/js/server.js
Normal file
629
public/js/server.js
Normal file
@@ -0,0 +1,629 @@
|
||||
let socket;
|
||||
let autoScroll = true;
|
||||
let serverRunning = false;
|
||||
let isExternalServer = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize WebSocket connection for real-time updates
|
||||
initializeWebSocket();
|
||||
|
||||
// Load initial data
|
||||
loadWorlds();
|
||||
updateServerStatus();
|
||||
|
||||
// Set up periodic status updates (every 3 seconds for better responsiveness)
|
||||
setInterval(updateServerStatus, 3000);
|
||||
|
||||
// Add event listeners for buttons
|
||||
document.getElementById('startBtn').addEventListener('click', startServer);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopServer);
|
||||
document.getElementById('restartBtn').addEventListener('click', restartServer);
|
||||
document.getElementById('downloadBtn').addEventListener('click', downloadLogs);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearLogs);
|
||||
document.getElementById('autoScrollBtn').addEventListener('click', toggleAutoScroll);
|
||||
document.getElementById('sendBtn').addEventListener('click', sendCommand);
|
||||
|
||||
// Add enter key handler for console input
|
||||
document.getElementById('consoleInput').addEventListener('keypress', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendCommand();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function initializeWebSocket() {
|
||||
socket = io();
|
||||
|
||||
socket.on('server:log', function(logEntry) {
|
||||
addLogEntry(logEntry.type, logEntry.content, logEntry.timestamp);
|
||||
});
|
||||
|
||||
socket.on('server:status', function(status) {
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
});
|
||||
|
||||
socket.on('server:players', function(players) {
|
||||
updatePlayersList(players, isExternalServer);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to check server status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusDisplay(status) {
|
||||
const statusLight = document.getElementById('statusLight');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const consoleInputGroup = document.getElementById('consoleInputGroup');
|
||||
|
||||
const wasRunning = serverRunning;
|
||||
serverRunning = status.isRunning;
|
||||
|
||||
if (status.isRunning) {
|
||||
if (status.isReady) {
|
||||
// Server is running and ready to accept connections
|
||||
statusLight.className = 'status-light online';
|
||||
statusText.textContent = status.isExternal ? 'Running (External - Monitor Only)' : 'Running';
|
||||
} else {
|
||||
// Server process is running but not ready yet
|
||||
statusLight.className = 'status-light starting';
|
||||
statusText.textContent = 'Starting...';
|
||||
}
|
||||
|
||||
// For external servers, disable control buttons
|
||||
if (status.isExternal) {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
} else {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
restartBtn.disabled = false;
|
||||
consoleInputGroup.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
statusLight.className = 'status-light offline';
|
||||
statusText.textContent = 'Offline';
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
|
||||
|
||||
// Reset button states if server stopped unexpectedly
|
||||
if (startBtn.textContent === '⏳ Starting...') {
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
if (restartBtn.textContent === '⏳ Restarting...') {
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
|
||||
// Log if server stopped unexpectedly
|
||||
if (wasRunning && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server has stopped. Check logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
document.getElementById('uptime').textContent = formatUptime(status.uptime);
|
||||
document.getElementById('playerCount').textContent = status.players || 0;
|
||||
document.getElementById('memoryUsage').textContent = status.memoryUsage ?
|
||||
Math.round(status.memoryUsage) + ' MB' : '--';
|
||||
|
||||
// Debug: Log the status to see what we're getting
|
||||
console.log('Server status update:', {
|
||||
isRunning: status.isRunning,
|
||||
players: status.players,
|
||||
uptime: status.uptime
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(milliseconds) {
|
||||
if (!milliseconds) return '--';
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
|
||||
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
|
||||
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
|
||||
return seconds + 's';
|
||||
}
|
||||
|
||||
async function loadWorlds() {
|
||||
console.log('loadWorlds() called');
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
console.log('Response status:', response.status, 'Content-Type:', contentType);
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for loading worlds');
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Please log in to load worlds</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>🔒 Authentication required</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const worlds = await response.json();
|
||||
console.log('Worlds received:', worlds);
|
||||
const worldSelect = document.getElementById('worldSelect');
|
||||
|
||||
if (worlds.length === 0) {
|
||||
worldSelect.innerHTML =
|
||||
'<option value="">No worlds found - server will create default world</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
|
||||
} else {
|
||||
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
||||
worlds.forEach(world => {
|
||||
const option = document.createElement('option');
|
||||
option.value = world.name;
|
||||
option.textContent = '🌍 ' + (world.displayName || world.name);
|
||||
worldSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Error loading worlds - will use defaults</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
console.log('Starting server with world:', worldName);
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before starting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = '⏳ Starting...';
|
||||
|
||||
const response = await fetch('/api/server/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName })
|
||||
});
|
||||
|
||||
// Check if response is HTML (redirect to login) instead of JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
// Optionally redirect to login
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server started successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly. Check logs for errors.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to start server: ' + (result.error || 'Unknown error'));
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server start error:', error);
|
||||
addLogEntry('error', 'Failed to start server: ' + error.message);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer() {
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
|
||||
try {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = '⏳ Stopping...';
|
||||
|
||||
const response = await fetch('/api/server/stop', { method: 'POST' });
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server stopped successfully');
|
||||
await updateServerStatus();
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to stop server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server stop error:', error);
|
||||
addLogEntry('error', 'Failed to stop server: ' + error.message);
|
||||
} finally {
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before restarting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
restartBtn.disabled = true;
|
||||
restartBtn.textContent = '⏳ Restarting...';
|
||||
|
||||
const response = await fetch('/api/server/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName || null })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server restarted successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly after restart. Check logs for errors.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to restart server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server restart error:', error);
|
||||
addLogEntry('error', 'Failed to restart server: ' + error.message);
|
||||
} finally {
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}
|
||||
|
||||
function addLogEntry(type, message, timestamp) {
|
||||
const consoleContent = document.getElementById('consoleContent');
|
||||
const logEntry = document.createElement('div');
|
||||
|
||||
timestamp = timestamp || new Date().toLocaleTimeString();
|
||||
|
||||
logEntry.className = 'log-entry ' + type;
|
||||
logEntry.innerHTML = '<span class="timestamp">' + timestamp + '</span>' +
|
||||
'<span class="message">' + escapeHtml(message) + '</span>';
|
||||
|
||||
consoleContent.appendChild(logEntry);
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if (autoScroll) {
|
||||
consoleContent.scrollTop = consoleContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const maxEntries = 1000;
|
||||
while (consoleContent.children.length > maxEntries) {
|
||||
consoleContent.removeChild(consoleContent.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('consoleContent').innerHTML = '';
|
||||
addLogEntry('info', 'Console cleared');
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
document.getElementById('autoScrollText').textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
||||
}
|
||||
|
||||
async function sendCommand() {
|
||||
const input = document.getElementById('consoleInput');
|
||||
const command = input.value.trim();
|
||||
|
||||
if (!command) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', 'Command sent: ' + command);
|
||||
input.value = '';
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to send command: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send command error:', error);
|
||||
addLogEntry('error', 'Failed to send command: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayersList(players, isExternal) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
|
||||
if (!players || players.length === 0) {
|
||||
playersList.innerHTML = '<p class="text-muted">No players online</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a table for better formatting with kick functionality
|
||||
const playersHtml = '<table class="table table-sm">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>Player</th>' +
|
||||
'<th>Last Activity</th>' +
|
||||
'<th>Actions</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody>' +
|
||||
players.map((player, index) => {
|
||||
// Format the last seen time
|
||||
let lastActivity = '--';
|
||||
if (player.lastSeen) {
|
||||
const now = new Date();
|
||||
const lastSeenTime = new Date(player.lastSeen);
|
||||
const diffMinutes = Math.floor((now - lastSeenTime) / (1000 * 60));
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
lastActivity = 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
lastActivity = diffMinutes + 'm ago';
|
||||
} else {
|
||||
lastActivity = Math.floor(diffMinutes / 60) + 'h ago';
|
||||
}
|
||||
}
|
||||
|
||||
return '<tr>' +
|
||||
'<td><strong>' + escapeHtml(player.name) + '</strong></td>' +
|
||||
'<td>' +
|
||||
'<small class="text-muted">' + lastActivity + '</small><br>' +
|
||||
'<span class="badge badge-secondary">' + (player.lastAction || 'Active') + '</span>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn-sm btn-outline-danger kick-player-btn" data-player-name="' + escapeHtml(player.name) + '"' +
|
||||
(isExternal ? ' disabled title="Cannot kick players on external servers"' : '') + '>' +
|
||||
'<i class="fas fa-user-slash"></i> Kick' +
|
||||
'</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>';
|
||||
|
||||
playersList.innerHTML = playersHtml;
|
||||
|
||||
// Add event listeners for kick buttons
|
||||
const kickButtons = playersList.querySelectorAll('.kick-player-btn');
|
||||
kickButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const playerName = this.getAttribute('data-player-name');
|
||||
kickPlayer(playerName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function kickPlayer(playerName) {
|
||||
console.log('kickPlayer() called for player:', playerName);
|
||||
addLogEntry('info', 'Attempting to kick player: ' + playerName);
|
||||
|
||||
if (!confirm('Are you sure you want to kick ' + playerName + '?')) {
|
||||
console.log('Kick cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending kick request...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
command: '/kick ' + playerName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
addLogEntry('error', 'Authentication required to kick players. Please refresh the page.');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addLogEntry('success', 'Kicked player: ' + playerName);
|
||||
// Refresh player list after a short delay
|
||||
setTimeout(updateServerStatus, 1000);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to kick player: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error kicking player:', error);
|
||||
addLogEntry('error', 'Error kicking player: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/server/logs');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required to download logs');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'server-logs-' + new Date().toISOString().split('T')[0] + '.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to download logs: HTTP ' + response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download logs error:', error);
|
||||
addLogEntry('error', 'Failed to download logs: ' + error.message);
|
||||
}
|
||||
}
|
39
public/js/shared-status.js
Normal file
39
public/js/shared-status.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Shared server status functionality for all pages
|
||||
|
||||
async function updateServerStatus(statusElementId) {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updateStatusElement(statusElementId, status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
// Show error state
|
||||
const statusElement = document.getElementById(statusElementId);
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Error';
|
||||
statusElement.className = 'status status-stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusElement(elementId, status) {
|
||||
const statusElement = document.getElementById(elementId);
|
||||
if (statusElement) {
|
||||
const statusText = status.statusText || (status.isRunning ? 'running' : 'stopped');
|
||||
statusElement.textContent = statusText.charAt(0).toUpperCase() + statusText.slice(1);
|
||||
statusElement.className = `status status-${statusText}`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user