Compare commits

..

8 Commits

Author SHA1 Message Date
albert 6c27bb42ae Plik do porównań 2026-05-13 10:52:07 +02:00
albert 244d3ef1fd Revert "Poprawki sterowanie galerią"
This reverts commit ba6c1c78ef.
2026-05-13 10:28:16 +02:00
albert ba6c1c78ef Poprawki sterowanie galerią 2026-05-13 10:23:45 +02:00
albert 1b9c4a766f Dodatkowy panel Galeria ON / OFF / Mute 2026-05-13 09:34:42 +02:00
Marcin Cieślikowski 88cbba01b6 Update dashboard template 2026-05-12 15:39:37 +02:00
Marcin Cieślikowski a17b7aad9d out_of_schedule jako włączone 2026-05-12 13:46:49 +02:00
Marcin Cieślikowski fa79b27158 zmiana na template do uniview 2026-05-12 13:43:31 +02:00
Marcin Cieślikowski dc8fa9b44e zewnętrzne zależności jako lokalne pliki 2026-05-12 13:24:25 +02:00
14 changed files with 4327 additions and 9 deletions
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+26 -9
View File
@@ -491,20 +491,37 @@
return ` return `
<div class="relative bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col gap-4"> <div class="relative bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col gap-4">
<!-- Nagłówek Karty z nowym przyciskiem --> <!-- Nagłówek Karty (Elastyczny pasek) -->
<div class="flex justify-between items-start"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<!-- Lewa strona: Nazwa galerii -->
<div> <div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors" onclick="openPopover('${loc.id}')">${escapeHtml(loc.label)}</h3> <h3 class="text-xl font-bold text-slate-900 dark:text-white cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors" onclick="openPopover('${loc.id}')">${escapeHtml(loc.label)}</h3>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1 font-mono">${loc.id}</div> <div class="text-xs text-slate-500 dark:text-slate-400 mt-1 font-mono">${loc.id}</div>
</div> </div>
<!-- Przycisk SZCZEGÓŁY (zamiast samego statustu 11/15) --> <!-- Prawa strona: Kontrolki Galerii + Przycisk SZCZEGÓŁY -->
<button onclick="openPopover('${loc.id}')" class="flex items-center gap-2 px-3 py-1.5 bg-brand-50 hover:bg-brand-100 dark:bg-brand-900/20 dark:hover:bg-brand-900/40 text-brand-600 dark:text-brand-400 border border-brand-200 dark:border-brand-800/50 rounded-lg text-xs font-bold transition-colors group"> <div class="flex items-center gap-3 self-stretch sm:self-auto flex-wrap">
<span class="w-2 h-2 rounded-full ${dotCls}"></span> <!-- Przyciski całej galerii (Włącz/Wyłącz/Wycisz) -->
<span>${on} / ${total}</span> <div class="flex bg-slate-100 dark:bg-slate-800 p-1.5 rounded-xl border border-slate-200 dark:border-slate-700 shadow-inner gap-1">
<i class="fa fa-search text-brand-400 dark:text-brand-500 ml-1 group-hover:scale-110 transition-transform"></i> <button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'all')" class="px-3 py-1.5 text-emerald-600 hover:bg-white dark:text-emerald-400 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Włącz całą galerię">
<span class="hidden sm:inline">SZCZEGÓŁY</span> <i class="fa fa-play"></i> <span class="hidden xl:inline">Włącz</span>
</button> </button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'all')" class="px-3 py-1.5 text-slate-600 hover:bg-white dark:text-slate-300 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Wyłącz całą galerię">
<i class="fa fa-stop"></i> <span class="hidden xl:inline">Wyłącz</span>
</button>
<button onclick="runCommand('${COMMAND_MUTE}', '${loc.id}', 'all')" class="px-3 py-1.5 text-amber-600 hover:bg-white dark:text-amber-400 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Wycisz całą galerię">
<i class="fa fa-volume-mute"></i> <span class="hidden xl:inline">Wycisz</span>
</button>
</div>
<!-- Przycisk Szczegóły z dymkiem i ilością -->
<button onclick="openPopover('${loc.id}')" class="flex items-center gap-2 px-4 py-2 bg-brand-50 hover:bg-brand-100 dark:bg-brand-900/20 dark:hover:bg-brand-900/40 text-brand-600 dark:text-brand-400 border border-brand-200 dark:border-brand-800/50 rounded-xl text-sm font-bold transition-all group hover:shadow-sm">
<span class="w-2.5 h-2.5 rounded-full ${dotCls}"></span>
<span>${on} / ${total}</span>
<i class="fa fa-search text-brand-400 dark:text-brand-500 ml-1 group-hover:scale-110 transition-transform"></i>
<span class="hidden sm:inline">SZCZEGÓŁY</span>
</button>
</div>
</div> </div>
<!-- Progress bar --> <!-- Progress bar -->
+803
View File
@@ -0,0 +1,803 @@
<script src="assets/vendor/tailwindcss-3.4.17-cdn.js"></script>
<link rel="stylesheet" href="assets/vendor/fontawesome/css/all.min.css">
<script>
tailwind.config = {
darkMode: 'class',
important: '#nscDashboardRoot',
theme: {
extend: {
colors: {
brand: {
50: '#eff4ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
900: '#1e3a8a',
}
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05)',
'glow': '0 0 15px rgba(59, 130, 246, 0.5)',
}
}
}
}
</script>
<style>
#nscDashboardRoot {
all: initial;
display: block;
position: relative;
width: 100%;
min-height: 100%;
padding: 1rem;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
color: #1e293b;
line-height: 1.5;
text-align: initial;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#nscDashboardRoot .nsc-dashboard-bg {
position: absolute;
inset: 0;
z-index: 0;
min-height: 100%;
background-color: #f8fafc;
pointer-events: none;
}
.dark #nscDashboardRoot .nsc-dashboard-bg {
background-color: #0f172a;
}
#nscDashboardRoot .nsc-dashboard-content {
position: relative;
z-index: 1;
width: 100%;
max-width: 1920px;
margin-inline: auto;
}
.dark #nscDashboardRoot {
color: #e2e8f0;
}
#nscDashboardRoot,
#nscDashboardRoot *,
#nscDashboardRoot *::before,
#nscDashboardRoot *::after {
box-sizing: border-box;
}
#nscDashboardRoot button,
#nscDashboardRoot input {
font: inherit;
}
#nscDashboardRoot :where(h1, h2, h3, h4, h5, p, button, input) {
margin: 0;
}
#nscDashboardRoot :where(h1, h2, h3, h4, h5, p) {
padding: 0;
border: 0;
background: transparent;
font-style: normal;
}
#nscDashboardRoot :where(button, input) {
color: inherit;
}
#nscDashboardRoot button {
background: transparent;
cursor: pointer;
}
#nscDashboardRoot input {
box-sizing: border-box !important;
margin: 0 !important;
}
#nscDashboardRoot button {
box-sizing: border-box !important;
margin: 0 !important;
font-size: inherit !important;
line-height: inherit !important;
}
#nscDashboardRoot button {
min-width: 0 !important;
min-height: 0 !important;
border-width: 0;
}
#nscDashboardRoot button.text-xs {
font-size: 0.75rem !important;
line-height: 1rem !important;
}
#nscDashboardRoot button.text-sm {
font-size: 0.875rem !important;
line-height: 1.25rem !important;
}
#nscDashboardRoot button.px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
#nscDashboardRoot button.px-3 {
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
#nscDashboardRoot button.px-4 {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
#nscDashboardRoot button.px-5 {
padding-left: 1.25rem !important;
padding-right: 1.25rem !important;
}
#nscDashboardRoot button.py-1 {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
}
#nscDashboardRoot button.py-1\.5 {
padding-top: 0.375rem !important;
padding-bottom: 0.375rem !important;
}
#nscDashboardRoot button.py-2 {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
#nscDashboardRoot button.py-2\.5 {
padding-top: 0.625rem !important;
padding-bottom: 0.625rem !important;
}
/* Wider, modern macOS-style scrollbar for popovers */
#nscDashboardRoot .custom-scrollbar::-webkit-scrollbar {
width: 12px;
}
#nscDashboardRoot .custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
#nscDashboardRoot .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5); /* Semi-transparent gray */
border: 3px solid transparent; /* Inner border */
background-clip: padding-box;
border-radius: 8px;
}
.dark #nscDashboardRoot .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.6);
}
#nscDashboardRoot .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.8);
border-width: 2px;
}
</style>
<div id="nscDashboardRoot">
<div class="nsc-dashboard-bg" aria-hidden="true"></div>
<div class="nsc-dashboard-content">
<div id="nscTerminalDashboard" class="flex flex-col gap-6">
<!-- The dashboard UI is injected here by JavaScript -->
</div>
<!-- Custom confirm modal replacing window.confirm -->
<div id="customConfirmModal" class="fixed inset-0 z-[999] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div class="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" id="customConfirmOverlay"></div>
<div class="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 w-full max-w-md p-6 transform scale-95 transition-transform duration-300" id="customConfirmBox">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xl flex-shrink-0">
<i class="fa fa-question-circle"></i>
</div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white" id="customConfirmTitle">Potwierdzenie</h3>
</div>
<div class="text-sm text-slate-600 dark:text-slate-300 mb-6 pl-13 font-medium leading-relaxed" id="customConfirmMessage">
Czy na pewno...
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-700">
<button id="customConfirmCancel" class="px-5 py-2.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-600 font-semibold transition-colors">
Anuluj
</button>
<button id="customConfirmOk" class="px-5 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-xl font-semibold transition-colors shadow-md shadow-brand-500/20">
Potwierdź Akcję
</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Initialize dark mode
function initTheme() {
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
initTheme();
// API and state configuration
const API_TERMINALS = "api.php?nav=api_terminal";
const API_COMMAND = "api.php?nav=api_run_remote_command";
const VISIBLE_GROUP_NAME = "Produkcja";
const COMMAND_WAKE = "WakeUp";
const COMMAND_SHUTDOWN = "ShutDown";
const COMMAND_RESTART = "Restart";
const ON_STATUSES = new Set(["running", "out_of_schedule"]);
// Application state
const state = {
terminals: [],
filter: "",
busy: false,
loading: false,
refreshSeconds: 5,
refreshTimerId: null,
openPopoverId: null,
popoverScrollTop: 0
};
function generateMockData() {
const data = [];
let idCounter = 1;
// Gallery 01 mock data: 15 devices (10 PCs, 5 lighting controllers)
for(let i=1; i<=10; i++) {
data.push({ id: idCounter++, name: `PC-GAL01-${i.toString().padStart(2, '0')}`, ip: `192.168.1.${10+i}`, status: Math.random() > 0.2 ? 'Running' : 'Stopped', location_id: 'LOC-1', location_name: 'Galeria 01', group_name: 'Produkcja', player: 'PC' });
}
for(let i=1; i<=5; i++) {
data.push({ id: idCounter++, name: `CTRL-GAL01-${i}`, ip: `192.168.1.${100+i}`, status: Math.random() > 0.1 ? 'Running' : 'Stopped', location_id: 'LOC-1', location_name: 'Galeria 01', group_name: 'Produkcja', player: 'CTRL' });
}
// Galleries 02-10 mock data for grid verification
for (let loc = 2; loc <= 10; loc++) {
const locName = `Galeria ${loc.toString().padStart(2, '0')}`;
const locId = `LOC-${loc}`;
for(let i=1; i<=2; i++) {
data.push({ id: idCounter++, name: `PC-GAL${loc}-${i}`, ip: `192.168.${loc}.${i}`, status: Math.random() > 0.3 ? 'Running' : 'Stopped', location_id: locId, location_name: locName, group_name: 'Produkcja', player: 'PC' });
}
data.push({ id: idCounter++, name: `CTRL-GAL${loc}-1`, ip: `192.168.${loc}.100`, status: Math.random() > 0.2 ? 'Running' : 'Stopped', location_id: locId, location_name: locName, group_name: 'Produkcja', player: 'CTRL' });
}
return data;
}
const MOCK_DATA = generateMockData();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initDashboard);
} else {
initDashboard();
}
function initDashboard() {
renderShell();
setupListeners();
refreshTerminals(false);
restartAutoRefresh();
}
function isTerminalOn(terminal) {
return ON_STATUSES.has(String(terminal?.status || "").toLowerCase());
}
function setupListeners() {
document.getElementById("nscRefreshInterval").addEventListener("change", (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 1) val = 5;
if (val > 60) val = 60;
state.refreshSeconds = val;
e.target.value = val;
restartAutoRefresh();
setStatus(`Odświeżanie co ${val}s.`, false);
});
document.getElementById("nscRefreshButton").addEventListener("click", () => refreshTerminals(false));
document.getElementById("nscTerminalFilter").addEventListener("input", (e) => {
state.filter = e.target.value.toLowerCase().trim();
renderDashboard();
});
}
function restartAutoRefresh() {
if (state.refreshTimerId) clearInterval(state.refreshTimerId);
state.refreshTimerId = setInterval(() => {
if (!state.busy && !state.loading) refreshTerminals(true);
}, state.refreshSeconds * 1000);
}
async function refreshTerminals(silent) {
if (state.loading) return;
state.loading = true;
if (!silent) setStatus("Ładowanie terminali...", false);
try {
// Detect preview mode where the backend API is unavailable
const isPreview = window.location.protocol === 'about:' || window.location.protocol === 'blob:' || window.location.hostname === '';
let data;
if (isPreview) {
await new Promise(r => setTimeout(r, 400)); // Simulate latency
data = JSON.parse(JSON.stringify(MOCK_DATA)); // MOCK data copy
} else {
const res = await fetch(API_TERMINALS);
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
data = await res.json();
}
state.terminals = data.filter(t => (t.group_name || "").toLowerCase() === VISIBLE_GROUP_NAME.toLowerCase());
renderDashboard();
setStatus(`Zaktualizowano: ${new Date().toLocaleTimeString()} (auto co ${state.refreshSeconds}s)`, false);
} catch (error) {
setStatus(`Błąd pobierania: ${error.message}`, true);
console.error(error);
} finally {
state.loading = false;
}
}
function customConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.getElementById('customConfirmModal');
const box = document.getElementById('customConfirmBox');
const overlay = document.getElementById('customConfirmOverlay');
document.getElementById('customConfirmTitle').textContent = title;
document.getElementById('customConfirmMessage').innerHTML = message.replace(/\n/g, '<br>');
modal.classList.remove('hidden');
// Short delay so the browser applies display before animation starts
setTimeout(() => {
modal.classList.remove('opacity-0');
box.classList.remove('scale-95');
box.classList.add('scale-100');
}, 10);
const cleanup = () => {
modal.classList.add('opacity-0');
box.classList.remove('scale-100');
box.classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
document.getElementById('customConfirmCancel').onclick = null;
document.getElementById('customConfirmOk').onclick = null;
};
document.getElementById('customConfirmCancel').onclick = () => { cleanup(); resolve(false); };
document.getElementById('customConfirmOk').onclick = () => { cleanup(); resolve(true); };
});
}
function renderShell() {
const root = document.getElementById("nscTerminalDashboard");
root.innerHTML = `
<!-- Hero Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col xl:flex-row gap-6 justify-between items-start xl:items-center">
<div>
<div class="text-xs font-bold tracking-widest text-brand-600 dark:text-brand-400 uppercase mb-1 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-brand-500 shadow-glow"></span>
POLIN / NSC
</div>
<h1 class="text-2xl md:text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Sterowanie Wystawą</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Grupowe oraz punktowe zarządzanie zasilaniem urządzeń. Grupa: <strong>${VISIBLE_GROUP_NAME}</strong></p>
</div>
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<div class="relative flex-1 xl:w-64 min-w-[200px]">
<i class="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" id="nscTerminalFilter" placeholder="Filtruj..." class="w-full pl-9 pr-4 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500 outline-none text-slate-800 dark:text-slate-200 transition-shadow">
</div>
<div class="flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl text-sm text-slate-600 dark:text-slate-300">
<i class="fa fa-clock text-slate-400"></i>
<span>Auto co</span>
<input type="number" id="nscRefreshInterval" value="5" min="1" max="60" class="w-12 text-center bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-md py-0.5 outline-none font-medium">
<span>s</span>
</div>
<button id="nscRefreshButton" class="px-4 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200 font-semibold rounded-xl hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors shadow-sm">
<i class="fa fa-sync-alt mr-2"></i>Odśwież
</button>
</div>
</div>
<!-- Status Line -->
<div id="nscStatusLine" class="text-xs font-semibold px-4 py-2 rounded-lg inline-flex items-center gap-2 border text-slate-600 bg-slate-100 border-slate-200 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700 transition-colors">
<i class="fa fa-info-circle"></i> Inicjalizacja...
</div>
<div id="nscCommandProgress" class="hidden bg-white dark:bg-slate-800 rounded-xl p-4 shadow-soft border border-slate-200 dark:border-slate-700">
<div class="flex items-center justify-between gap-4 text-xs font-bold mb-2">
<span id="nscCommandProgressLabel" class="text-slate-600 dark:text-slate-300">Wysyłanie poleceń...</span>
<span id="nscCommandProgressCount" class="text-slate-900 dark:text-white">0 / 0</span>
</div>
<div class="h-2.5 w-full bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div id="nscCommandProgressBar" class="h-full bg-brand-500 transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Global summary view -->
<div id="nscExhibitionCard"></div>
<!-- Location grid -->
<div id="nscLocationGrid" class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-6"></div>
`;
}
function setStatus(msg, isError) {
const el = document.getElementById("nscStatusLine");
el.textContent = msg;
el.className = `text-xs font-semibold px-4 py-2 rounded-lg inline-flex items-center gap-2 border transition-colors ${isError ? 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-900/20 dark:border-red-800/50' : 'text-slate-600 bg-slate-100 border-slate-200 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700'}`;
el.innerHTML = `<i class="fa ${isError ? 'fa-exclamation-triangle' : 'fa-check-circle'}"></i> ${msg}`;
}
function setCommandProgress(sent, total, label) {
const progress = document.getElementById("nscCommandProgress");
const progressBar = document.getElementById("nscCommandProgressBar");
const progressCount = document.getElementById("nscCommandProgressCount");
const progressLabel = document.getElementById("nscCommandProgressLabel");
if (!progress || !progressBar || !progressCount || !progressLabel) return;
const pct = total ? Math.round((sent / total) * 100) : 0;
progress.classList.remove("hidden");
progressLabel.textContent = label;
progressCount.textContent = `${sent} / ${total}`;
progressBar.style.width = `${pct}%`;
}
function hideCommandProgress(delayMs = 1200) {
const progress = document.getElementById("nscCommandProgress");
if (!progress) return;
setTimeout(() => progress.classList.add("hidden"), delayMs);
}
function openPopover(locId) {
document.querySelectorAll('[id^="popover-"]').forEach(p => closePopover(p.id.replace('popover-', '')));
state.openPopoverId = locId;
const pop = document.getElementById(`popover-${locId}`);
if(pop) {
pop.classList.remove('opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.add('opacity-100', 'visible', 'scale-100');
}
}
function closePopover(locId) {
if(state.openPopoverId === locId) state.openPopoverId = null;
const pop = document.getElementById(`popover-${locId}`);
if(pop) {
pop.classList.add('opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.remove('opacity-100', 'visible', 'scale-100');
}
}
function renderDashboard() {
// Save the scroll position before rebuilding the open popover DOM
let currentScroll = 0;
if (state.openPopoverId) {
const activeScrollArea = document.querySelector(`#popover-${state.openPopoverId} .custom-scrollbar`);
if (activeScrollArea) currentScroll = activeScrollArea.scrollTop;
}
const locationsMap = new Map();
state.terminals.forEach(t => {
const locId = t.location_id || "unknown";
if (!locationsMap.has(locId)) locationsMap.set(locId, { id: locId, label: t.location_name || locId, terminals: [] });
locationsMap.get(locId).terminals.push(t);
});
const allLocations = Array.from(locationsMap.values()).sort((a,b) => String(a.label).localeCompare(String(b.label), 'pl', {numeric: true}));
let visibleLocations = allLocations;
// Filtering
if(state.filter) {
visibleLocations = allLocations.filter(loc => {
const matchLoc = loc.label.toLowerCase().includes(state.filter);
const matchTerminals = loc.terminals.some(t =>
(t.name || "").toLowerCase().includes(state.filter) ||
(t.ip || "").includes(state.filter)
);
return matchLoc || matchTerminals;
});
}
renderGlobalExhibition(allLocations, visibleLocations);
const grid = document.getElementById("nscLocationGrid");
grid.innerHTML = visibleLocations.map(renderLocationCard).join("");
// Restore the open popover and scroll position after rebuilding the HTML
if (state.openPopoverId) {
const pop = document.getElementById(`popover-${state.openPopoverId}`);
if (pop) {
// Temporarily remove animation classes to avoid reanimating every 5 seconds
pop.classList.remove('transition-all', 'duration-300', 'opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.add('opacity-100', 'visible', 'scale-100');
const scrollArea = pop.querySelector('.custom-scrollbar');
if (scrollArea) scrollArea.scrollTop = currentScroll; // Restore scroll position
// Restore animations shortly after rendering
setTimeout(() => {
pop.classList.add('transition-all', 'duration-300');
}, 50);
} else {
// Clear state if filtering removed the popover from the DOM
state.openPopoverId = null;
}
}
}
function renderGlobalExhibition(allLocations, visibleLocations) {
const container = document.getElementById("nscExhibitionCard");
const allT = state.terminals;
const onT = allT.filter(isTerminalOn).length;
const total = allT.length;
const pct = total ? Math.round((onT/total)*100) : 0;
const visibleLocationCount = visibleLocations.length;
const totalLocationCount = allLocations.length;
const locationSummary = state.filter
? `Lokalizacje: ${visibleLocationCount} / ${totalLocationCount}`
: `Lokalizacje: ${totalLocationCount}`;
let colorCls = "bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700";
let fillCls = "bg-slate-400";
if(total > 0 && onT === total) { colorCls = "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/50"; fillCls = "bg-emerald-500"; }
else if(onT > 0) { colorCls = "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50"; fillCls = "bg-amber-500"; }
container.innerHTML = `
<div class="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col md:flex-row gap-6 items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl font-bold shadow-inner ${colorCls}">
<i class="fa ${onT === total && total>0 ? 'fa-check' : 'fa-globe'}"></i>
</div>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">Wszystkie Lokalizacje</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Zarządzanie całą wystawą (${locationSummary})</p>
</div>
</div>
<div class="flex-1 max-w-md w-full">
<div class="flex justify-between text-xs font-bold mb-2">
<span class="text-slate-500 dark:text-slate-400 uppercase tracking-wider">Uruchomione</span>
<span class="text-slate-900 dark:text-white">${onT} / ${total}</span>
</div>
<div class="h-2.5 w-full bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full ${fillCls} transition-all duration-500" style="width: ${pct}%"></div>
</div>
</div>
<div class="flex gap-2">
<button onclick="runGlobalCommand('${COMMAND_WAKE}')" class="px-5 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-bold text-sm shadow-md transition-colors"><i class="fa fa-play mr-2"></i>Włącz Całość</button>
<button onclick="runGlobalCommand('${COMMAND_SHUTDOWN}')" class="px-5 py-2.5 bg-slate-600 hover:bg-slate-700 text-white rounded-xl font-bold text-sm shadow-md transition-colors"><i class="fa fa-stop mr-2"></i>Wyłącz Całość</button>
</div>
</div>
`;
}
function renderLocationCard(loc) {
const total = loc.terminals.length;
const on = loc.terminals.filter(isTerminalOn).length;
const pct = total ? Math.round((on/total)*100) : 0;
const pcs = loc.terminals.filter(t => t.player === 'UVP');
const onPcs = pcs.filter(isTerminalOn).length;
const lights = loc.terminals.filter(t => t.player === 'CTRL');
const onLights = lights.filter(isTerminalOn).length;
let dotCls = "bg-slate-400";
if(total > 0 && on === total) dotCls = "bg-emerald-500 shadow-glow";
else if(on > 0) dotCls = "bg-amber-500";
return `
<div class="relative bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col gap-4">
<!-- Card header with the details button -->
<div class="flex justify-between items-start">
<div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors" onclick="openPopover('${loc.id}')">${escapeHtml(loc.label)}</h3>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1 font-mono">${loc.id}</div>
</div>
<!-- Details button with status count -->
<button onclick="openPopover('${loc.id}')" class="flex items-center gap-2 px-3 py-1.5 bg-brand-50 hover:bg-brand-100 dark:bg-brand-900/20 dark:hover:bg-brand-900/40 text-brand-600 dark:text-brand-400 border border-brand-200 dark:border-brand-800/50 rounded-lg text-xs font-bold transition-colors group">
<span class="w-2 h-2 rounded-full ${dotCls}"></span>
<span>${on} / ${total}</span>
<i class="fa fa-search text-brand-400 dark:text-brand-500 ml-1 group-hover:scale-110 transition-transform"></i>
<span class="hidden sm:inline">SZCZEGÓŁY</span>
</button>
</div>
<!-- Progress bar -->
<div class="h-1.5 w-full bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full ${dotCls} transition-all duration-500" style="width: ${pct}%"></div>
</div>
<!-- Controllers section on the card -->
<div class="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-3 border border-slate-100 dark:border-slate-700 flex flex-col xl:flex-row justify-between items-start xl:items-center gap-3">
<div class="flex items-center gap-3 cursor-pointer" onclick="openPopover('${loc.id}')">
<div class="w-8 h-8 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center"><i class="fa fa-desktop"></i></div>
<div>
<div class="text-sm font-bold text-slate-800 dark:text-slate-200">Kontrolery</div>
<div class="text-xs text-slate-500 dark:text-slate-400">${onPcs} z ${pcs.length} włączonych</div>
</div>
</div>
<div class="flex gap-1.5">
<button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-emerald-100 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 text-emerald-700 dark:text-emerald-400 rounded-md text-xs font-bold transition-colors">Włącz</button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-md text-xs font-bold transition-colors">Wyłącz</button>
<button onclick="runCommand('${COMMAND_RESTART}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-400 rounded-md text-xs font-bold transition-colors" title="Restart"><i class="fa fa-redo"></i></button>
</div>
</div>
<!-- Lighting section on the card -->
<div class="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-3 border border-slate-100 dark:border-slate-700 flex flex-col xl:flex-row justify-between items-start xl:items-center gap-3">
<div class="flex items-center gap-3 cursor-pointer" onclick="openPopover('${loc.id}')">
<div class="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center"><i class="fa fa-lightbulb"></i></div>
<div>
<div class="text-sm font-bold text-slate-800 dark:text-slate-200">Oświetlenie</div>
<div class="text-xs text-slate-500 dark:text-slate-400">${onLights} z ${lights.length} włączonych</div>
</div>
</div>
<div class="flex gap-1.5">
<button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'ctrl')" class="px-3 py-1.5 bg-emerald-100 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 text-emerald-700 dark:text-emerald-400 rounded-md text-xs font-bold transition-colors">Włącz</button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'ctrl')" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-md text-xs font-bold transition-colors">Wyłącz</button>
</div>
</div>
<!-- Popover centered over the card and matched to the card size in the grid -->
<div id="popover-${loc.id}" class="absolute inset-0 z-50 flex opacity-0 invisible scale-95 transition-all duration-300 pointer-events-none">
<!-- Popover panel filling the card frame -->
<div class="relative w-full h-full flex flex-col bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 pointer-events-auto overflow-hidden">
<!-- Popover header -->
<div class="flex items-center justify-between p-4 border-b border-slate-100 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50 shrink-0">
<h4 class="font-bold text-slate-800 dark:text-slate-200 text-lg flex items-center gap-2">
<i class="fa fa-list text-brand-500"></i> Szczegóły: ${escapeHtml(loc.label)}
</h4>
<button onclick="closePopover('${loc.id}')" class="w-8 h-8 flex items-center justify-center rounded-full bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-600 dark:text-slate-300 transition-colors">
<i class="fa fa-times"></i>
</button>
</div>
<!-- Scrollable area -->
<div class="custom-scrollbar overflow-y-auto p-4 flex flex-col gap-4 flex-1">
${renderTerminalRows(pcs, 'Kontrolery (PC)', true)}
${renderTerminalRows(lights, 'Oświetlenie', false)}
</div>
</div>
</div>
</div>
`;
}
function renderTerminalRows(terminals, title, allowRestart) {
if(!terminals.length) return '';
const rows = terminals.map(t => {
const isRun = isTerminalOn(t);
const statusCls = isRun ? "text-emerald-500" : "text-slate-400 dark:text-slate-500";
const dotCls = isRun ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600";
// Individual action buttons
let actions = `
<button onclick="runSingleCommand('${COMMAND_WAKE}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40 rounded text-xs font-bold transition-colors">Włącz</button>
<button onclick="runSingleCommand('${COMMAND_SHUTDOWN}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 rounded text-xs font-bold transition-colors">Wyłącz</button>
`;
if(allowRestart) {
actions += `<button onclick="runSingleCommand('${COMMAND_RESTART}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40 rounded text-xs font-bold transition-colors" title="Restart"><i class="fa fa-redo"></i></button>`;
}
return `
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-2 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded-lg transition-colors border border-transparent hover:border-slate-100 dark:hover:border-slate-700">
<div class="flex items-center gap-3 overflow-hidden">
<span class="w-2 h-2 rounded-full flex-shrink-0 ${dotCls}"></span>
<div class="truncate">
<div class="text-sm font-semibold text-slate-800 dark:text-slate-200 truncate" title="${escapeHtml(t.name)}">${escapeHtml(t.name || `ID ${t.id}`)}</div>
<div class="text-xs font-mono text-slate-500 dark:text-slate-400 flex gap-2">
<span>${t.ip || '-'}</span>
<span class="${statusCls}">${t.status || 'Brak'}</span>
</div>
</div>
</div>
<div class="flex gap-1 flex-shrink-0 sm:ml-auto">
${actions}
</div>
</div>
`;
}).join('');
return `
<div>
<h5 class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2 pl-2">${title}</h5>
<div class="flex flex-col gap-1">
${rows}
</div>
</div>
`;
}
async function runGlobalCommand(command) {
const label = command === COMMAND_WAKE ? "Włącz" : "Wyłącz";
const confirmed = await customConfirm(
`Masowe wykonanie: ${label}`,
`Czy na pewno chcesz wykonać operację <strong>${label}</strong> na CAŁEJ wystawie (grupa: ${VISIBLE_GROUP_NAME})?\nDotyczy to wszystkich ${state.terminals.length} urządzeń.`
);
if(confirmed) executeCommand(command, 'all', 'all');
}
async function runCommand(command, locationId, type) {
const locName = state.terminals.find(t => t.location_id === locationId)?.location_name || locationId;
const typeName = type === 'computer' ? 'Kontrolery' : 'Oświetlenie';
const actionName = command === COMMAND_WAKE ? "Włączenie" : (command === COMMAND_SHUTDOWN ? "Wyłączenie" : "Restart");
const confirmed = await customConfirm(
`Akcja grupowa: ${actionName}`,
`Lokalizacja: <strong>${locName}</strong>\nUrządzenia: <strong>${typeName}</strong>\n\nCzy na pewno chcesz kontynuować?`
);
if(confirmed) executeCommand(command, locationId, type);
}
async function runSingleCommand(command, terminalId, terminalName) {
const actionName = command === COMMAND_WAKE ? "Włączenie" : (command === COMMAND_SHUTDOWN ? "Wyłączenie" : "Restart");
const terminal = state.terminals.find(t => String(t.id) === String(terminalId));
const confirmed = await customConfirm(
`Pojedyncza Akcja: ${actionName}`,
`Urządzenie: <strong>${terminalName}</strong>\nIP: ${terminal?.ip || 'Brak IP'}\n\nCzy na pewno wysłać polecenie?`
);
if(confirmed) {
if(terminal) executeCommandBulk(command, [terminal], terminalName);
}
}
async function executeCommand(command, locationId, type) {
const targets = state.terminals.filter(t => {
if(locationId !== 'all' && t.location_id !== locationId) return false;
if(type !== 'all' && (type === 'computer' ? t.player !== 'UVP' : t.player !== 'CTRL')) return false;
return true;
});
if(!targets.length) return setStatus("Brak urządzeń spełniających kryteria.", true);
executeCommandBulk(command, targets, "Grupa urządzeń");
}
async function executeCommandBulk(command, targets, scopeLabel) {
state.busy = true;
setStatus(`Wysyłanie polecenia ${command} do ${targets.length} urządzeń (${scopeLabel})...`, false);
setCommandProgress(0, targets.length, `Wysłano polecenie do 0 z ${targets.length} urządzeń`);
try {
// Preview mode skips FETCH requests
const isPreview = window.location.protocol === 'about:' || window.location.protocol === 'blob:' || window.location.hostname === '';
let failedCount = 0;
let sentCount = 0;
for(const t of targets) {
if (isPreview) {
await new Promise(r => setTimeout(r, 100)); // Simulate request
const tRef = MOCK_DATA.find(m => m.id === t.id);
if(tRef) tRef.status = command === COMMAND_SHUTDOWN ? 'Stopped' : 'Running';
} else {
// Production request
try {
const body = new URLSearchParams();
body.set("command", command);
const url = `${API_COMMAND}&terminal_id=${encodeURIComponent(t.id)}`;
const res = await fetch(url, { method: "POST", body: body });
if(!res.ok) failedCount++;
} catch(e) {
failedCount++;
}
}
sentCount++;
setCommandProgress(sentCount, targets.length, `Wysłano polecenie do ${sentCount} z ${targets.length} urządzeń`);
}
if(failedCount > 0) setStatus(`Zakończono z błędami. Niepowodzenia: ${failedCount}/${targets.length}`, true);
else setStatus(`Polecenie wykonane pomyślnie dla ${targets.length} urządzeń.`, false);
hideCommandProgress();
setTimeout(() => refreshTerminals(true), 1500);
} finally {
state.busy = false;
}
}
function escapeHtml(str) {
return String(str||"").replace(/[&<>"']/g, m => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[m]);
}
</script>
+744
View File
@@ -0,0 +1,744 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UniView WebAdmin - Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#eff4ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
900: '#1e3a8a',
}
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05)',
'glow': '0 0 15px rgba(59, 130, 246, 0.5)',
}
}
}
}
</script>
<style>
/* Ukrycie paska przewijania w menu poziomym */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Szerszy, nowoczesny pasek w Popoverze w stylu macOS */
.custom-scrollbar::-webkit-scrollbar {
width: 12px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5); /* Szary, półprzezroczysty */
border: 3px solid transparent; /* Wewnętrzne obramowanie */
background-clip: padding-box;
border-radius: 8px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.6);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.8);
border-width: 2px;
}
/* Ochrona przed skakaniem interfejsu */
body { overflow-y: scroll; }
</style>
</head>
<body class="bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-200 transition-colors duration-300 font-sans antialiased min-h-screen flex flex-col">
<header id="layout_header" class="sticky top-0 z-[100] bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 shadow-sm">
<div class="px-4 py-2 flex flex-col gap-2">
<!-- Górny Rząd: Logo, Menu Główne, Sesja -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-6 overflow-hidden flex-1">
<!-- Logo -->
<div class="flex-shrink-0">
<img width="135" height="30" alt="UniView WebAdmin" src="https://placehold.co/135x30/2563eb/ffffff?text=UniView+Logo" class="rounded rounded-md shadow-sm">
</div>
<!-- Pełne Menu Główne (10 pozycji) -->
<ul class="flex items-center gap-1 overflow-x-auto no-scrollbar flex-1 pb-1">
<li><a href="#" class="px-3 py-1.5 text-sm font-semibold rounded-full bg-brand-500 text-white shadow-md shadow-brand-500/20 whitespace-nowrap">Dashboard</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Programy</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Kontrolery</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Lokalizacje</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Pliki</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Dane</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Raportowanie</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Usterki</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Administracja</a></li>
<li><a href="#" class="px-3 py-1.5 text-sm font-medium rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors whitespace-nowrap">Zaawansowane menu</a></li>
</ul>
</div>
<!-- Panel Użytkownika -->
<div class="flex items-center gap-3 flex-shrink-0">
<div class="hidden md:flex items-center gap-3 text-xs font-medium text-slate-500 dark:text-slate-400">
<span class="px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded-md border border-slate-200 dark:border-slate-700">POLIN</span>
<a href="#" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors"><i class="fa fa-user mr-1"></i> pcs.agoral</a>
</div>
<!-- Dark Mode Toggle -->
<button id="darkModeToggle" class="w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" title="Przełącz motyw">
<i class="fa fa-moon dark:hidden"></i>
<i class="fa fa-sun hidden dark:block text-amber-400"></i>
</button>
<button class="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900 rounded-full hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors">
<i class="fa fa-power-off"></i> Wyloguj
</button>
</div>
</div>
<!-- Dolny Rząd: Submenu (Tylko 2 elementy) -->
<ul class="flex items-center gap-2 overflow-x-auto no-scrollbar pt-1 border-t border-slate-100 dark:border-slate-800/50">
<li><a href="#" class="px-3 py-1 text-xs font-medium rounded-md text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">node-red</a></li>
<li><a href="#" class="px-3 py-1 text-xs font-semibold rounded-md bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200 shadow-sm border border-slate-200/50 dark:border-slate-700">Dashboard Sterowania</a></li>
</ul>
</div>
</header>
<main id="layout_content" class="flex-1 p-4 md:p-6 w-full max-w-[1920px] mx-auto">
<div id="nscTerminalDashboard" class="flex flex-col gap-6">
<!-- Tutaj JS wstrzyknie interfejs Dashboardu -->
</div>
</main>
<footer id="layout_footer" class="p-4 text-center text-xs text-slate-400 dark:text-slate-500 border-t border-slate-200 dark:border-slate-800">
<p>© Union Systems 2000 Sp. z o.o.</p>
</footer>
<!-- Modale Confirm (zastępuje window.confirm) -->
<div id="customConfirmModal" class="fixed inset-0 z-[999] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div class="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" id="customConfirmOverlay"></div>
<div class="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 w-full max-w-md p-6 transform scale-95 transition-transform duration-300" id="customConfirmBox">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xl flex-shrink-0">
<i class="fa fa-question-circle"></i>
</div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white" id="customConfirmTitle">Potwierdzenie</h3>
</div>
<div class="text-sm text-slate-600 dark:text-slate-300 mb-6 pl-13 font-medium leading-relaxed" id="customConfirmMessage">
Czy na pewno...
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-700">
<button id="customConfirmCancel" class="px-5 py-2.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-600 font-semibold transition-colors">
Anuluj
</button>
<button id="customConfirmOk" class="px-5 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-xl font-semibold transition-colors shadow-md shadow-brand-500/20">
Potwierdź Akcję
</button>
</div>
</div>
</div>
<script>
// Inicjalizacja Dark Mode
function initTheme() {
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
initTheme();
document.getElementById('darkModeToggle').addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
});
// Konfiguracja API i Stanu
const API_TERMINALS = "api.php?nav=api_terminal";
const API_COMMAND = "api.php?nav=api_run_remote_command";
const VISIBLE_GROUP_NAME = "Produkcja";
// GŁÓWNE KOMENDY (Zmiana: 2 nowe komendy Audio)
const COMMAND_WAKE = "WakeUp";
const COMMAND_SHUTDOWN = "ShutDown";
const COMMAND_RESTART = "Restart";
const COMMAND_AUDIO_ON = "AudioOn";
const COMMAND_AUDIO_OFF = "AudioOff";
// Stan aplikacji
const state = {
terminals: [],
filter: "",
busy: false,
loading: false,
refreshSeconds: 5,
refreshTimerId: null,
openPopoverId: null,
popoverScrollTop: 0
};
function generateMockData() {
const data = [];
let idCounter = 1;
// Galeria 01 - Zgodnie z prośbą 15 urządzeń (10 PC, 5 Oświetlenie)
for(let i=1; i<=10; i++) {
data.push({ id: idCounter++, name: `PC-GAL01-${i.toString().padStart(2, '0')}`, ip: `192.168.1.${10+i}`, status: Math.random() > 0.2 ? 'Running' : 'Stopped', location_id: 'LOC-1', location_name: 'Galeria 01', group_name: 'Produkcja', player: 'PC' });
}
for(let i=1; i<=5; i++) {
data.push({ id: idCounter++, name: `CTRL-GAL01-${i}`, ip: `192.168.1.${100+i}`, status: Math.random() > 0.1 ? 'Running' : 'Stopped', location_id: 'LOC-1', location_name: 'Galeria 01', group_name: 'Produkcja', player: 'CTRL' });
}
// Galerie 02-10 (standardowa ilość do weryfikacji siatki)
for (let loc = 2; loc <= 10; loc++) {
const locName = `Galeria ${loc.toString().padStart(2, '0')}`;
const locId = `LOC-${loc}`;
for(let i=1; i<=2; i++) {
data.push({ id: idCounter++, name: `PC-GAL${loc}-${i}`, ip: `192.168.${loc}.${i}`, status: Math.random() > 0.3 ? 'Running' : 'Stopped', location_id: locId, location_name: locName, group_name: 'Produkcja', player: 'PC' });
}
data.push({ id: idCounter++, name: `CTRL-GAL${loc}-1`, ip: `192.168.${loc}.100`, status: Math.random() > 0.2 ? 'Running' : 'Stopped', location_id: locId, location_name: locName, group_name: 'Produkcja', player: 'CTRL' });
}
return data;
}
const MOCK_DATA = generateMockData();
document.addEventListener("DOMContentLoaded", initDashboard);
function initDashboard() {
renderShell();
setupListeners();
refreshTerminals(false);
restartAutoRefresh();
}
function setupListeners() {
document.getElementById("nscRefreshInterval").addEventListener("change", (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 1) val = 5;
if (val > 60) val = 60;
state.refreshSeconds = val;
e.target.value = val;
restartAutoRefresh();
setStatus(`Odświeżanie co ${val}s.`, false);
});
document.getElementById("nscRefreshButton").addEventListener("click", () => refreshTerminals(false));
document.getElementById("nscTerminalFilter").addEventListener("input", (e) => {
state.filter = e.target.value.toLowerCase().trim();
renderDashboard();
});
}
function restartAutoRefresh() {
if (state.refreshTimerId) clearInterval(state.refreshTimerId);
state.refreshTimerId = setInterval(() => {
if (!state.busy && !state.loading) refreshTerminals(true);
}, state.refreshSeconds * 1000);
}
async function refreshTerminals(silent) {
if (state.loading) return;
state.loading = true;
if (!silent) setStatus("Ładowanie terminali...", false);
try {
// Wykrywanie Trybu "Preview" (gdzie nie ma backendu API)
const isPreview = window.location.protocol === 'about:' || window.location.protocol === 'blob:' || window.location.hostname === '';
let data;
if (isPreview) {
await new Promise(r => setTimeout(r, 400)); // Symulacja opóźnienia
data = JSON.parse(JSON.stringify(MOCK_DATA)); // Kopia MOCK
} else {
const res = await fetch(API_TERMINALS);
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
data = await res.json();
}
state.terminals = data.filter(t => (t.group_name || "").toLowerCase() === VISIBLE_GROUP_NAME.toLowerCase());
renderDashboard();
setStatus(`Zaktualizowano: ${new Date().toLocaleTimeString()} (auto co ${state.refreshSeconds}s)`, false);
} catch (error) {
setStatus(`Błąd pobierania: ${error.message}`, true);
console.error(error);
} finally {
state.loading = false;
}
}
function customConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.getElementById('customConfirmModal');
const box = document.getElementById('customConfirmBox');
const overlay = document.getElementById('customConfirmOverlay');
document.getElementById('customConfirmTitle').textContent = title;
document.getElementById('customConfirmMessage').innerHTML = message.replace(/\n/g, '<br>');
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.remove('opacity-0');
box.classList.remove('scale-95');
box.classList.add('scale-100');
}, 10);
const cleanup = () => {
modal.classList.add('opacity-0');
box.classList.remove('scale-100');
box.classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
document.getElementById('customConfirmCancel').onclick = null;
document.getElementById('customConfirmOk').onclick = null;
};
document.getElementById('customConfirmCancel').onclick = () => { cleanup(); resolve(false); };
document.getElementById('customConfirmOk').onclick = () => { cleanup(); resolve(true); };
});
}
function renderShell() {
const root = document.getElementById("nscTerminalDashboard");
root.innerHTML = `
<!-- Hero Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col xl:flex-row gap-6 justify-between items-start xl:items-center">
<div>
<div class="text-xs font-bold tracking-widest text-brand-600 dark:text-brand-400 uppercase mb-1 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-brand-500 shadow-glow"></span>
POLIN / NSC
</div>
<h1 class="text-2xl md:text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Sterowanie Wystawą</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Grupowe oraz punktowe zarządzanie zasilaniem urządzeń. Grupa: <strong>${VISIBLE_GROUP_NAME}</strong></p>
</div>
<!-- Pasek Narzędzi -->
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<div class="relative flex-1 xl:w-64 min-w-[200px]">
<i class="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" id="nscTerminalFilter" placeholder="Filtruj..." class="w-full pl-9 pr-4 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500 outline-none text-slate-800 dark:text-slate-200 transition-shadow">
</div>
<div class="flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl text-sm text-slate-600 dark:text-slate-300">
<i class="fa fa-clock text-slate-400"></i>
<span>Auto co</span>
<input type="number" id="nscRefreshInterval" value="5" min="1" max="60" class="w-12 text-center bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-md py-0.5 outline-none font-medium">
<span>s</span>
</div>
<button id="nscRefreshButton" class="px-4 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200 font-semibold rounded-xl hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors shadow-sm">
<i class="fa fa-sync-alt mr-2"></i>Odśwież
</button>
</div>
</div>
<!-- Status Line -->
<div id="nscStatusLine" class="text-xs font-semibold px-4 py-2 rounded-lg inline-flex items-center gap-2 border text-slate-600 bg-slate-100 border-slate-200 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700 transition-colors">
<i class="fa fa-info-circle"></i> Inicjalizacja...
</div>
<!-- Widok Globalny (Podsumowanie) -->
<div id="nscExhibitionCard"></div>
<!-- Siatka Lokalizacji -->
<div id="nscLocationGrid" class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-6"></div>
`;
}
function setStatus(msg, isError) {
const el = document.getElementById("nscStatusLine");
el.textContent = msg;
el.className = `text-xs font-semibold px-4 py-2 rounded-lg inline-flex items-center gap-2 border transition-colors ${isError ? 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-900/20 dark:border-red-800/50' : 'text-slate-600 bg-slate-100 border-slate-200 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700'}`;
el.innerHTML = `<i class="fa ${isError ? 'fa-exclamation-triangle' : 'fa-check-circle'}"></i> ${msg}`;
}
function openPopover(locId) {
document.querySelectorAll('[id^="popover-"]').forEach(p => closePopover(p.id.replace('popover-', '')));
state.openPopoverId = locId;
const pop = document.getElementById(`popover-${locId}`);
if(pop) {
pop.classList.remove('opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.add('opacity-100', 'visible', 'scale-100');
}
}
function closePopover(locId) {
if(state.openPopoverId === locId) state.openPopoverId = null;
const pop = document.getElementById(`popover-${locId}`);
if(pop) {
pop.classList.add('opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.remove('opacity-100', 'visible', 'scale-100');
}
}
function renderDashboard() {
// ZAPIS: Przed przebudową DOM zapamiętujemy scroll w otwartym dymku
let currentScroll = 0;
if (state.openPopoverId) {
const activeScrollArea = document.querySelector(`#popover-${state.openPopoverId} .custom-scrollbar`);
if (activeScrollArea) currentScroll = activeScrollArea.scrollTop;
}
const locationsMap = new Map();
state.terminals.forEach(t => {
const locId = t.location_id || "unknown";
if (!locationsMap.has(locId)) locationsMap.set(locId, { id: locId, label: t.location_name || locId, terminals: [] });
locationsMap.get(locId).terminals.push(t);
});
let locations = Array.from(locationsMap.values()).sort((a,b) => String(a.label).localeCompare(String(b.label), 'pl', {numeric: true}));
// Filtrowanie
if(state.filter) {
locations = locations.filter(loc => {
const matchLoc = loc.label.toLowerCase().includes(state.filter);
const matchTerminals = loc.terminals.some(t =>
(t.name || "").toLowerCase().includes(state.filter) ||
(t.ip || "").includes(state.filter)
);
return matchLoc || matchTerminals;
});
}
renderGlobalExhibition(locations);
const grid = document.getElementById("nscLocationGrid");
grid.innerHTML = locations.map(renderLocationCard).join("");
// ODTWORZENIE: Po zbudowaniu HTML przywracamy otwarty dymek i pozycję scrolla
if (state.openPopoverId) {
const pop = document.getElementById(`popover-${state.openPopoverId}`);
if (pop) {
// Usuwamy klasy animacji tymczasowo, by nie "wjeżdżał" co 5 sekund
pop.classList.remove('transition-all', 'duration-300', 'opacity-0', 'invisible', 'scale-95', 'pointer-events-none');
pop.classList.add('opacity-100', 'visible', 'scale-100');
const scrollArea = pop.querySelector('.custom-scrollbar');
if (scrollArea) scrollArea.scrollTop = currentScroll; // Przywracamy przewijanie!
// Zwracamy animacje po ułamku sekundy
setTimeout(() => {
pop.classList.add('transition-all', 'duration-300');
}, 50);
} else {
// Jeśli po filtracji dymku nie ma w DOM, czyścimy stan
state.openPopoverId = null;
}
}
}
function renderGlobalExhibition(locations) {
const container = document.getElementById("nscExhibitionCard");
const allT = state.terminals;
const onT = allT.filter(t => t.status?.toLowerCase() === 'running').length;
const total = allT.length;
const pct = total ? Math.round((onT/total)*100) : 0;
let colorCls = "bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700";
let fillCls = "bg-slate-400";
if(total > 0 && onT === total) { colorCls = "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/50"; fillCls = "bg-emerald-500"; }
else if(onT > 0) { colorCls = "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50"; fillCls = "bg-amber-500"; }
container.innerHTML = `
<div class="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col md:flex-row gap-6 items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl font-bold shadow-inner ${colorCls}">
<i class="fa ${onT === total && total>0 ? 'fa-check' : 'fa-globe'}"></i>
</div>
<div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">Wszystkie Lokalizacje</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Zarządzanie całą wystawą (Galerie: ${locations.length})</p>
</div>
</div>
<div class="flex-1 max-w-md w-full">
<div class="flex justify-between text-xs font-bold mb-2">
<span class="text-slate-500 dark:text-slate-400 uppercase tracking-wider">Uruchomione</span>
<span class="text-slate-900 dark:text-white">${onT} / ${total}</span>
</div>
<div class="h-2.5 w-full bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full ${fillCls} transition-all duration-500" style="width: ${pct}%"></div>
</div>
</div>
<div class="flex gap-2">
<button onclick="runGlobalCommand('${COMMAND_WAKE}')" class="px-5 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-bold text-sm shadow-md transition-colors"><i class="fa fa-play mr-2"></i>Włącz Całość</button>
<button onclick="runGlobalCommand('${COMMAND_SHUTDOWN}')" class="px-5 py-2.5 bg-slate-600 hover:bg-slate-700 text-white rounded-xl font-bold text-sm shadow-md transition-colors"><i class="fa fa-stop mr-2"></i>Wyłącz Całość</button>
</div>
</div>
`;
}
function renderLocationCard(loc) {
const total = loc.terminals.length;
const on = loc.terminals.filter(t => t.status?.toLowerCase() === 'running').length;
const pct = total ? Math.round((on/total)*100) : 0;
const pcs = loc.terminals.filter(t => t.player === 'PC');
const onPcs = pcs.filter(t => t.status?.toLowerCase() === 'running').length;
const lights = loc.terminals.filter(t => t.player === 'CTRL');
const onLights = lights.filter(t => t.status?.toLowerCase() === 'running').length;
let dotCls = "bg-slate-400";
if(total > 0 && on === total) dotCls = "bg-emerald-500 shadow-glow";
else if(on > 0) dotCls = "bg-amber-500";
return `
<div class="relative bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-soft border border-slate-200 dark:border-slate-700 flex flex-col gap-4">
<!-- Nagłówek Karty z wbudowanym panelem przycisków -->
<div class="flex flex-col sm:flex-row justify-between items-start gap-3 w-full">
<!-- Lewa strona: Nazwa galerii (Brak ID!) -->
<div class="flex-shrink-0">
<h3 class="text-xl font-bold text-slate-900 dark:text-white cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors whitespace-nowrap" onclick="openPopover('${loc.id}')">${escapeHtml(loc.label)}</h3>
</div>
<!-- Prawa strona: 4 Kontrolki + Przycisk SZCZEGÓŁY -->
<div class="flex items-center justify-end gap-2 sm:gap-3 flex-1 flex-wrap">
<!-- Przyciski całej galerii (Włącz/Wyłącz/Audio ON/Audio OFF) -->
<div class="flex bg-slate-100 dark:bg-slate-800 p-1.5 rounded-xl border border-slate-200 dark:border-slate-700 shadow-inner gap-1">
<button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'all')" class="px-3 py-1.5 text-emerald-600 hover:bg-white dark:text-emerald-400 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Włącz całą galerię">
<i class="fa fa-play"></i> <span class="hidden xl:inline">Włącz</span>
</button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'all')" class="px-3 py-1.5 text-slate-600 hover:bg-white dark:text-slate-300 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Wyłącz całą galerię">
<i class="fa fa-stop"></i> <span class="hidden xl:inline">Wyłącz</span>
</button>
<button onclick="runCommand('${COMMAND_AUDIO_ON}', '${loc.id}', 'all')" class="px-3 py-1.5 text-sky-600 hover:bg-white dark:text-sky-400 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Włącz Audio w galerii">
<i class="fa fa-volume-up"></i> <span class="hidden xl:inline">Audio ON</span>
</button>
<button onclick="runCommand('${COMMAND_AUDIO_OFF}', '${loc.id}', 'all')" class="px-3 py-1.5 text-amber-600 hover:bg-white dark:text-amber-400 dark:hover:bg-slate-700 hover:shadow-sm rounded-lg text-sm font-bold transition-all flex items-center gap-2" title="Wyłącz Audio w galerii">
<i class="fa fa-volume-mute"></i> <span class="hidden xl:inline">Audio OFF</span>
</button>
</div>
<!-- Przycisk Szczegóły z dymkiem i ilością -->
<button onclick="openPopover('${loc.id}')" class="flex items-center gap-2 px-4 py-2 bg-brand-50 hover:bg-brand-100 dark:bg-brand-900/20 dark:hover:bg-brand-900/40 text-brand-600 dark:text-brand-400 border border-brand-200 dark:border-brand-800/50 rounded-xl text-sm font-bold transition-all group hover:shadow-sm">
<span class="w-2.5 h-2.5 rounded-full ${dotCls}"></span>
<span>${on} / ${total}</span>
<i class="fa fa-search text-brand-400 dark:text-brand-500 ml-1 group-hover:scale-110 transition-transform"></i>
<span class="hidden sm:inline">SZCZEGÓŁY</span>
</button>
</div>
</div>
<!-- Progress bar -->
<div class="h-1.5 w-full bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full ${dotCls} transition-all duration-500" style="width: ${pct}%"></div>
</div>
<!-- Sekcja KONTROLERY na kafelku -->
<div class="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-3 border border-slate-100 dark:border-slate-700 flex flex-col xl:flex-row justify-between items-start xl:items-center gap-3">
<div class="flex items-center gap-3 cursor-pointer" onclick="openPopover('${loc.id}')">
<div class="w-8 h-8 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center"><i class="fa fa-desktop"></i></div>
<div>
<div class="text-sm font-bold text-slate-800 dark:text-slate-200">Kontrolery</div>
<div class="text-xs text-slate-500 dark:text-slate-400">${onPcs} z ${pcs.length} włączonych</div>
</div>
</div>
<div class="flex gap-1.5">
<button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-emerald-100 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 text-emerald-700 dark:text-emerald-400 rounded-md text-xs font-bold transition-colors">Włącz</button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-md text-xs font-bold transition-colors">Wyłącz</button>
<button onclick="runCommand('${COMMAND_RESTART}', '${loc.id}', 'computer')" class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-400 rounded-md text-xs font-bold transition-colors" title="Restart"><i class="fa fa-redo"></i></button>
</div>
</div>
<!-- Sekcja OŚWIETLENIE na kafelku -->
<div class="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-3 border border-slate-100 dark:border-slate-700 flex flex-col xl:flex-row justify-between items-start xl:items-center gap-3">
<div class="flex items-center gap-3 cursor-pointer" onclick="openPopover('${loc.id}')">
<div class="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center"><i class="fa fa-lightbulb"></i></div>
<div>
<div class="text-sm font-bold text-slate-800 dark:text-slate-200">Oświetlenie</div>
<div class="text-xs text-slate-500 dark:text-slate-400">${onLights} z ${lights.length} włączonych</div>
</div>
</div>
<div class="flex gap-1.5">
<button onclick="runCommand('${COMMAND_WAKE}', '${loc.id}', 'ctrl')" class="px-3 py-1.5 bg-emerald-100 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 text-emerald-700 dark:text-emerald-400 rounded-md text-xs font-bold transition-colors">Włącz</button>
<button onclick="runCommand('${COMMAND_SHUTDOWN}', '${loc.id}', 'ctrl')" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-md text-xs font-bold transition-colors">Wyłącz</button>
</div>
</div>
<!-- Dymek (Popover) centralnie nad kafelkiem -->
<div id="popover-${loc.id}" class="absolute inset-0 z-50 flex opacity-0 invisible scale-95 transition-all duration-300 pointer-events-none">
<!-- Właściwe okienko (zajmujące 100% obszaru) -->
<div class="relative w-full h-full flex flex-col bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 pointer-events-auto overflow-hidden">
<!-- Header Dymka -->
<div class="flex items-center justify-between p-4 border-b border-slate-100 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50 shrink-0">
<h4 class="font-bold text-slate-800 dark:text-slate-200 text-lg flex items-center gap-2">
<i class="fa fa-list text-brand-500"></i> Szczegóły: ${escapeHtml(loc.label)}
</h4>
<button onclick="closePopover('${loc.id}')" class="w-8 h-8 flex items-center justify-center rounded-full bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-600 dark:text-slate-300 transition-colors">
<i class="fa fa-times"></i>
</button>
</div>
<!-- Obszar przewijany -->
<div class="custom-scrollbar overflow-y-auto p-4 flex flex-col gap-4 flex-1">
${renderTerminalRows(pcs, 'Kontrolery (PC)', true)}
${renderTerminalRows(lights, 'Oświetlenie', false)}
</div>
</div>
</div>
</div>
`;
}
function renderTerminalRows(terminals, title, allowRestart) {
if(!terminals.length) return '';
const rows = terminals.map(t => {
const isRun = t.status?.toLowerCase() === 'running';
const statusCls = isRun ? "text-emerald-500" : "text-slate-400 dark:text-slate-500";
const dotCls = isRun ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600";
// Indywidualne przyciski
let actions = `
<button onclick="runSingleCommand('${COMMAND_WAKE}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40 rounded text-xs font-bold transition-colors">Włącz</button>
<button onclick="runSingleCommand('${COMMAND_SHUTDOWN}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 rounded text-xs font-bold transition-colors">Wyłącz</button>
`;
if(allowRestart) {
actions += `<button onclick="runSingleCommand('${COMMAND_RESTART}', '${t.id}', '${escapeHtml(t.name)}')" class="px-2 py-1 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40 rounded text-xs font-bold transition-colors" title="Restart"><i class="fa fa-redo"></i></button>`;
}
return `
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-2 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded-lg transition-colors border border-transparent hover:border-slate-100 dark:hover:border-slate-700">
<div class="flex items-center gap-3 overflow-hidden">
<span class="w-2 h-2 rounded-full flex-shrink-0 ${dotCls}"></span>
<div class="truncate">
<div class="text-sm font-semibold text-slate-800 dark:text-slate-200 truncate" title="${escapeHtml(t.name)}">${escapeHtml(t.name || `ID ${t.id}`)}</div>
<div class="text-xs font-mono text-slate-500 dark:text-slate-400 flex gap-2">
<span>${t.ip || '-'}</span>
<span class="${statusCls}">${t.status || 'Brak'}</span>
</div>
</div>
</div>
<div class="flex gap-1 flex-shrink-0 sm:ml-auto">
${actions}
</div>
</div>
`;
}).join('');
return `
<div>
<h5 class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2 pl-2">${title}</h5>
<div class="flex flex-col gap-1">
${rows}
</div>
</div>
`;
}
async function runGlobalCommand(command) {
const label = command === COMMAND_WAKE ? "Włącz" : "Wyłącz";
const confirmed = await customConfirm(
`Masowe wykonanie: ${label}`,
`Czy na pewno chcesz wykonać operację <strong>${label}</strong> na CAŁEJ wystawie (grupa: ${VISIBLE_GROUP_NAME})?\nDotyczy to wszystkich ${state.terminals.length} urządzeń.`
);
if(confirmed) executeCommand(command, 'all', 'all');
}
async function runCommand(command, locationId, type) {
const locName = locationId === 'all' ? "Cała wystawa" : (state.terminals.find(t => t.location_id === locationId)?.location_name || locationId);
const typeName = type === 'computer' ? 'Kontrolery' : (type === 'ctrl' ? 'Oświetlenie' : 'Wszystkie urządzenia');
// Logika nazw dla nowych komend
let actionName = "";
if(command === COMMAND_WAKE) actionName = "Włączenie";
else if(command === COMMAND_SHUTDOWN) actionName = "Wyłączenie";
else if(command === COMMAND_RESTART) actionName = "Restart";
else if(command === COMMAND_AUDIO_ON) actionName = "Włączenie Audio";
else if(command === COMMAND_AUDIO_OFF) actionName = "Wyłączenie Audio";
const confirmed = await customConfirm(
`Akcja grupowa: ${actionName}`,
`Lokalizacja: <strong>${locName}</strong>\nUrządzenia: <strong>${typeName}</strong>\n\nCzy na pewno chcesz kontynuować?`
);
if(confirmed) executeCommand(command, locationId, type);
}
async function runSingleCommand(command, terminalId, terminalName) {
let actionName = "";
if(command === COMMAND_WAKE) actionName = "Włączenie";
else if(command === COMMAND_SHUTDOWN) actionName = "Wyłączenie";
else if(command === COMMAND_RESTART) actionName = "Restart";
else if(command === COMMAND_AUDIO_ON) actionName = "Włączenie Audio";
else if(command === COMMAND_AUDIO_OFF) actionName = "Wyłączenie Audio";
const terminal = state.terminals.find(t => String(t.id) === String(terminalId));
const confirmed = await customConfirm(
`Pojedyncza Akcja: ${actionName}`,
`Urządzenie: <strong>${terminalName}</strong>\nIP: ${terminal?.ip || 'Brak IP'}\n\nCzy na pewno wysłać polecenie?`
);
if(confirmed) {
if(terminal) executeCommandBulk(command, [terminal], terminalName);
}
}
async function executeCommand(command, locationId, type) {
const targets = state.terminals.filter(t => {
if(locationId !== 'all' && t.location_id !== locationId) return false;
if(type !== 'all' && (type === 'computer' ? t.player !== 'PC' : t.player !== 'CTRL')) return false;
return true;
});
if(!targets.length) return setStatus("Brak urządzeń spełniających kryteria.", true);
executeCommandBulk(command, targets, "Grupa urządzeń");
}
async function executeCommandBulk(command, targets, scopeLabel) {
state.busy = true;
setStatus(`Wysyłanie polecenia ${command} do ${targets.length} urządzeń (${scopeLabel})...`, false);
try {
// Wykrywanie Trybu Preview wstrzymuje żądania FETCH
const isPreview = window.location.protocol === 'about:' || window.location.protocol === 'blob:' || window.location.hostname === '';
let failedCount = 0;
for(const t of targets) {
if (isPreview) {
await new Promise(r => setTimeout(r, 100)); // Symulacja requestu
const tRef = MOCK_DATA.find(m => m.id === t.id);
if(tRef) tRef.status = command === COMMAND_SHUTDOWN ? 'Stopped' : 'Running';
} else {
// Produkcja
try {
const body = new URLSearchParams();
body.set("command", command);
body.set("tag_id", t.id);
const res = await fetch(API_COMMAND, { method: "POST", body: body });
if(!res.ok) failedCount++;
} catch(e) {
failedCount++;
}
}
}
if(failedCount > 0) setStatus(`Zakończono z błędami. Niepowodzenia: ${failedCount}/${targets.length}`, true);
else setStatus(`Polecenie wykonane pomyślnie dla ${targets.length} urządzeń.`, false);
setTimeout(() => refreshTerminals(true), 1500);
} finally {
state.busy = false;
}
}
function escapeHtml(str) {
return String(str||"").replace(/[&<>"']/g, m => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[m]);
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff