1604 lines
61 KiB
HTML
1604 lines
61 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Station Broadcast Console</title>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
/* Ultra-Dark / Minimalist Theme */
|
|
--bg-body: #09090b;
|
|
/* Zinc-950 */
|
|
--bg-panel: #18181b;
|
|
/* Zinc-900 */
|
|
--bg-hover: #27272a;
|
|
/* Zinc-800 */
|
|
--bg-border: #27272a;
|
|
/* Zinc-800 */
|
|
|
|
--text-primary: #e4e4e7;
|
|
/* Zinc-200 */
|
|
--text-muted: #71717a;
|
|
/* Zinc-500 */
|
|
|
|
--accent: #fafafa;
|
|
/* Zinc-50 */
|
|
--danger: #ef4444;
|
|
/* Red-500 */
|
|
--success: #10b981;
|
|
/* Emerald-500 */
|
|
--warning: #f59e0b;
|
|
/* Amber-500 */
|
|
|
|
--radius: 6px;
|
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-ui);
|
|
background-color: var(--bg-body);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
padding: 0;
|
|
height: 100vh;
|
|
display: grid;
|
|
grid-template-rows: 1fr 4px 300px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* --- Splitter / Resizer --- */
|
|
.resizer-horizontal {
|
|
height: 4px;
|
|
background: var(--bg-border);
|
|
cursor: row-resize;
|
|
transition: background 0.2s;
|
|
z-index: 50;
|
|
}
|
|
|
|
.resizer-horizontal:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
.resizer-vertical {
|
|
width: 4px;
|
|
background: var(--bg-border);
|
|
cursor: col-resize;
|
|
transition: background 0.2s;
|
|
z-index: 50;
|
|
}
|
|
|
|
.resizer-vertical:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
/* --- Main Area (Top): Status & File Manager --- */
|
|
.main-area {
|
|
display: grid;
|
|
grid-template-columns: 400px 4px 1fr;
|
|
/* Add col for resizer */
|
|
background: var(--bg-body);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Left Sidebar: Status */
|
|
.sidebar {
|
|
background: var(--bg-body);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.version-panel {
|
|
padding: 16px 20px;
|
|
background: var(--bg-panel);
|
|
border-bottom: 1px solid var(--bg-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.version-panel-header {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.version-display {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.1rem;
|
|
color: var(--success);
|
|
font-weight: 500;
|
|
background: #000;
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--bg-border);
|
|
text-align: center;
|
|
}
|
|
|
|
.section-header {
|
|
padding: 16px 20px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
background: var(--bg-body);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.status-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
.status-item {
|
|
border-bottom: 1px solid var(--bg-border);
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.status-main {
|
|
padding: 12px 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.status-main:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.item-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.item-title {
|
|
font-weight: 500;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.item-sub {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--bg-border);
|
|
}
|
|
|
|
.dot-online {
|
|
background: var(--success);
|
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
}
|
|
|
|
.dot-offline {
|
|
background: var(--danger);
|
|
}
|
|
|
|
.dot-warn {
|
|
background: var(--warning);
|
|
}
|
|
|
|
.dot-broadcasting {
|
|
background: #3b82f6; /* Blue for broadcasting */
|
|
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
|
|
animation: pulse 1s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.5; transform: scale(1.5); }
|
|
100% { opacity: 1; transform: scale(1); }
|
|
}
|
|
|
|
/* Expandable Details */
|
|
.status-details {
|
|
display: none;
|
|
background: var(--bg-panel);
|
|
padding: 10px 20px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
border-top: 1px solid var(--bg-border);
|
|
}
|
|
|
|
.status-details.open {
|
|
display: block;
|
|
}
|
|
|
|
.trip-log {
|
|
display: grid;
|
|
grid-template-columns: 80px 1fr auto;
|
|
gap: 10px;
|
|
padding: 4px 0;
|
|
border-bottom: 1px dashed var(--bg-border);
|
|
}
|
|
|
|
.trip-log:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* Right Content: File Manager */
|
|
.content-area {
|
|
background: var(--bg-body);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fm-toolbar {
|
|
padding: 12px 24px;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background: var(--bg-body);
|
|
}
|
|
|
|
.breadcrumb {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 0.9rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.breadcrumb-item {
|
|
cursor: pointer;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.breadcrumb-item:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.breadcrumb-item.active {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
cursor: default;
|
|
}
|
|
|
|
.breadcrumb-sep {
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.btn {
|
|
background: transparent;
|
|
border: 1px solid var(--bg-border);
|
|
color: var(--text-primary);
|
|
padding: 6px 12px;
|
|
border-radius: var(--radius);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.btn:hover:not(:disabled) {
|
|
background: var(--bg-hover);
|
|
border-color: var(--text-muted);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--text-primary);
|
|
color: var(--bg-body);
|
|
border-color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: #fff;
|
|
border-color: #fff;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-icon {
|
|
padding: 8px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-icon:hover {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: var(--danger);
|
|
}
|
|
|
|
.file-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.file-header {
|
|
display: grid;
|
|
grid-template-columns: 40px 1fr 120px 100px;
|
|
padding: 10px 24px;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--bg-body);
|
|
z-index: 10;
|
|
}
|
|
|
|
.file-row {
|
|
display: grid;
|
|
grid-template-columns: 40px 1fr 120px 100px;
|
|
padding: 12px 24px;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
align-items: center;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.file-row:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.file-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.file-name {
|
|
font-weight: 500;
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.file-meta {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* --- Console (Bottom) --- */
|
|
.console-area {
|
|
background: #000;
|
|
/* Pure black for console */
|
|
border-top: 1px solid var(--bg-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.console-header {
|
|
padding: 8px 20px;
|
|
background: var(--bg-panel);
|
|
border-bottom: 1px solid var(--bg-border);
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.console-logs {
|
|
flex: 1;
|
|
padding: 16px 20px;
|
|
overflow-y: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.85rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.log-entry {
|
|
display: flex;
|
|
gap: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.log-time {
|
|
color: var(--text-muted);
|
|
opacity: 0.5;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.log-msg {
|
|
color: #d4d4d8;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.type-info {
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.type-warn {
|
|
color: #eab308;
|
|
}
|
|
|
|
.type-err {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.type-success {
|
|
color: #10b981;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
-webkit-backdrop-filter: blur(4px);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--bg-border);
|
|
border-radius: var(--radius);
|
|
width: 90%;
|
|
max-width: 800px;
|
|
max-height: 85vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid var(--bg-border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-title {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.modal-footer {
|
|
padding: 16px 24px;
|
|
border-top: 1px solid var(--bg-border);
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
background: var(--bg-body);
|
|
}
|
|
|
|
textarea.editor {
|
|
width: 100%;
|
|
height: 400px;
|
|
background: var(--bg-body);
|
|
border: 1px solid var(--bg-border);
|
|
border-radius: var(--radius);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9rem;
|
|
padding: 16px;
|
|
resize: none;
|
|
outline: none;
|
|
}
|
|
|
|
textarea.editor:focus {
|
|
border-color: var(--text-muted);
|
|
}
|
|
|
|
/* Custom Audio Player */
|
|
.audio-player {
|
|
background: var(--bg-body);
|
|
border: 1px solid var(--bg-border);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.play-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--text-primary);
|
|
color: var(--bg-body);
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: transform 0.1s;
|
|
}
|
|
|
|
.play-btn:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.progress-container {
|
|
flex: 1;
|
|
height: 6px;
|
|
background: var(--bg-hover);
|
|
border-radius: 3px;
|
|
position: relative;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 100%;
|
|
background: var(--text-primary);
|
|
border-radius: 3px;
|
|
width: 0%;
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.time-display {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
min-width: 80px;
|
|
text-align: right;
|
|
}
|
|
|
|
/* Toasts */
|
|
.toast-container {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
z-index: 200;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.toast {
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--bg-border);
|
|
padding: 12px 20px;
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 300px;
|
|
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.toast-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toast-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.toast-title {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.toast-msg {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Utilities */
|
|
input[type="checkbox"] {
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 1px solid var(--text-muted);
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type="checkbox"]:checked {
|
|
background: var(--text-primary);
|
|
border-color: var(--text-primary);
|
|
}
|
|
|
|
input[type="checkbox"]:checked::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 5px;
|
|
top: 2px;
|
|
width: 4px;
|
|
height: 8px;
|
|
border: solid #000;
|
|
border-width: 0 2px 2px 0;
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
input[type="file"] {
|
|
display: none;
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--bg-hover);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #52525b;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- Top Area: Sidebar (Status) + File Manager -->
|
|
<div class="main-area" id="main-area">
|
|
|
|
<!-- Sidebar: Status -->
|
|
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
|
<!-- Version Control (Hidden since we don't use it anymore) -->
|
|
<div class="version-panel" style="display:none;">
|
|
<div class="version-panel-header">
|
|
<span>Audio Version</span>
|
|
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
|
|
</button>
|
|
</div>
|
|
<div class="version-display" id="current-version">Loading...</div>
|
|
</div>
|
|
|
|
<!-- Stations -->
|
|
<div class="section-header">Active Stations</div>
|
|
<div class="status-list" id="station-list">
|
|
</div>
|
|
|
|
<!-- Trains -->
|
|
<div class="section-header" style="border-top:1px solid var(--bg-border)">Active Trains</div>
|
|
<div class="status-list" id="train-list" style="flex:1;">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resizer Vertical -->
|
|
<div class="resizer-vertical" id="resizer-v"></div>
|
|
|
|
<!-- Content: Radio Stream -->
|
|
<div class="content-area" id="radio-view" style="display:none; min-width: 300px; padding: 20px; overflow-y: auto;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; border-bottom:1px solid var(--bg-border); padding-bottom:10px;">
|
|
<h2 style="margin:0; font-weight:500; font-size:1.2rem; color:var(--text-primary);">Global Radio Stream</h2>
|
|
<button class="btn" onclick="showFileManager()">Back to Files</button>
|
|
</div>
|
|
|
|
<div id="radio-streams-container" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap:16px;">
|
|
<!-- Stream cards will be generated here -->
|
|
<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">
|
|
Waiting for broadcast streams...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content: File Manager -->
|
|
<div class="content-area" id="file-manager-view" style="min-width: 300px;">
|
|
<div class="fm-toolbar">
|
|
<div class="breadcrumb" id="breadcrumb"></div>
|
|
<div class="actions">
|
|
<span id="selection-count"
|
|
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
|
|
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
|
|
<a href="/infoboard.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Full Info Board</a>
|
|
<a href="/generator.html" class="btn btn-primary" style="text-decoration:none">Broadcast Generator</a>
|
|
<a href="/tempbroadcast.html" class="btn btn-primary" style="text-decoration:none">Temp Broadcast</a>
|
|
<a href="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
|
|
<a href="/listmanager.html" class="btn btn-primary" style="text-decoration:none">list.txt Manager</a>
|
|
<button class="btn" onclick="refreshFiles()">Refresh</button>
|
|
<button class="btn" id="btn-delete" disabled onclick="deleteSelected()">Delete</button>
|
|
<button class="btn btn-primary" id="btn-upload" onclick="triggerUpload()">Upload</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="file-header">
|
|
<div style="display:flex;justify-content:center">
|
|
<input type="checkbox" id="select-all" title="Select All/None" aria-label="Select All/None" onclick="toggleSelectAll()">
|
|
</div>
|
|
<div>Name</div>
|
|
<div>Type</div>
|
|
<div style="text-align:right">Action</div>
|
|
</div>
|
|
|
|
<div class="file-list" id="file-list-body">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resizer Horizontal -->
|
|
<div class="resizer-horizontal" id="resizer-h"></div>
|
|
|
|
<!-- Bottom Area: Console -->
|
|
<div class="console-area" style="min-height: 100px;">
|
|
<div class="console-header">
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<span>System Output</span>
|
|
<span id="conn-status" style="color:var(--danger)">Disconnected</span>
|
|
</div>
|
|
<button class="btn" style="padding:2px 8px;font-size:0.7rem" onclick="forceSave()">Force Save Data</button>
|
|
</div>
|
|
<div class="console-logs" id="console-logs">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden Inputs -->
|
|
<input type="file" id="upload-config" accept=".txt" onchange="performUpload('listFile', '/api/upload/list', this)">
|
|
<input type="file" id="upload-general" multiple accept=".wav"
|
|
onchange="performUpload('generalAudioFiles', '/api/upload/general-audio', this)">
|
|
<input type="file" id="upload-station" multiple accept=".wav"
|
|
onchange="performUpload('stationAudioFiles', '/api/upload/station-audio', this)">
|
|
|
|
<!-- Preview Modal -->
|
|
<div class="modal-overlay" id="preview-modal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<div class="modal-title" id="modal-title">File Preview</div>
|
|
<button class="btn-icon" onclick="closeModal()" title="Close Modal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" id="modal-content">
|
|
</div>
|
|
<div class="modal-footer" id="modal-footer">
|
|
<button class="btn" onclick="closeModal()">Close</button>
|
|
<button class="btn btn-primary" id="btn-save" onclick="saveFile()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Container -->
|
|
<div class="toast-container" id="toast-container"></div>
|
|
|
|
<script>
|
|
// --- Icons (SVG) ---
|
|
const ICONS = {
|
|
folder: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#fbbf24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
|
|
audio: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#38bdf8"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
|
|
file: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#94a3b8"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>`,
|
|
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
|
|
play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>`,
|
|
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
|
|
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
|
|
chevronUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`
|
|
};
|
|
|
|
// --- State ---
|
|
let allFiles = [];
|
|
let currentPath = 'root';
|
|
let expandedTrains = new Set();
|
|
let stationCodeMap = {};
|
|
let broadcastingStations = new Set();
|
|
|
|
// --- WebSocket ---
|
|
const connStatus = document.getElementById('conn-status');
|
|
let ws;
|
|
|
|
function connect() {
|
|
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
ws = new WebSocket(`${wsProto}://${window.location.host}`);
|
|
|
|
ws.onopen = () => {
|
|
connStatus.textContent = 'Connected';
|
|
connStatus.style.color = 'var(--success)';
|
|
ws.send(JSON.stringify({ type: 'console_connect' }));
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
connStatus.textContent = 'Disconnected (Reconnecting...)';
|
|
connStatus.style.color = 'var(--danger)';
|
|
setTimeout(connect, 3000);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'init_state' || data.type === 'state_update') {
|
|
updateState(data);
|
|
if (data.logs && data.type === 'init_state') data.logs.forEach(l => addLog(l));
|
|
} else if (data.type === 'log') {
|
|
addLog(data.data);
|
|
} else if (data.type === 'event') {
|
|
showToast(data.title, data.message);
|
|
} else if (data.type === 'stream_start') {
|
|
broadcastingStations.add(data.station);
|
|
updateStationDots();
|
|
updateRadioStreams(data.station, data.seq, true, data.hlsUrl);
|
|
} else if (data.type === 'stream_end') {
|
|
broadcastingStations.delete(data.station);
|
|
updateStationDots();
|
|
updateRadioStreams(data.station, null, false);
|
|
}
|
|
};
|
|
}
|
|
|
|
connect();
|
|
|
|
// --- Renderers ---
|
|
function updateState(state) {
|
|
if (state.stations) {
|
|
const list = document.getElementById('station-list');
|
|
list.innerHTML = '';
|
|
const stList = Array.isArray(state.stations) ? state.stations : Object.values(state.stations);
|
|
|
|
stList.forEach(st => {
|
|
stationCodeMap[st.name] = st.code;
|
|
});
|
|
|
|
if (stList.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active stations</div>';
|
|
|
|
stList.forEach(st => {
|
|
const isOnline = (Date.now() - st.lastSeen) < 60000;
|
|
const isBroadcasting = broadcastingStations.has(st.name);
|
|
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
|
const div = document.createElement('div');
|
|
div.className = 'status-item';
|
|
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
div.innerHTML = `
|
|
<div class="status-main">
|
|
<div class="item-info">
|
|
<div class="item-title">
|
|
${st.name}
|
|
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
|
|
</div>
|
|
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
|
|
<button class="btn-icon" onclick="deleteStation('${st.name}')">${ICONS.trash}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
list.appendChild(div);
|
|
});
|
|
}
|
|
|
|
if (state.activeTrains) {
|
|
const list = document.getElementById('train-list');
|
|
list.innerHTML = '';
|
|
|
|
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
|
|
|
|
state.activeTrains.forEach(tr => {
|
|
const isExpanded = expandedTrains.has(tr.trainId);
|
|
const div = document.createElement('div');
|
|
div.className = 'status-item';
|
|
|
|
let detailsHtml = '';
|
|
if (tr.tripLogs && tr.tripLogs.length > 0) {
|
|
detailsHtml = tr.tripLogs.map(log => {
|
|
const time = new Date(log.time).toLocaleTimeString();
|
|
const stCode = stationCodeMap[log.station] || log.station;
|
|
return `
|
|
<div class="trip-log">
|
|
<span>${time}</span>
|
|
<span>${stCode}</span>
|
|
<span>${log.action}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
detailsHtml = '<div style="font-style:italic">No trip history</div>';
|
|
}
|
|
|
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
|
|
|
div.innerHTML = `
|
|
<div class="status-main" onclick="toggleTrain('${tr.trainId}')">
|
|
<div class="item-info">
|
|
<div class="item-title">${tr.trainId}</div>
|
|
<div class="item-sub">${currentStCode}</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
|
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
|
</div>
|
|
</div>
|
|
<div class="status-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
|
|
<div style="margin-bottom:8px;font-weight:600">Trip Logs</div>
|
|
${detailsHtml}
|
|
<div style="margin-top:10px;text-align:right">
|
|
<button class="btn-icon" onclick="event.stopPropagation(); deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
list.appendChild(div);
|
|
});
|
|
|
|
// Network Statistics
|
|
const statsDiv = document.createElement('div');
|
|
statsDiv.className = 'status-item';
|
|
const isStatsExpanded = expandedTrains.has('__STATS__');
|
|
|
|
let statsHtml = '<div style="padding:10px;text-align:center;color:var(--text-muted)">No data</div>';
|
|
if (state.travelTimes) {
|
|
const keys = Object.keys(state.travelTimes).sort();
|
|
if (keys.length > 0) {
|
|
statsHtml = keys.map(key => {
|
|
const times = state.travelTimes[key];
|
|
const [from, to] = key.split('|');
|
|
const fromCode = stationCodeMap[from] || from;
|
|
const toCode = stationCodeMap[to] || to;
|
|
const avg = Math.ceil(times.reduce((a, b) => a + b, 0) / times.length);
|
|
return `
|
|
<div class="trip-log">
|
|
<span>${fromCode} → ${toCode}</span>
|
|
<span style="text-align:right; color:var(--success)">${avg}s</span>
|
|
<span style="text-align:right; font-size:0.75em; opacity:0.7">(${times.length})</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
statsDiv.innerHTML = `
|
|
<div class="status-main" onclick="toggleTrain('__STATS__')" style="background:var(--bg-body); border-top:1px solid var(--bg-border)">
|
|
<div class="item-info">
|
|
<div class="item-title" style="font-size:0.85rem; color:var(--text-muted)">NETWORK STATISTICS</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<button class="btn-icon" onclick="event.stopPropagation(); deleteStats()" style="display:inline-flex" title="Clear Statistics">${ICONS.trash}</button>
|
|
<div style="color:var(--text-muted)">${isStatsExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
|
</div>
|
|
</div>
|
|
<div class="status-details ${isStatsExpanded ? 'open' : ''}" id="details-__STATS__">
|
|
<div style="display:grid; grid-template-columns: 1fr 40px 30px; gap:10px; margin-bottom:5px; font-weight:600; font-size:0.75rem; color:var(--text-muted)">
|
|
<span>ROUTE</span>
|
|
<span style="text-align:right">AVG</span>
|
|
<span style="text-align:right">CNT</span>
|
|
</div>
|
|
${statsHtml}
|
|
</div>
|
|
`;
|
|
list.appendChild(statsDiv);
|
|
}
|
|
}
|
|
|
|
/* Removed separate Travel Times renderer */
|
|
|
|
function showRadioStream() {
|
|
document.getElementById('file-manager-view').style.display = 'none';
|
|
document.getElementById('radio-view').style.display = 'flex';
|
|
document.getElementById('radio-view').style.flexDirection = 'column';
|
|
}
|
|
|
|
function showFileManager() {
|
|
document.getElementById('radio-view').style.display = 'none';
|
|
document.getElementById('file-manager-view').style.display = 'flex';
|
|
document.getElementById('file-manager-view').style.flexDirection = 'column';
|
|
}
|
|
|
|
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
|
const container = document.getElementById('radio-streams-container');
|
|
const cardId = `stream-card-${station.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
|
|
if (isStarting) {
|
|
// Clear empty state
|
|
if (container.innerHTML.includes('Waiting for broadcast streams...')) {
|
|
container.innerHTML = '';
|
|
}
|
|
|
|
let card = document.getElementById(cardId);
|
|
if (!card) {
|
|
card = document.createElement('div');
|
|
card.id = cardId;
|
|
card.style.cssText = 'background:var(--bg-panel); border:1px solid #3b82f6; border-radius:var(--radius); padding:16px; box-shadow:0 4px 12px rgba(59, 130, 246, 0.15); animation:slideIn 0.3s ease-out; position:relative; overflow:hidden; display:flex; flex-direction:column; gap:12px;';
|
|
container.appendChild(card);
|
|
}
|
|
|
|
const seqHtml = seq ? seq.map(s => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.75rem; font-family:var(--font-mono); margin:2px; display:inline-block;">${s}</span>`).join('') : 'Unknown sequence';
|
|
|
|
card.innerHTML = `
|
|
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
<div style="font-weight:600; font-size:1.1rem;">${station}</div>
|
|
<div class="status-dot dot-broadcasting"></div>
|
|
</div>
|
|
${hlsUrl ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${hlsUrl}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${hlsUrl}</a></div>` : ''}
|
|
<div>
|
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</div>
|
|
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
|
|
${seqHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
const card = document.getElementById(cardId);
|
|
if (card) {
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'scale(0.95)';
|
|
card.style.transition = 'all 0.3s';
|
|
setTimeout(() => {
|
|
card.remove();
|
|
if (container.children.length === 0) {
|
|
container.innerHTML = '<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">Waiting for broadcast streams...</div>';
|
|
}
|
|
}, 300);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateStationDots() {
|
|
const dots = document.querySelectorAll('[id^="station-dot-"]');
|
|
dots.forEach(dot => {
|
|
const name = dot.getAttribute('data-name');
|
|
const isOnline = dot.getAttribute('data-online') === 'true';
|
|
const isBroadcasting = broadcastingStations.has(name);
|
|
|
|
dot.className = 'status-dot ' + (isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'));
|
|
});
|
|
}
|
|
|
|
function toggleTrain(id) {
|
|
if (expandedTrains.has(id)) expandedTrains.delete(id);
|
|
else expandedTrains.add(id);
|
|
// We need to re-render to update icons/state, but since state updates often,
|
|
// just toggling the DOM element is smoother until next update
|
|
const details = document.getElementById(`details-${id}`);
|
|
if (details) {
|
|
details.classList.toggle('open');
|
|
// Force re-render on next update will respect the Set
|
|
}
|
|
}
|
|
|
|
function addLog(logText) {
|
|
const container = document.getElementById('console-logs');
|
|
const div = document.createElement('div');
|
|
div.className = 'log-entry';
|
|
|
|
const timeMatch = logText.match(/^\[(.*?)\]/);
|
|
let time = new Date().toLocaleTimeString();
|
|
let content = logText;
|
|
let typeClass = '';
|
|
|
|
if (timeMatch) {
|
|
// Try to parse ISO string to local time
|
|
const dateStr = timeMatch[1];
|
|
const date = new Date(dateStr);
|
|
if (!isNaN(date.getTime())) {
|
|
time = date.toLocaleTimeString();
|
|
} else {
|
|
// Fallback if not valid date
|
|
time = dateStr.split('T')[1]?.split('.')[0] || dateStr;
|
|
}
|
|
content = logText.substring(timeMatch[0].length).trim();
|
|
}
|
|
|
|
if (content.includes('[ERROR]')) typeClass = 'type-err';
|
|
else if (content.includes('[WARN]')) typeClass = 'type-warn';
|
|
else if (content.includes('[SUCCESS]')) typeClass = 'type-success';
|
|
else typeClass = 'type-info';
|
|
|
|
content = content.replace(/^\[.*?\]\s*/, '');
|
|
|
|
div.innerHTML = `<div class="log-time">${time}</div><div class="log-msg"><span class="${typeClass}">></span> ${content}</div>`;
|
|
container.prepend(div);
|
|
if (container.children.length > 100) container.lastChild.remove();
|
|
}
|
|
|
|
function showToast(title, message) {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast';
|
|
|
|
// Icon based on title/message
|
|
let iconColor = 'var(--text-primary)';
|
|
if (title.includes('Arrival')) iconColor = 'var(--success)';
|
|
if (title.includes('Departure')) iconColor = 'var(--warning)';
|
|
|
|
toast.innerHTML = `
|
|
<div class="toast-icon" style="background:${iconColor}; color:var(--bg-body)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
</div>
|
|
<div class="toast-content">
|
|
<div class="toast-title">${title}</div>
|
|
<div class="toast-msg">${message}</div>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transform = 'translateX(100%)';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 5000);
|
|
}
|
|
|
|
async function forceSave() {
|
|
try {
|
|
await fetch('/api/save-data', { method: 'POST' });
|
|
addLog('[INFO] Data save requested');
|
|
} catch (e) {
|
|
addLog('[ERROR] Save request failed');
|
|
}
|
|
}
|
|
|
|
// --- File Manager ---
|
|
async function refreshFiles() {
|
|
try {
|
|
const res = await fetch('/api/files');
|
|
const data = await res.json();
|
|
allFiles = data.files;
|
|
renderFiles();
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
function renderFiles() {
|
|
const container = document.getElementById('file-list-body');
|
|
const breadcrumb = document.getElementById('breadcrumb');
|
|
const btnUpload = document.getElementById('btn-upload');
|
|
container.innerHTML = '';
|
|
|
|
// Breadcrumb
|
|
if (currentPath === 'root') {
|
|
breadcrumb.innerHTML = '<div class="breadcrumb-item active">Home</div>';
|
|
btnUpload.disabled = true;
|
|
} else {
|
|
const name = currentPath === 'config' ? 'Config' : (currentPath === 'general' ? 'General' : 'Station');
|
|
breadcrumb.innerHTML = `
|
|
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
|
<div class="breadcrumb-sep">/</div>
|
|
<div class="breadcrumb-item active">${name}</div>
|
|
`;
|
|
btnUpload.disabled = false;
|
|
}
|
|
|
|
// Content
|
|
if (currentPath === 'root') {
|
|
const folders = [
|
|
{ id: 'config', name: 'Configuration', count: allFiles.filter(f => f.type === 'config').length },
|
|
{ id: 'general', name: 'General Audio', count: allFiles.filter(f => f.type === 'general').length },
|
|
{ id: 'station', name: 'Station Audio', count: allFiles.filter(f => f.type === 'station').length }
|
|
];
|
|
|
|
folders.forEach(f => {
|
|
const row = document.createElement('div');
|
|
row.className = 'file-row';
|
|
row.onclick = () => navigate(f.id);
|
|
row.innerHTML = `
|
|
<div></div>
|
|
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
|
|
<div class="file-meta">${f.count} items</div>
|
|
<div></div>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
document.getElementById('select-all').disabled = true;
|
|
} else {
|
|
document.getElementById('select-all').disabled = false;
|
|
const files = allFiles.filter(f => f.type === currentPath);
|
|
|
|
if (files.length === 0) container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">Empty folder</div>';
|
|
|
|
files.forEach(f => {
|
|
const row = document.createElement('div');
|
|
row.className = 'file-row';
|
|
// Click to preview
|
|
row.onclick = (e) => {
|
|
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
|
openPreview(f);
|
|
}
|
|
};
|
|
row.innerHTML = `
|
|
<div style="display:flex;justify-content:center"><input type="checkbox" class="file-check" value="${f.path}" onchange="updateSelection()"></div>
|
|
<div style="display:flex;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${f.name}</span></div>
|
|
<div class="file-meta">${f.type}</div>
|
|
<div style="text-align:right">
|
|
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
|
</div>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
updateSelection();
|
|
}
|
|
|
|
function navigate(path) {
|
|
currentPath = path;
|
|
document.getElementById('select-all').checked = false;
|
|
renderFiles();
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const main = document.getElementById('select-all');
|
|
document.querySelectorAll('.file-check').forEach(c => c.checked = main.checked);
|
|
updateSelection();
|
|
}
|
|
|
|
function updateSelection() {
|
|
const count = document.querySelectorAll('.file-check:checked').length;
|
|
const btn = document.getElementById('btn-delete');
|
|
const span = document.getElementById('selection-count');
|
|
|
|
if (count > 0) {
|
|
btn.disabled = false;
|
|
span.textContent = `${count} selected`;
|
|
} else {
|
|
btn.disabled = true;
|
|
span.textContent = '';
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
async function deleteFile(path) {
|
|
await fetch(`/api/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
|
|
refreshFiles();
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
const checks = document.querySelectorAll('.file-check:checked');
|
|
const btn = document.getElementById('btn-delete');
|
|
btn.textContent = '...';
|
|
for (const c of checks) await deleteFile(c.value);
|
|
btn.textContent = 'Delete';
|
|
refreshFiles();
|
|
}
|
|
|
|
async function deleteStation(name) {
|
|
await fetch(`/api/stations/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
}
|
|
|
|
async function deleteTrain(id) {
|
|
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
}
|
|
|
|
async function deleteStats() {
|
|
if (!confirm('Are you sure you want to clear all network travel time statistics?')) return;
|
|
const res = await fetch('/api/stats', { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.success) showToast('Success', 'Network statistics cleared');
|
|
else showToast('Error', data.message);
|
|
}
|
|
|
|
function triggerUpload() {
|
|
if (currentPath === 'config') document.getElementById('upload-config').click();
|
|
else if (currentPath === 'general') document.getElementById('upload-general').click();
|
|
else if (currentPath === 'station') document.getElementById('upload-station').click();
|
|
}
|
|
|
|
async function performUpload(field, url, input) {
|
|
if (input.files.length === 0) return;
|
|
|
|
const btn = document.getElementById('btn-upload');
|
|
const originalText = btn.textContent;
|
|
const total = input.files.length;
|
|
|
|
btn.disabled = true;
|
|
|
|
const BATCH_SIZE = 5;
|
|
let successCount = 0;
|
|
|
|
const uploadBatch = async (files) => {
|
|
const formData = new FormData();
|
|
for (let f of files) formData.append(field, f);
|
|
try {
|
|
await fetch(url, { method: 'POST', body: formData });
|
|
return files.length;
|
|
} catch (e) { return 0; }
|
|
};
|
|
|
|
if (field === 'listFile') {
|
|
btn.textContent = 'Uploading...';
|
|
const formData = new FormData();
|
|
formData.append(field, input.files[0]);
|
|
try { await fetch(url, { method: 'POST', body: formData }); } catch (e) { }
|
|
} else {
|
|
const fileArray = Array.from(input.files);
|
|
for (let i = 0; i < total; i += BATCH_SIZE) {
|
|
const batch = fileArray.slice(i, i + BATCH_SIZE);
|
|
const progress = Math.min(Math.round(((i + batch.length) / total) * 100), 100);
|
|
btn.textContent = `Uploading ${progress}%`;
|
|
await uploadBatch(batch);
|
|
}
|
|
}
|
|
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
input.value = '';
|
|
refreshFiles();
|
|
}
|
|
|
|
// --- Preview & Edit ---
|
|
let currentPreviewFile = null;
|
|
let audioInstance = null;
|
|
|
|
async function openPreview(file) {
|
|
currentPreviewFile = file;
|
|
const modal = document.getElementById('preview-modal');
|
|
const title = document.getElementById('modal-title');
|
|
const content = document.getElementById('modal-content');
|
|
const saveBtn = document.getElementById('btn-save');
|
|
|
|
title.textContent = file.name;
|
|
content.innerHTML = '<div style="text-align:center; color:var(--text-muted)">Loading...</div>';
|
|
modal.classList.add('active');
|
|
|
|
if (file.name.endsWith('.txt')) {
|
|
saveBtn.style.display = 'block';
|
|
try {
|
|
const res = await fetch(`/api/files/content?path=${encodeURIComponent(file.path)}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
content.innerHTML = `<textarea class="editor" id="file-editor" spellcheck="false">${data.content}</textarea>`;
|
|
} else {
|
|
content.innerHTML = `<div style="color:var(--danger)">Error: ${data.message}</div>`;
|
|
}
|
|
} catch (e) {
|
|
content.innerHTML = `<div style="color:var(--danger)">Failed to load content</div>`;
|
|
}
|
|
} else if (file.name.endsWith('.wav')) {
|
|
saveBtn.style.display = 'none';
|
|
content.innerHTML = `
|
|
<div class="audio-player">
|
|
<button class="play-btn" id="audio-play-btn">${ICONS.play}</button>
|
|
<div class="progress-container" id="audio-progress">
|
|
<div class="progress-bar" id="audio-bar"></div>
|
|
</div>
|
|
<span class="time-display" id="audio-time">0:00 / 0:00</span>
|
|
</div>
|
|
`;
|
|
initAudioPlayer(file.path);
|
|
} else {
|
|
saveBtn.style.display = 'none';
|
|
content.innerHTML = `
|
|
<div style="text-align:center; padding: 40px; color:var(--text-muted)">
|
|
<div>Preview not available for this file type</div>
|
|
${file.name.endsWith('.dfpwm') ? '<div style="margin-top:8px; font-size:0.8rem">DFPWM requires in-game player</div>' : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function initAudioPlayer(path) {
|
|
if (audioInstance) { audioInstance.pause(); audioInstance = null; }
|
|
|
|
const audio = new Audio('/' + path);
|
|
audioInstance = audio;
|
|
|
|
const btn = document.getElementById('audio-play-btn');
|
|
const progress = document.getElementById('audio-progress');
|
|
const bar = document.getElementById('audio-bar');
|
|
const timeDisplay = document.getElementById('audio-time');
|
|
|
|
const formatTime = (s) => {
|
|
const m = Math.floor(s / 60);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
timeDisplay.textContent = `0:00 / ${formatTime(audio.duration)}`;
|
|
});
|
|
|
|
audio.addEventListener('timeupdate', () => {
|
|
if (!audio.duration) return;
|
|
const pct = (audio.currentTime / audio.duration) * 100;
|
|
bar.style.width = `${pct}%`;
|
|
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
|
});
|
|
|
|
audio.addEventListener('ended', () => {
|
|
btn.innerHTML = ICONS.play;
|
|
bar.style.width = '0%';
|
|
});
|
|
|
|
btn.addEventListener('click', () => {
|
|
if (audio.paused) {
|
|
audio.play();
|
|
btn.innerHTML = ICONS.pause;
|
|
} else {
|
|
audio.pause();
|
|
btn.innerHTML = ICONS.play;
|
|
}
|
|
});
|
|
|
|
progress.addEventListener('click', (e) => {
|
|
const rect = progress.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const pct = x / rect.width;
|
|
audio.currentTime = pct * audio.duration;
|
|
});
|
|
}
|
|
|
|
async function saveFile() {
|
|
if (!currentPreviewFile || !currentPreviewFile.name.endsWith('.txt')) return;
|
|
|
|
const editor = document.getElementById('file-editor');
|
|
if (!editor) return;
|
|
|
|
const btn = document.getElementById('btn-save');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Saving...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/files/content', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: currentPreviewFile.path,
|
|
content: editor.value
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
btn.textContent = 'Saved';
|
|
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 1000);
|
|
} else {
|
|
alert('Save failed: ' + data.message);
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
} catch (e) {
|
|
alert('Save error');
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('preview-modal').classList.remove('active');
|
|
if (audioInstance) {
|
|
audioInstance.pause();
|
|
audioInstance = null;
|
|
}
|
|
currentPreviewFile = null;
|
|
}
|
|
|
|
// Init
|
|
refreshFiles();
|
|
fetchVersion();
|
|
|
|
async function fetchVersion() {
|
|
try {
|
|
const res = await fetch('/api/version');
|
|
const data = await res.json();
|
|
if (data.version) {
|
|
document.getElementById('current-version').textContent = data.version;
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('current-version').textContent = 'Error';
|
|
}
|
|
}
|
|
|
|
// --- Resizers ---
|
|
const resizerV = document.getElementById('resizer-v');
|
|
const resizerH = document.getElementById('resizer-h');
|
|
const mainArea = document.getElementById('main-area');
|
|
const body = document.body;
|
|
|
|
// Vertical Resizer (Sidebar width)
|
|
let isResizingV = false;
|
|
resizerV.addEventListener('mousedown', (e) => {
|
|
isResizingV = true;
|
|
document.body.style.cursor = 'col-resize';
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Horizontal Resizer (Console height)
|
|
let isResizingH = false;
|
|
resizerH.addEventListener('mousedown', (e) => {
|
|
isResizingH = true;
|
|
document.body.style.cursor = 'row-resize';
|
|
e.preventDefault();
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (isResizingV) {
|
|
// Constrain width (min 200px, max 600px)
|
|
const newWidth = Math.max(200, Math.min(e.clientX, 600));
|
|
mainArea.style.gridTemplateColumns = `${newWidth}px 4px 1fr`;
|
|
}
|
|
if (isResizingH) {
|
|
// Constrain console height (min 100px, max 80vh)
|
|
const containerHeight = window.innerHeight;
|
|
const newConsoleHeight = containerHeight - e.clientY;
|
|
const clampedHeight = Math.max(100, Math.min(newConsoleHeight, containerHeight - 200));
|
|
body.style.gridTemplateRows = `1fr 4px ${clampedHeight}px`; // Add 4px for splitter
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
if (isResizingV || isResizingH) {
|
|
isResizingV = false;
|
|
isResizingH = false;
|
|
document.body.style.cursor = 'default';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|