Update: 2026 copyright, private service modals, real-time status checking

This commit is contained in:
Roberth Rajala 2026-02-01 17:06:15 +01:00
parent 0771b37ee2
commit 0ae90b8b85
3 changed files with 401 additions and 17 deletions

View File

@ -266,9 +266,9 @@
</div>
<h3 class="service-name">Nextcloud</h3>
<p class="service-desc">Private cloud storage, files, and collaboration</p>
<div class="service-status online">
<div class="service-status checking" data-status-url="https://cloud.lemonlink.eu/status.php">
<span class="status-dot"></span>
<span>Online</span>
<span class="status-text">Checking...</span>
</div>
<div class="service-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -302,9 +302,9 @@
</div>
<h3 class="service-name">Netdata</h3>
<p class="service-desc">Real-time system monitoring and metrics</p>
<div class="service-status online">
<div class="service-status checking" data-status-url="https://stats.lemonlink.eu/api/v1/info">
<span class="status-dot"></span>
<span>Online</span>
<span class="status-text">Checking...</span>
</div>
<div class="service-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -313,50 +313,53 @@
</div>
</a>
<a href="https://photos.lemonlink.eu" class="service-card" target="_blank" rel="noopener">
<div class="service-card private-service" data-service="immich">
<div class="service-glow"></div>
<div class="service-icon" style="--icon-color: #ad5c5c;">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</div>
<h3 class="service-name">Immich</h3>
<p class="service-desc">Self-hosted photo and video backup solution</p>
<div class="service-status online">
<div class="service-status checking" data-status-url="https://photos.lemonlink.eu/api/server-info/ping">
<span class="status-dot"></span>
<span>Online</span>
<span class="status-text">Checking...</span>
</div>
<div class="service-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7 17L17 7M17 7H7M17 7V17"/>
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</a>
</div>
<a href="https://dash.lemonlink.eu" class="service-card" target="_blank" rel="noopener">
<div class="service-card private-service" data-service="homarr">
<div class="service-glow"></div>
<div class="service-icon" style="--icon-color: #ff5c5c;">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
</div>
<h3 class="service-name">Homarr</h3>
<p class="service-desc">Customizable dashboard for all your services</p>
<div class="service-status online">
<div class="service-status checking" data-status-url="https://dash.lemonlink.eu">
<span class="status-dot"></span>
<span>Online</span>
<span class="status-text">Checking...</span>
</div>
<div class="service-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7 17L17 7M17 7H7M17 7V17"/>
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</a>
</div>
<a href="https://vpn.lemonlink.eu" class="service-card" target="_blank" rel="noopener">
<div class="service-card private-service" data-service="tailscale">
<div class="service-glow"></div>
<div class="service-icon" style="--icon-color: #7b68ee;">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
</div>
<h3 class="service-name">Tailscale</h3>
<p class="service-desc">Zero-config VPN for secure remote access</p>
<div class="service-status online">
<div class="service-status checking" data-status-url="https://login.tailscale.com">
<span class="status-dot"></span>
<span class="status-text">Checking...</span>
</div>
<span class="status-dot"></span>
<span>Online</span>
</div>
@ -623,11 +626,69 @@
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2024 LemonLink. All rights reserved.</p>
<p>&copy; 2026 LemonLink. All rights reserved.</p>
<p class="footer-made">Powered by 2x Xeon E5645 | 96GB RAM | TrueNAS SCALE</p>
</div>
</footer>
<!-- Service Info Modals -->
<div id="modal-immich" class="service-modal">
<div class="modal-content">
<button class="modal-close">&times;</button>
<h3>📸 Immich Photo Server</h3>
<p>Self-hosted photo and video backup solution - a Google Photos alternative.</p>
<div class="modal-details">
<h4>Features:</h4>
<ul>
<li>Automatic mobile photo backup</li>
<li>AI-powered face recognition</li>
<li>Albums and sharing</li>
<li>RAW file support</li>
</ul>
<h4>Access:</h4>
<p>This is a <strong>private service</strong>. Contact me for access.</p>
</div>
</div>
</div>
<div id="modal-homarr" class="service-modal">
<div class="modal-content">
<button class="modal-close">&times;</button>
<h3>🎛️ Homarr Dashboard</h3>
<p>Personal dashboard aggregating all services in one place.</p>
<div class="modal-details">
<h4>Features:</h4>
<ul>
<li>Service status monitoring</li>
<li>Quick bookmarks</li>
<li>Weather widget</li>
<li>Custom integrations</li>
</ul>
<h4>Access:</h4>
<p>This is a <strong>private service</strong>. VPN access required.</p>
</div>
</div>
</div>
<div id="modal-tailscale" class="service-modal">
<div class="modal-content">
<button class="modal-close">&times;</button>
<h3>🔒 Tailscale VPN</h3>
<p>Zero-configuration mesh VPN for secure remote access to the homelab.</p>
<div class="modal-details">
<h4>Features:</h4>
<ul>
<li>Point-to-point encrypted connections</li>
<li>No open ports required</li>
<li>Multi-device support</li>
<li>Exit nodes for secure browsing</li>
</ul>
<h4>Access:</h4>
<p>Tailscale network invitation required. Contact me to join the tailnet.</p>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

139
script.js
View File

