Compare commits

..

No commits in common. 'fa79b2715803757c53897418e2332789cf8924a9' and 'c54cc9a832889cf56069e4230ced6df52ef34e48' have entirely different histories.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,5 +1,11 @@
<script src="assets/vendor/tailwindcss-3.4.17-cdn.js"></script>
<link rel="stylesheet" href="assets/vendor/fontawesome/css/all.min.css">
<!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',
@ -23,7 +29,11 @@
}
</script>
<style>
/* Wider, modern macOS-style scrollbar for popovers */
/* 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;
}
@ -32,8 +42,8 @@
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5); /* Semi-transparent gray */
border: 3px solid transparent; /* Inner border */
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;
}
@ -44,14 +54,77 @@
background-color: rgba(107, 114, 128, 0.8);
border-width: 2px;
}
</style>
/* 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">
<!-- The dashboard UI is injected here by JavaScript -->
<!-- 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>
<!-- Custom confirm modal replacing window.confirm -->
<!-- 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">
@ -76,7 +149,7 @@
</div>
<script>
// Initialize dark mode
// 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');
@ -86,7 +159,12 @@
}
initTheme();
// API and state configuration
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";
@ -94,7 +172,7 @@
const COMMAND_SHUTDOWN = "ShutDown";
const COMMAND_RESTART = "Restart";
// Application state
// Stan aplikacji
const state = {
terminals: [],
filter: "",
@ -110,7 +188,7 @@
const data = [];
let idCounter = 1;
// Gallery 01 mock data: 15 devices (10 PCs, 5 lighting controllers)
// 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' });
}
@ -118,7 +196,7 @@
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
// 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}`;
@ -132,11 +210,7 @@
}
const MOCK_DATA = generateMockData();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initDashboard);
} else {
initDashboard();
}
function initDashboard() {
renderShell();
@ -175,13 +249,13 @@
if (!silent) setStatus("Ładowanie terminali...", false);
try {
// Detect preview mode where the backend API is unavailable
// 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)); // Simulate latency
data = JSON.parse(JSON.stringify(MOCK_DATA)); // MOCK data copy
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}`);
@ -209,7 +283,7 @@
document.getElementById('customConfirmMessage').innerHTML = message.replace(/\n/g, '<br>');
modal.classList.remove('hidden');
// Short delay so the browser applies display before animation starts
// Małe opóźnienie dla przeglądarki, żeby zastosowała display:block przed animacją
setTimeout(() => {
modal.classList.remove('opacity-0');
box.classList.remove('scale-95');
@ -245,7 +319,7 @@
<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 -->
<!-- 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>
@ -268,10 +342,10 @@
<i class="fa fa-info-circle"></i> Inicjalizacja...
</div>
<!-- Global summary view -->
<!-- Widok Globalny (Podsumowanie) -->
<div id="nscExhibitionCard"></div>
<!-- Location grid -->
<!-- Siatka Lokalizacji -->
<div id="nscLocationGrid" class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-6"></div>
`;
}
@ -303,7 +377,7 @@
}
function renderDashboard() {
// Save the scroll position before rebuilding the open popover DOM
// 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`);
@ -317,12 +391,11 @@
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;
let locations = Array.from(locationsMap.values()).sort((a,b) => String(a.label).localeCompare(String(b.label), 'pl', {numeric: true}));
// Filtering
// Filtrowanie
if(state.filter) {
visibleLocations = allLocations.filter(loc => {
locations = locations.filter(loc => {
const matchLoc = loc.label.toLowerCase().includes(state.filter);
const matchTerminals = loc.terminals.some(t =>
(t.name || "").toLowerCase().includes(state.filter) ||
@ -332,44 +405,39 @@
});
}
renderGlobalExhibition(allLocations, visibleLocations);
renderGlobalExhibition(locations);
const grid = document.getElementById("nscLocationGrid");
grid.innerHTML = visibleLocations.map(renderLocationCard).join("");
grid.innerHTML = locations.map(renderLocationCard).join("");
// Restore the open popover and scroll position after rebuilding the HTML
// ODTWORZENIE: Po zbudowaniu HTML przywracamy otwarty dymek i pozycję scrolla
if (state.openPopoverId) {
const pop = document.getElementById(`popover-${state.openPopoverId}`);
if (pop) {
// Temporarily remove animation classes to avoid reanimating every 5 seconds
// 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; // Restore scroll position
if (scrollArea) scrollArea.scrollTop = currentScroll; // Przywracamy przewijanie!
// Restore animations shortly after rendering
// Zwracamy animacje po ułamku sekundy
setTimeout(() => {
pop.classList.add('transition-all', 'duration-300');
}, 50);
} else {
// Clear state if filtering removed the popover from the DOM
// Jeśli po filtracji dymku nie ma w DOM, czyścimy stan
state.openPopoverId = null;
}
}
}
function renderGlobalExhibition(allLocations, visibleLocations) {
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;
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";
@ -384,7 +452,7 @@
</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>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Zarządzanie całą wystawą (Galerie: ${locations.length})</p>
</div>
</div>
@ -423,14 +491,14 @@
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 -->
<!-- Nagłówek Karty z nowym przyciskiem -->
<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 -->
<!-- Przycisk SZCZEGÓŁY (zamiast samego statustu 11/15) -->
<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>
@ -444,7 +512,7 @@
<div class="h-full ${dotCls} transition-all duration-500" style="width: ${pct}%"></div>
</div>
<!-- Controllers section on the card -->
<!-- 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>
@ -460,7 +528,7 @@
</div>
</div>
<!-- Lighting section on the card -->
<!-- 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>
@ -475,13 +543,13 @@
</div>
</div>
<!-- Popover centered over the card and matched to the card size in the grid -->
<!-- Dymek (Popover) centralnie nad kafelkiem, wymiar w 100% dopasowany do kafelka w ramach siatki -->
<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 -->
<!-- Właściwe okienko (zajmujące calutką ramkę) -->
<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 -->
<!-- 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)}
@ -491,7 +559,7 @@
</button>
</div>
<!-- Scrollable area -->
<!-- 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)}
@ -510,7 +578,7 @@
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
// 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>
@ -598,18 +666,18 @@
setStatus(`Wysyłanie polecenia ${command} do ${targets.length} urządzeń (${scopeLabel})...`, false);
try {
// Preview mode skips FETCH requests
// 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)); // Simulate request
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 {
// Production request
// Produkcja
try {
const body = new URLSearchParams();
body.set("command", command);
@ -636,6 +704,5 @@
return String(str||"").replace(/[&<>"']/g, m => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[m]);
}
</script>
</body>
</html>
Loading…
Cancel
Save