RC: (upload) astro initial structure
This commit is contained in:
338
src/components/HomelabStatus.astro
Normal file
338
src/components/HomelabStatus.astro
Normal 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>
|
||||
334
src/components/ParallaxHero.astro
Normal file
334
src/components/ParallaxHero.astro
Normal 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>
|
||||
131
src/components/RecentPosts.astro
Normal file
131
src/components/RecentPosts.astro
Normal 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>
|
||||
Reference in New Issue
Block a user