RC: (upload) astro initial structure

This commit is contained in:
Raul Costa
2026-04-01 00:19:49 +01:00
commit 8c11192e7b
29 changed files with 8561 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
---
// HomelabStatus.astro
// Set USE_DEMO=false and API_URL to your actual endpoint once deployed.
---
<section class="section homelab-status" id="homelab-status">
<div class="container">
<div class="hl-header">
<div>
<div class="accent-line"></div>
<h2>Homelab <span class="text-accent">Status</span></h2>
<p class="font-mono text-muted" style="font-size:0.82rem;margin-top:0.4rem">
Proxmox · pve-node-01 ·
<span id="hl-ts" class="text-accent" style="font-size:0.75rem">connecting...</span>
</p>
</div>
<a href="/homelab" class="btn btn-primary">Full overview →</a>
</div>
<div class="hl-grid" id="hl-grid">
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
<div class="hl-card skeleton"></div>
</div>
<div class="hl-detail card" id="hl-detail" style="margin-top:1.5rem;display:none">
<div class="hl-detail-grid">
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">CPU history (1 min)</span>
<div class="spark-bars" id="bars-cpu"></div>
</div>
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">Load history (1 min)</span>
<div class="spark-bars" id="bars-load"></div>
</div>
<div class="hl-spark-block">
<span class="hl-spark-label font-mono">Network RX/TX (1 min)</span>
<div class="spark-bars" id="bars-net"></div>
</div>
</div>
</div>
</div>
</section>
<script is:inline>
// ── CONFIG ────────────────────────────────────────────────────────────
const API_URL = 'http://pve.int:8080/api/homelab'; // Or use raw IP
const SPARK_H = 48; // Pixel height of .spark-bars
// ── FALLBACK ROLLING BUFFERS ──────────────────────────────────────────
let fallbackCpu = Array(60).fill(0);
let fallbackLoad = Array(60).fill(0);
let fallbackRx = Array(60).fill(0);
let fallbackTx = Array(60).fill(0);
// ── DATA PARSER ───────────────────────────────────────────────────────
function parseData(d, isLive, timestamp = null) {
const cCpu = Math.round(d.cpu || 0);
const cRam = Math.round(d.ram?.percent ?? d.ram ?? 0);
const cLoad = d.load_avg ? d.load_avg[0] : 0;
const cRx = d.rx_mb || 0;
const cTx = d.tx_mb || 0;
function roll(buf, hist, val) {
if (hist && hist.length > 0) return hist;
buf.shift(); buf.push(val);
return [...buf];
}
return {
isLive,
timestamp,
cpu: cCpu,
ram_used: cRam,
root_disk: Math.round(d.root_disk_pct || 0),
rx_mb: cRx,
tx_mb: cTx,
cpu_hist: roll(fallbackCpu, d.cpu_hist, cCpu),
load_hist: roll(fallbackLoad, d.load_hist, cLoad),
rx_hist: roll(fallbackRx, d.rx_hist, cRx),
tx_hist: roll(fallbackTx, d.tx_hist, cTx),
};
}
// ── CACHED FETCH LOGIC ────────────────────────────────────────────────
async function fetchLive() {
try {
// 1. Try to fetch the live data
const res = await fetch(API_URL, { signal: AbortSignal.timeout(4000) });
const d = await res.json();
if (d.error) throw new Error(d.error);
// 2. If successful, save it to the browser's Local Storage!
localStorage.setItem('homelab_cache', JSON.stringify(d));
localStorage.setItem('homelab_ts', Date.now());
return parseData(d, true);
} catch (e) {
console.warn('API fetch failed, attempting to load from cache...', e);
// 3. If it fails, pull the last known good data from memory
const cachedData = localStorage.getItem('homelab_cache');
const cachedTime = localStorage.getItem('homelab_ts');
if (cachedData) {
return parseData(JSON.parse(cachedData), false, parseInt(cachedTime));
} else {
throw new Error("No cache available and API is offline.");
}
}
}
// ── RENDER ────────────────────────────────────────────────────────────
function bar(pct) {
const c = pct > 85 ? '#FF4444' : pct > 70 ? '#FFB800' : '#00D2BE';
return `<div class="hl-bar-track"><div class="hl-bar-fill" style="width:${pct}%;background:${c}"></div></div>`;
}
function renderCards(d) {
const cards = [
{ label: 'CPU', value: `${d.cpu}%`, sub: 'overall usage', pct: d.cpu, icon: '⬡' },
{ label: 'RAM', value: `${d.ram_used}%`, sub: 'used of total', pct: d.ram_used, icon: '▣' },
{ label: 'Disk', value: `${d.root_disk}%`, sub: '/ partition usage', pct: d.root_disk, icon: '⛁' },
{ label: 'Network', value: `↓${d.rx_mb} ↑${d.tx_mb}`, sub: 'Mbps rx / tx', pct: null, icon: '⇅' },
];
const grid = document.getElementById('hl-grid');
if (grid) {
grid.innerHTML = cards.map(c => `
<div class="hl-card card ${d.isLive ? '' : 'offline-card'}">
<div class="hl-card-top">
<span class="hl-icon">${c.icon}</span>
<span class="hl-label font-mono">${c.label}</span>
</div>
<div class="hl-value font-display">${c.value}</div>
<div class="hl-sub text-muted">${c.sub}</div>
${c.pct !== null ? bar(c.pct) : ''}
</div>
`).join('');
}
}
function renderSparks(d) {
document.getElementById('hl-detail').style.display = '';
renderSparkBars('bars-cpu', d.cpu_hist, '#00D2BE');
renderSparkBars('bars-load', d.load_hist, '#00A19C');
renderSparkBars('bars-net', d.rx_hist, '#007A76', d.tx_hist);
}
// BULLETPROOF SVG RENDERER
function renderSparkBars(id, series, color, series2) {
const el = document.getElementById(id);
if (!el || !series.length) return;
const W = 240;
const H = SPARK_H;
const max = Math.max(...series, ...(series2 || []), 0.05);
let rects = '';
const step = W / series.length;
const barW = series2 ? 1.5 : 3;
series.forEach((v, i) => {
const x = i * step;
const h1 = Math.max(1, (v / max) * H);
const y1 = H - h1;
if (series2) {
const h2 = Math.max(1, ((series2[i] || 0) / max) * H);
const y2 = H - h2;
rects += `<rect x="${x}" y="${y1}" width="${barW}" height="${h1}" fill="${color}" opacity="0.9"/>`;
rects += `<rect x="${x + barW}" y="${y2}" width="${barW}" height="${h2}" fill="#005F5D" opacity="0.7"/>`;
} else {
rects += `<rect x="${x}" y="${y1}" width="${barW}" height="${h1}" fill="${color}"/>`;
}
});
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" style="width:100%; height:${H}px; display:block; ${series2 ? '' : 'opacity: 0.8;'}">${rects}</svg>`;
}
async function render() {
const ts = document.getElementById('hl-ts');
try {
const data = await fetchLive();
renderCards(data);
renderSparks(data);
// Update the timestamp based on live/offline status
if (data.isLive) {
ts.style.color = 'var(--petronas-teal)';
ts.textContent = 'updated ' + new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
ts.style.color = '#FFB800'; // Warning Orange
const oldTime = new Date(data.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
ts.textContent = `offline · last seen ${oldTime}`;
}
} catch (e) {
// Only happens if the API is dead AND there is no cache
if (ts) {
ts.style.color = '#FF4444'; // Error Red
ts.textContent = 'API unreachable · no cache found';
}
}
}
render();
// refresh every 10 sec
setInterval(render, 10000);
</script>
<style>
.homelab-status { padding-top: 4rem; }
.hl-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 2.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.hl-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.25rem;
}
.hl-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 130px;
transition: opacity 0.3s ease;
}
/* Make cards look slightly faded when offline */
.offline-card {
opacity: 0.6;
filter: grayscale(40%);
}
.hl-card.skeleton {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 12px;
animation: skeleton-pulse 1.8s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.hl-card-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hl-icon {
font-size: 1.1rem;
color: var(--petronas-teal);
opacity: 0.8;
}
.hl-label {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.hl-value {
font-size: 2.2rem;
font-weight: 700;
color: var(--petronas-teal);
line-height: 1;
letter-spacing: -0.01em;
}
.hl-sub {
font-size: 0.72rem;
color: var(--text-secondary);
margin-bottom: 0.4rem;
}
.hl-bar-track {
height: 3px;
background: var(--bg-surface);
border-radius: 2px;
overflow: hidden;
margin-top: auto;
}
.hl-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.9s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Sparklines */
.hl-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
padding: 1.25rem;
}
.hl-spark-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hl-spark-label {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.spark-bars {
width: 100%;
height: 48px;
}
@media (max-width: 900px) {
.hl-grid { grid-template-columns: 1fr 1fr; }
.hl-detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.hl-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,334 @@
---
// ParallaxHero.astro — Osmo-style multi-layer GSAP parallax, Petronas theme
---
<div class="parallax">
<section class="parallax__header">
<div class="parallax__visuals">
<div data-parallax-layers class="parallax__layers">
<!-- LAYER 1 — bg photo (slowest, most travel) -->
<img
src="/image_zb1c2e65feww76x.png"
loading="eager"
data-parallax-layer="1"
alt=""
class="parallax__layer-img"
/>
<!-- LAYER 2 — teal colour grade (follows bg) -->
<div data-parallax-layer="2" class="parallax__layer-tint"></div>
<!-- LAYER 3 — title (mid speed) -->
<div data-parallax-layer="3" class="parallax__layer-title">
<h1 class="parallax__title">
<span class="parallax__title-accent">/</span>slashroot
</h1>
<!-- <div class="parallax__eyebrow font-mono">
<span style="color:var(--petronas-teal)">$</span> cat /var/log/adventures.log
</div> -->
<div class="parallax__cta">
<a href="/blog" class="btn btn-primary">Read the logs</a>
</div>
</div>
<!-- LAYER 4 — figure foreground PNG (barely moves = pops forward) -->
<div data-parallax-layer="4" class="parallax__layer-figure" aria-hidden="true">
<img
src="/image_zb1c25zb1c25zb1c.png"
alt=""
class="parallax__figure-img"
/>
</div>
</div>
<!-- Bottom fade -->
<div class="parallax__fade"></div>
<!-- Scroll hint inside header for absolute positioning -->
<div class="parallax__scroll-hint" id="scroll-hint" aria-hidden="true">
<span class="font-mono">scroll</span>
<div class="scroll-line"></div>
</div>
</div>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" is:inline></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" is:inline></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.1.13/dist/lenis.min.js" is:inline></script>
<script is:inline>
if (history.scrollRestoration) {
history.scrollRestoration = "manual";
}
document.addEventListener("DOMContentLoaded", () => {
gsap.registerPlugin(ScrollTrigger);
document.querySelectorAll('[data-parallax-layers]').forEach((trigger) => {
const header = trigger.closest('.parallax__header');
const tl = gsap.timeline({
scrollTrigger: {
trigger: header,
start: "top top",
end: "bottom top",
scrub: 0,
invalidateOnRefresh: true
}
});
// tl.to('[data-parallax-layer="1"]', { yPercent: 40, ease: "none" }, 0)
tl.fromTo('[data-parallax-layer="1"]',
{ yPercent: 0 }, // FORCE the start position
{ yPercent: 40, ease: "none" }, // The end position
0 // Keep your 0 position parameter
)
.to('[data-parallax-layer="2"]', { yPercent: 40, ease: "none" }, 0)
.to('[data-parallax-layer="3"]', { yPercent: 20, ease: "none" }, 0)
.to('[data-parallax-layer="4"]', { yPercent: 6, ease: "none" }, 0);
});
// Lenis smooth scroll
const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
// Fade scroll hint
const hint = document.getElementById('scroll-hint');
if (hint) {
ScrollTrigger.create({
start: 60,
onEnter: () => gsap.to(hint, { opacity: 0, duration: 0.4 }),
onLeaveBack: () => gsap.to(hint, { opacity: 1, duration: 0.4 }),
});
}
// Entrance animation
const intro = gsap.timeline({ defaults: { ease: "power2.out" } });
intro
.from('.parallax__eyebrow', { opacity: 0, y: 20, duration: 0.8 }, 0.3)
.from('.parallax__title', { opacity: 0, y: 32, duration: 1.0 }, 0.5)
.from('.parallax__subtitle', { opacity: 0, y: 16, duration: 0.8 }, 0.72)
.from('.parallax__cta', { opacity: 0, y: 12, duration: 0.7 }, 0.92)
.from('.parallax__figure-img', { opacity: 0, y: 24, duration: 1.0 }, 0.25);
window.addEventListener('load', () => {
ScrollTrigger.refresh();
});
});
</script>
<style>
.parallax {
position: relative;
}
/* Osmo natural height — no 200vh hack */
.parallax__header {
min-height: 100svh;
position: relative;
z-index: 2;
}
/* Osmo: absolute + 120% height, no sticky */
.parallax__visuals {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 110%;
overflow: hidden;
}
.parallax__layers {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Osmo magic offset — pulled up so it has room to scroll down */
.parallax__layer-img {
position: absolute;
top: -20.5%;
left: 0;
width: 100%;
height: 117.5%;
object-fit: cover;
object-position: center 30%;
display: block;
filter: saturate(0.25) brightness(0.45);
pointer-events: none;
will-change: transform;
}
/* Layer 2 — teal tint, same geometry as bg */
.parallax__layer-tint {
position: absolute;
top: -17.5%;
left: 0;
width: 100%;
height: 117.5%;
background:
radial-gradient(ellipse 65% 50% at 50% 60%, rgba(0,210,190,0.13) 0%, transparent 65%),
linear-gradient(to bottom,
rgba(5,10,10,0.65) 0%,
transparent 20%,
transparent 65%,
rgba(5,10,10,0.85) 100%
),
linear-gradient(to right,
rgba(5,10,10,0.5) 0%,
transparent 25%,
transparent 75%,
rgba(5,10,10,0.5) 100%
);
will-change: transform;
}
/* Layer 3 — title: same top offset as bg, centred vertically */
.parallax__layer-title {
position: absolute;
top: -20.5%;
left: 0;
width: 100%;
height: 117.5%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 1.5rem;
padding-bottom: 12vh;
text-align: center;
z-index: 1;
will-change: transform;
}
.parallax__eyebrow {
font-size: clamp(0.68rem, 1.4vw, 0.88rem);
color: var(--text-muted);
letter-spacing: 0.12em;
margin-bottom: 1rem;
}
.parallax__title {
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(4rem, 13vw, 11rem);
letter-spacing: -0.02em;
line-height: 0.92;
color: var(--text-primary);
text-shadow:
0 0 120px rgba(0,210,190,0.2),
0 4px 60px rgba(0,0,0,0.95);
margin-bottom: 1.25rem;
}
.parallax__title-accent { color: var(--petronas-teal); }
.parallax__subtitle {
font-size: clamp(0.72rem, 1.8vw, 0.95rem);
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 2.5rem;
max-width: 54ch;
}
.parallax__cta {
display: flex;
align-items: flex-end;
width: 120%;
gap: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.parallax__cta-ghost {
font-size: 0.88rem;
color: var(--text-muted);
letter-spacing: 0.06em;
transition: color 150ms;
}
.parallax__cta-ghost:hover { color: var(--petronas-teal); }
/* Layer 4 — figure: same Osmo offset as bg so GSAP travel is consistent.
SVG is constrained with max-width so it doesn't fill the whole container. */
.parallax__layer-figure {
position: absolute;
top: -9.5%;
left: 0;
width: 120%;
height: 105.5%;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 2vh;
z-index: 2;
pointer-events: none;
will-change: transform;
}
.parallax__figure-img {
width: clamp(200px, 50vh, 650px);
height: 70vh;
display: block;
flex-shrink: 0;
object-fit: contain;
object-position: bottom center;
filter: drop-shadow(0 -8px 24px rgba(0,210,190,0.15));
}
/* ── Bottom fade ─────────────────────────────────────────────── */
.parallax__fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 180px;
background: linear-gradient(to bottom, transparent, var(--bg-void));
z-index: 10;
pointer-events: none;
}
/* Scroll hint — absolute inside the 100svh header */
.parallax__scroll-hint {
position: absolute;
bottom: 2.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
z-index: 30;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.scroll-line {
width: 1px;
height: 48px;
background: linear-gradient(to bottom, var(--petronas-teal), transparent);
transform-origin: top;
animation: scroll-grow 2s ease-in-out infinite;
}
@keyframes scroll-grow {
0% { transform: scaleY(0); opacity: 1; }
65% { transform: scaleY(1); opacity: 1; }
100% { transform: scaleY(1); opacity: 0; }
}
@media (max-width: 600px) {
.parallax__figure-svg { width: 110px; }
.parallax__cta { gap: 1rem; }
}
</style>

View File

@@ -0,0 +1,131 @@
---
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 3);
---
<section class="section recent-posts" id="recent-posts">
<div class="container">
<div class="section-header">
<div class="accent-line"></div>
<h2>Recent <span class="text-accent">Entries</span></h2>
<p class="text-muted font-mono" style="margin-top:0.5rem;font-size:0.85rem">
latest dispatches from the terminal
</p>
</div>
<div class="posts-grid">
{posts.map((post, i) => (
<article class="card post-card" class:list={[{ featured: i === 0 }]}>
<a href={`/blog/${post.id}`} class="post-link">
<div class="post-card-inner">
{post.data.day && (
<div class="post-day font-mono">entry_{String(post.data.day).padStart(3,'0')}</div>
)}
<h3 class="post-card-title">{post.data.title}</h3>
<p class="post-card-desc text-muted">{post.data.description}</p>
<div class="post-card-meta">
<time class="font-mono text-muted" style="font-size:0.72rem">
{post.data.pubDate.toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' })}
</time>
<div class="post-card-tags">
{(post.data.tags ?? []).slice(0,3).map((t: string) => (
<span class="tag">{t}</span>
))}
</div>
</div>
<div class="post-card-cta text-accent font-mono">
cat post.md <span class="cta-arrow">→</span>
</div>
</div>
</a>
</article>
))}
</div>
<div style="text-align:center;margin-top:3rem">
<a href="/blog" class="btn btn-primary">View all entries →</a>
</div>
</div>
</section>
<style>
.recent-posts {
padding-bottom: 1rem;
padding-top: 15rem; }
.section-header { margin-bottom: 3rem; }
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.post-card { padding: 0; overflow: hidden; }
.post-card.featured {
border-color: rgba(0, 210, 190, 0.2);
}
.post-link {
display: block;
color: inherit;
height: 100%;
}
.post-link:hover .post-card-title { color: var(--petronas-teal); }
.post-link:hover .cta-arrow { transform: translateX(4px); }
.post-card-inner {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.75rem;
height: 100%;
}
.post-day {
font-size: 0.7rem;
color: var(--petronas-teal);
letter-spacing: 0.1em;
opacity: 0.7;
}
.post-card-title {
font-size: 1.15rem;
line-height: 1.3;
transition: color var(--transition-fast);
}
.post-card-desc {
font-size: 0.88rem;
line-height: 1.6;
flex: 1;
}
.post-card-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.post-card-tags { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.post-card-cta {
font-size: 0.75rem;
margin-top: 0.5rem;
letter-spacing: 0.05em;
}
.cta-arrow {
display: inline-block;
transition: transform var(--transition-fast);
}
@media (max-width: 900px) {
.posts-grid { grid-template-columns: 1fr; }
}
</style>