You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

744 lines
47 KiB
HTML

<!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>