@ -465,3 +465,142 @@ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.documentElement.style.setProperty('--transition-normal', '0s');
document.documentElement.style.setProperty('--transition-slow', '0s');
}
/**
* Service Status Checker - Real-time health checks
*/
function initServiceStatusChecker() {
const statusElements = document.querySelectorAll('.service-status[data-status-url]');
statusElements.forEach(element => {
const url = element.getAttribute('data-status-url');
checkServiceHealth(element, url);
// Check every 60 seconds
setInterval(() => checkServiceHealth(element, url), 60000);
});
}
async function checkServiceHealth(element, url) {
const statusText = element.querySelector('.status-text');
const originalText = statusText.textContent;
try {
// Try to fetch with a short timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: 'HEAD',
mode: 'no-cors',
signal: controller.signal
});
clearTimeout(timeoutId);
// If we get here, service is likely online
element.className = 'service-status online';
statusText.textContent = 'Online';
} catch (error) {
// For no-cors requests, we can't read the response
// Try an alternative approach - image load test for same-origin
testViaImage(element, url, statusText);
}
}
function testViaImage(element, url, statusText) {
// Extract domain from URL
const urlObj = new URL(url);
const testUrl = `${urlObj.protocol}//${urlObj.hostname}/favicon.ico`;
const img = new Image();
img.onload = () => {
element.className = 'service-status online';
statusText.textContent = 'Online';
};
img.onerror = () => {
// Could be offline or just no favicon
// Check if it's a private/internal service
if (element.closest('.private-service')) {
element.className = 'service-status online';
statusText.textContent = 'Restricted';
} else {
element.className = 'service-status offline';
statusText.textContent = 'Offline';
}
};
img.src = testUrl + '?t=' + Date.now();
// Timeout after 3 seconds
setTimeout(() => {
if (!img.complete) {
img.src = '';
if (element.closest('.private-service')) {
element.className = 'service-status online';
statusText.textContent = 'Restricted';
} else {
element.className = 'service-status maintenance';
statusText.textContent = 'Unknown';
}
}
}, 3000);
}
// Initialize status checker
document.addEventListener('DOMContentLoaded', initServiceStatusChecker);
/**
* Service Info Modals
*/
function initServiceModals() {
const privateServices = document.querySelectorAll('.private-service');
const modals = document.querySelectorAll('.service-modal');
const closeButtons = document.querySelectorAll('.modal-close');
// Open modal when clicking private service
privateServices.forEach(service => {
service.addEventListener('click', (e) => {
e.preventDefault();
const serviceName = service.getAttribute('data-service');
const modal = document.getElementById(`modal-${serviceName}`);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
});
});
// Close modal when clicking close button
closeButtons.forEach(button => {
button.addEventListener('click', () => {
const modal = button.closest('.service-modal');
modal.classList.remove('active');
document.body.style.overflow = '';
});
});
// Close modal when clicking outside
modals.forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
});
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
modals.forEach(modal => {
modal.classList.remove('active');
});
document.body.style.overflow = '';
}
});
}
// Initialize modals
document.addEventListener('DOMContentLoaded', initServiceModals);

View File

@ -1512,3 +1512,187 @@ body {
background: rgba(234, 179, 8, 0.3);
color: var(--color-text);
}
/* Selection */
::selection {
background: rgba(234, 179, 8, 0.3);
color: var(--color-text);
}
/* ========================================
Service Status Checking
======================================== */
.service-status.checking {
background: rgba(107, 114, 128, 0.1);
border: 1px solid rgba(107, 114, 128, 0.3);
color: #6b7280;
}
.service-status.checking .status-dot {
background: #6b7280;
animation: pulse-dot 1s infinite;
}
.service-status.online {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.service-status.online .status-dot {
background: #22c55e;
animation: pulse-dot 2s infinite;
}
.service-status.offline {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.service-status.offline .status-dot {
background: #ef4444;
animation: none;
}
.service-status.maintenance {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.service-status.maintenance .status-dot {
background: #f59e0b;
animation: pulse-dot 1.5s infinite;
}
/* Private Services */
.private-service {
cursor: pointer;
}
.private-service .service-arrow svg {
width: 20px;
height: 20px;
}
/* ========================================
Service Info Modals
======================================== */
.service-modal {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
align-items: center;
justify-content: center;
padding: 2rem;
opacity: 0;
transition: opacity 0.3s ease;
}
.service-modal.active {
display: flex;
opacity: 1;
}
.modal-content {
background: var(--color-bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 2.5rem;
max-width: 500px;
width: 100%;
position: relative;
transform: translateY(20px);
transition: transform 0.3s ease;
box-shadow: var(--shadow-card);
}
.service-modal.active .modal-content {
transform: translateY(0);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-fast);
}
.modal-close:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
color: #ef4444;
}
.modal-content h3 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.modal-content p {
color: var(--color-text-muted);
margin-bottom: 1.5rem;
}
.modal-details h4 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 1.5rem 0 0.75rem;
}
.modal-details ul {
list-style: none;
padding: 0;
}
.modal-details li {
padding: 0.35rem 0;
padding-left: 1.5rem;
position: relative;
color: var(--color-text-muted);
}
.modal-details li::before {
content: '✓';
position: absolute;
left: 0;
color: var(--color-primary);
font-weight: bold;
}
.modal-details strong {
color: var(--color-primary);
}
@media (max-width: 768px) {
.service-modal {
padding: 1rem;
}
.modal-content {
padding: 1.5rem;
}
}