1
0
mirror of https://github.com/avinal/avinal.github.io.git synced 2026-07-03 23:30:09 +05:30

fix: add fallback for music album art

- remove setup page and fix album art

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-03-05 19:27:44 +05:30
committed by Morumotto
parent 9dd8b56aaa
commit 924b449301
6 changed files with 158 additions and 341 deletions
+40 -7
View File
@@ -346,9 +346,32 @@ const hero = lb.nowPlaying ?? lb.recentTracks[0] ?? null;
function coverUrl(listen: any): string { function coverUrl(listen: any): string {
const mbids = listen.track_metadata?.mbid_mapping ?? {}; const mbids = listen.track_metadata?.mbid_mapping ?? {};
return mbids.release_mbid const info = listen.track_metadata?.additional_info ?? {};
? `https://coverartarchive.org/release/${mbids.release_mbid}/front-250` const rid = mbids.release_mbid ?? mbids.caa_release_mbid ?? info.release_mbid;
: ""; if (rid) return `https://coverartarchive.org/release/${rid}/front-250`;
const rgid = mbids.release_group_mbid;
if (rgid) return `https://coverartarchive.org/release-group/${rgid}/front-250`;
return "";
}
async function fetchItunesArt(track: string, artist: string): Promise<string> {
try {
const q = encodeURIComponent(`${track} ${artist}`);
const res = await fetch(`https://itunes.apple.com/search?term=${q}&media=music&limit=1`);
if (!res.ok) return "";
const data = await res.json();
const url = data.results?.[0]?.artworkUrl100;
return url ? url.replace("100x100", "250x250") : "";
} catch { return ""; }
}
function attachArtFallback(img: HTMLImageElement, track: string, artist: string) {
img.addEventListener("error", async () => {
if (img.dataset.fallbackTried) { img.style.display = "none"; return; }
img.dataset.fallbackTried = "1";
const alt = await fetchItunesArt(track, artist);
if (alt) { img.src = alt; } else { img.style.display = "none"; }
}, { once: false });
} }
function esc(s: string): string { function esc(s: string): string {
@@ -371,7 +394,7 @@ const hero = lb.nowPlaying ?? lb.recentTracks[0] ?? null;
return ` return `
<div class="mc-hero"> <div class="mc-hero">
<div class="mc-art-wrap"> <div class="mc-art-wrap">
${cover ? `<img class="mc-art" src="${cover}" alt="" onerror="this.style.display='none'" />` : ""} ${cover ? `<img class="mc-art" src="${cover}" alt="" data-track="${track}" data-artist="${artist}" />` : `<img class="mc-art" src="" alt="" data-track="${track}" data-artist="${artist}" data-no-cover="1" style="display:none" />`}
<div class="mc-art-ph"> <div class="mc-art-ph">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" opacity="0.25"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" opacity="0.25"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div> </div>
@@ -390,9 +413,7 @@ const hero = lb.nowPlaying ?? lb.recentTracks[0] ?? null;
const meta = l.track_metadata ?? {}; const meta = l.track_metadata ?? {};
const cover = coverUrl(l); const cover = coverUrl(l);
const ago = l.listened_at ? timeAgo(l.listened_at) : ""; const ago = l.listened_at ? timeAgo(l.listened_at) : "";
const thumb = cover const thumb = `<img class="mc-thumb" src="${cover || ""}" alt="" loading="lazy" data-track="${esc(meta.track_name ?? "")}" data-artist="${esc(meta.artist_name ?? "")}" ${!cover ? 'data-no-cover="1" style="display:none"' : ""} />`;
? `<img class="mc-thumb" src="${cover}" alt="" loading="lazy" onerror="this.classList.add('mc-thumb-hide')" />`
: "";
return `<li class="mc-track"> return `<li class="mc-track">
${thumb} ${thumb}
<div class="mc-track-info"> <div class="mc-track-info">
@@ -435,6 +456,18 @@ const hero = lb.nowPlaying ?? lb.recentTracks[0] ?? null;
body.innerHTML = renderHero(hero, isLive) body.innerHTML = renderHero(hero, isLive)
+ `<ul class="mc-recent">${renderRecent(listens)}</ul>`; + `<ul class="mc-recent">${renderRecent(listens)}</ul>`;
body.querySelectorAll<HTMLImageElement>("img[data-track]").forEach((img) => {
const t = img.dataset.track ?? "";
const a = img.dataset.artist ?? "";
if (img.dataset.noCover === "1") {
fetchItunesArt(t, a).then((url) => {
if (url) { img.src = url; img.style.display = ""; }
});
} else {
attachArtFallback(img, t, a);
}
});
} catch { } catch {
// keep existing content on error // keep existing content on error
} }
-1
View File
@@ -6,7 +6,6 @@ const navLinks: { href: string; label: string; external?: boolean }[] = [
{ href: "/events", label: "Events" }, { href: "/events", label: "Events" },
{ href: "/meeting", label: "Meet" }, { href: "/meeting", label: "Meet" },
{ href: "https://todo.avinal.space/explore", label: "Memos", external: true }, { href: "https://todo.avinal.space/explore", label: "Memos", external: true },
{ href: "/setup", label: "Setup" },
]; ];
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
+51 -20
View File
@@ -38,9 +38,15 @@
"summary": "Builds for OpenShift & Developer Tools", "summary": "Builds for OpenShift & Developer Tools",
"location": "Bengaluru, India", "location": "Bengaluru, India",
"highlights": [ "highlights": [
"Serving as Team Lead for Builds for OpenShift — managing engineers, driving sprint planning, code reviews, and cross-functional collaboration", "Leading the Builds for OpenShift team — managing a group of engineers, owning sprint planning, backlog grooming, and cross-functional alignment with product and QE",
"Working on Builds for OpenShift based on the upstream Shipwright project — designing, maintaining, and releasing build features", "Architecting and shipping features for Builds for OpenShift (based on the upstream Shipwright project) from design proposals through implementation, testing, and GA releases",
"Maintainer of Tekton Results — contributing upstream to enhance observability and results storage for Tekton pipelines" "Driving Tekton Results upstream as a maintainer — improving observability, long-term storage, and query performance for Tekton pipeline results at scale",
"Representing the team in OpenShift-wide architecture discussions, coordinating with platform, CLI, and console teams on build-related integrations",
"Mentoring junior engineers through 1:1s, design reviews, and pair programming sessions to grow team capability"
],
"links": [
{ "label": "Shipwright", "url": "https://shipwright.io" },
{ "label": "Tekton Results", "url": "https://github.com/tektoncd/results" }
] ]
}, },
{ {
@@ -52,9 +58,14 @@
"summary": "Hybrid Cloud Engineering — Pipeline Service", "summary": "Hybrid Cloud Engineering — Pipeline Service",
"location": "Bengaluru, India", "location": "Bengaluru, India",
"highlights": [ "highlights": [
"Worked on Pipeline Service — a SaaS for pipelines leveraging kcp, Kubernetes/OpenShift, Tekton, and Argo CD", "Built and maintained Pipeline Service — a multi-tenant SaaS pipeline platform leveraging kcp, Kubernetes/OpenShift, Tekton, and Argo CD for managed CI/CD",
"Contributed to Tekton Results for long-term, efficient storage of PipelineRuns and TaskRuns", "Contributed to Tekton Results for efficient long-term storage of PipelineRuns and TaskRuns, improving query latency and reliability for large-scale clusters",
"Maintained Source-to-Image (S2I) and Shared Resource CSI Driver components in OpenShift" "Maintained Source-to-Image (S2I) and Shared Resource CSI Driver components in OpenShift, triaging bugs and shipping quarterly releases",
"Participated in on-call rotations, resolving production incidents across the pipeline stack and authoring runbooks for recurring issues",
"Collaborated with upstream Tekton community on design proposals and contributed patches accepted into the core project"
],
"links": [
{ "label": "Tekton", "url": "https://tekton.dev" }
] ]
}, },
{ {
@@ -66,8 +77,9 @@
"summary": "Pipeline Service Team", "summary": "Pipeline Service Team",
"location": "Bengaluru, India", "location": "Bengaluru, India",
"highlights": [ "highlights": [
"Designed and implemented the Minimal Tekton Server with unit tests — creates Tekton resources on Kubernetes/OpenShift clusters using the Tekton API", "Designed and implemented the Minimal Tekton Server (MKS) from scratch — a lightweight server that creates and manages Tekton resources on Kubernetes/OpenShift clusters",
"Built MKS Server, MKS CLI, and MKS Dashboard using Golang, Kubernetes, Tekton, and Redis" "Built the MKS CLI and MKS Dashboard in Golang, providing a developer-friendly interface for pipeline operations backed by Redis for state management",
"Wrote comprehensive unit and integration tests achieving high coverage, following test-driven development practices across the full stack"
] ]
}, },
{ {
@@ -79,8 +91,11 @@
"summary": "Developer Documentation", "summary": "Developer Documentation",
"location": "Shenzhen, Remote", "location": "Shenzhen, Remote",
"highlights": [ "highlights": [
"Redesigned developer and user documentation for the Apache APISIX project", "Redesigned the developer and user documentation for Apache APISIX, improving information architecture and discoverability for a fast-growing API gateway project",
"Created Katacoda tutorials for APISIX, collaborating with the community and integrating feedback" "Created interactive Katacoda tutorials that guided users through real APISIX deployments, collaborating closely with the community and incorporating feedback from maintainers"
],
"links": [
{ "label": "Apache APISIX", "url": "https://apisix.apache.org" }
] ]
}, },
{ {
@@ -92,9 +107,13 @@
"summary": "Build System & CI/CD Modernization", "summary": "Build System & CI/CD Modernization",
"location": "Remote", "location": "Remote",
"highlights": [ "highlights": [
"Upgraded the build system from Unix Makefile to CMake — build time reduced to 57 minutes (2× faster)", "Migrated the entire build system from legacy Unix Makefiles to modern CMake — cutting build time from 1015 minutes down to 57 minutes (2× improvement)",
"Migrated CI/CD from Travis CI to GitHub Actions — CI time reduced from 12 hours to 2025 minutes", "Replaced Travis CI with GitHub Actions for the CI/CD pipeline — reducing CI run time from 12 hours to 2025 minutes with parallel job execution and caching",
"Refactored and fixed years-old unit and functional testing code in C/C++ and PHP" "Refactored and fixed years-old unit and functional testing code across C/C++ and PHP, restoring test suites that had been broken or skipped for multiple releases"
],
"links": [
{ "label": "GSoC Project", "url": "https://summerofcode.withgoogle.com" },
{ "label": "Blog Series", "url": "https://avinal.space/posts/gsoc/gsoc-fossology" }
] ]
}, },
{ {
@@ -106,8 +125,8 @@
"summary": "Inventory & Billing Management", "summary": "Inventory & Billing Management",
"location": "Bengaluru, Remote", "location": "Bengaluru, Remote",
"highlights": [ "highlights": [
"Designed and developed an Inventory and Billing Management App using Spring Boot and PostgreSQL", "Designed and developed a full-stack Inventory and Billing Management application using Spring Boot, PostgreSQL, and Thymeleaf for a small-business client",
"Created REST API endpoints according to functional requirements" "Implemented RESTful API endpoints for CRUD operations on inventory items, invoices, and customer records according to functional specifications"
] ]
}, },
{ {
@@ -119,8 +138,12 @@
"summary": "VLC for Android Documentation", "summary": "VLC for Android Documentation",
"location": "Paris, Remote", "location": "Paris, Remote",
"highlights": [ "highlights": [
"Documented VLC for Android using Sphinx, reStructuredText, Markdown, and shell scripting", "Authored comprehensive documentation for VLC for Android using Sphinx and reStructuredText, covering build instructions, architecture overview, and contributor guidelines",
"Delivered user-friendly documentation with supporting screenshots and step-by-step tutorials" "Produced step-by-step tutorials with annotated screenshots, making the project accessible to new contributors and end users alike"
],
"links": [
{ "label": "GSoD Case Study", "url": "https://developers.google.com/season-of-docs" },
{ "label": "VideoLAN", "url": "https://www.videolan.org" }
] ]
}, },
{ {
@@ -131,18 +154,26 @@
"summary": "Mentoring & Open Source", "summary": "Mentoring & Open Source",
"location": "Remote", "location": "Remote",
"highlights": [ "highlights": [
"Mentoring GSoC students since 2022, guiding open source license compliance tooling projects" "Mentoring Google Summer of Code students since 2022 guiding contributors on open-source license compliance tooling, code review practices, and upstream collaboration",
"Helping students navigate the FOSSology codebase, define project milestones, and deliver production-quality patches accepted by the maintainer community"
],
"links": [
{ "label": "FOSSology", "url": "https://www.fossology.org" }
] ]
}, },
{ {
"name": "GNU C Library", "name": "GNU C Library",
"position": "Contributor", "position": "Contributor",
"url": "https://www.gnu.org/software/libc/", "url": "https://sourceware.org/glibc/",
"startDate": "2024-05-01", "startDate": "2024-05-01",
"summary": "GNU Project", "summary": "GNU Project",
"location": "Remote", "location": "Remote",
"highlights": [ "highlights": [
"Submitting patches to the GNU C Library (glibc)" "Contributing patches to glibc — one of the most critical pieces of the GNU/Linux ecosystem, used by virtually every Linux distribution",
"Working on bug fixes and improvements submitted via the Sourceware mailing list and reviewed by core glibc maintainers"
],
"links": [
{ "label": "Patches", "url": "https://sourceware.org/cgit/glibc/log/?qt=author&q=avinal" }
] ]
} }
], ],
+7 -4
View File
@@ -24,10 +24,13 @@ function parseTrack(listen: any): LBTrack {
const info = meta.additional_info ?? {}; const info = meta.additional_info ?? {};
const mbids = meta.mbid_mapping ?? {}; const mbids = meta.mbid_mapping ?? {};
const releaseMbid = mbids.release_mbid ?? info.release_mbid; const releaseMbid = mbids.release_mbid ?? mbids.caa_release_mbid ?? info.release_mbid;
const coverArtUrl = releaseMbid let coverArtUrl: string | undefined;
? `https://coverartarchive.org/release/${releaseMbid}/front-250` if (releaseMbid) {
: undefined; coverArtUrl = `https://coverartarchive.org/release/${releaseMbid}/front-250`;
} else if (mbids.release_group_mbid) {
coverArtUrl = `https://coverartarchive.org/release-group/${mbids.release_group_mbid}/front-250`;
}
const listenUrl = const listenUrl =
info.origin_url ?? info.origin_url ??
+60 -1
View File
@@ -9,12 +9,15 @@ function fmtDate(iso: string) {
return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); return d.toLocaleDateString("en-US", { month: "short", year: "numeric" });
} }
type ExtLink = { label: string; url: string };
type Role = { type Role = {
title: string; title: string;
startDate: string; startDate: string;
endDate?: string; endDate?: string;
summary?: string; summary?: string;
highlights?: string[]; highlights?: string[];
links?: ExtLink[];
}; };
type TimelineEntry = { type TimelineEntry = {
@@ -27,6 +30,7 @@ type TimelineEntry = {
endDate?: string; endDate?: string;
summary?: string; summary?: string;
highlights?: string[]; highlights?: string[];
links?: ExtLink[];
roles?: Role[]; roles?: Role[];
}; };
@@ -41,6 +45,7 @@ type FlatEntry = {
endDate?: string; endDate?: string;
summary?: string; summary?: string;
highlights?: string[]; highlights?: string[];
links?: ExtLink[];
}; };
/** /**
@@ -73,9 +78,9 @@ function groupToTimeline(entries: FlatEntry[]): TimelineEntry[] {
endDate: latest.endDate, endDate: latest.endDate,
summary: latest.summary, summary: latest.summary,
highlights: latest.highlights, highlights: latest.highlights,
links: latest.links,
}); });
} else { } else {
const earliest = sorted[sorted.length - 1];
result.push({ result.push({
type: latest.type, type: latest.type,
title: latest.subtitle, title: latest.subtitle,
@@ -90,6 +95,7 @@ function groupToTimeline(entries: FlatEntry[]): TimelineEntry[] {
endDate: e.endDate, endDate: e.endDate,
summary: e.summary, summary: e.summary,
highlights: e.highlights, highlights: e.highlights,
links: e.links,
})), })),
}); });
} }
@@ -108,6 +114,7 @@ const workFlat: FlatEntry[] = work.map((w: any) => ({
endDate: w.endDate, endDate: w.endDate,
summary: w.summary, summary: w.summary,
highlights: w.highlights, highlights: w.highlights,
links: w.links,
})); }));
const volunteerFlat: FlatEntry[] = volunteer.map((v: any) => ({ const volunteerFlat: FlatEntry[] = volunteer.map((v: any) => ({
@@ -216,6 +223,16 @@ const typeLabels: Record<string, string> = {
{role.highlights.map((h) => <li>{h}</li>)} {role.highlights.map((h) => <li>{h}</li>)}
</ul> </ul>
)} )}
{role.links && role.links.length > 0 && (
<div class="tl-links">
{role.links.map((lnk) => (
<a href={lnk.url} class="tl-ext-link" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{lnk.label}
</a>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -239,6 +256,16 @@ const typeLabels: Record<string, string> = {
{entry.highlights.map((h) => <li>{h}</li>)} {entry.highlights.map((h) => <li>{h}</li>)}
</ul> </ul>
)} )}
{entry.links && entry.links.length > 0 && (
<div class="tl-links">
{entry.links.map((lnk) => (
<a href={lnk.url} class="tl-ext-link" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{lnk.label}
</a>
))}
</div>
)}
</Fragment> </Fragment>
)} )}
</div> </div>
@@ -575,6 +602,38 @@ const typeLabels: Record<string, string> = {
font-weight: 700; font-weight: 700;
} }
/* ---- External links ---- */
.tl-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
.tl-ext-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--accent);
text-decoration: none;
padding: 2px var(--space-2);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: var(--radius-full);
transition: all var(--duration-fast) var(--ease-out);
}
.tl-ext-link:hover {
background: var(--accent-subtle);
border-color: var(--accent);
}
.tl-ext-link svg {
flex-shrink: 0;
opacity: 0.7;
}
/* ---- Grouped roles ---- */ /* ---- Grouped roles ---- */
.tl-org-title { .tl-org-title {
font-size: var(--text-lg); font-size: var(--text-lg);
-308
View File
@@ -1,308 +0,0 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
const hardware = [
{
category: "Servers",
items: [
{ name: "Raspberry Pi 5 8GB", url: "https://www.raspberrypi.com/products/raspberry-pi-5/", detail: "Primary server — runs most self-hosted services" },
{ name: "Raspberry Pi 4B 8GB", url: "https://www.raspberrypi.org/products/raspberry-pi-4-model-b/", detail: "Secondary server — backup and lighter workloads" },
],
},
{
category: "Storage",
items: [
{ name: "Samsung SSD 970 EVO Plus 500GB", url: "https://www.samsung.com/us/computing/memory-storage/solid-state-drives/ssd-970-evo-plus-nvme-m-2-500gb-mz-v7s500b-am/", detail: "NVMe on the Pi 5 via Pimoroni NVMe Base" },
{ name: "WD Blue SA510 SATA SSD M.2 500GB", url: "https://www.westerndigital.com/en-in/products/internal-drives/wd-blue-sa510-sata-m-2-ssd", detail: "Connected via USB enclosure to the Pi 4B" },
],
},
{
category: "Accessories",
items: [
{ name: "Raspberry Pi 27W USB-C PSU", url: "https://www.raspberrypi.com/products/27w-power-supply/", detail: "For the Pi 5" },
{ name: "Raspberry Pi 15W USB-C PSU", url: "https://www.raspberrypi.com/products/type-c-power-supply/", detail: "For the Pi 4B" },
{ name: "Pimoroni NVMe Base", url: "https://shop.pimoroni.com/products/nvme-base", detail: "NVMe SSD HAT for Pi 5" },
{ name: "PiBOX NVMe SSD Enclosure", url: "https://pibox.in/", detail: "USB 3.2 10Gbps enclosure" },
],
},
];
const selfHosted = [
{ name: "Immich", url: "https://immich.app/", desc: "Photo and video backup — Google Photos replacement", category: "Media" },
{ name: "Paisa", url: "https://paisa.fyi/", desc: "Personal finance and budget manager using plain text accounting", category: "Finance" },
{ name: "Vikunja", url: "https://vikunja.io/", desc: "Todo lists and kanban boards with CalDAV support", category: "Productivity" },
{ name: "Atuin", url: "https://atuin.sh/", desc: "Encrypted shell history sync across all machines", category: "Dev Tools" },
{ name: "Gitea", url: "https://about.gitea.com/", desc: "Self-hosted Git service — personal project mirror", category: "Dev Tools" },
{ name: "Paperless-ngx", url: "https://docs.paperless-ngx.com/", desc: "Document management with built-in OCR", category: "Documents" },
{ name: "Shiori", url: "https://github.com/go-shiori/shiori", desc: "Simple bookmark manager — Pocket alternative", category: "Bookmarks" },
{ name: "Memos", url: "https://usememos.com/", desc: "Lightweight note-taking — quick thoughts and snippets", category: "Notes" },
];
const networking = [
{ name: "Tailscale", url: "https://tailscale.com/", desc: "Mesh VPN connecting all devices securely" },
{ name: "RunTipi", url: "https://runtipi.io/", desc: "Docker Compose app manager for server administration" },
];
const softwareStack = [
{ category: "Editor", items: ["Neovim", "VS Code / Cursor"] },
{ category: "Terminal", items: ["Kitty", "Zsh", "Starship prompt", "Tmux"] },
{ category: "OS", items: ["Fedora (daily driver)", "Raspberry Pi OS (servers)"] },
{ category: "Browser", items: ["Firefox"] },
];
---
<BaseLayout title="Setup" description="Hardware, software, and self-hosted services powering Avinal's workflow">
<div class="setup-page">
<header class="setup-header">
<h1>Setup</h1>
<p class="setup-desc">
The hardware, software, and self-hosted services that power my workflow.
Heavily inspired by the homelab and self-hosting community.
</p>
</header>
<section class="setup-section">
<h2>Hardware</h2>
{hardware.map((group) => (
<div class="hw-group">
<h3 class="hw-category">{group.category}</h3>
<div class="hw-items">
{group.items.map((item) => (
<div class="hw-card">
<h4><a href={item.url}>{item.name}</a></h4>
<p>{item.detail}</p>
</div>
))}
</div>
</div>
))}
</section>
<section class="setup-section">
<h2>Self-Hosted Services</h2>
<p class="section-intro">
All services run on the Raspberry Pis, connected via Tailscale VPN.
Most are deployed using Docker Compose via RunTipi.
</p>
<div class="service-grid">
{selfHosted.map((svc) => (
<div class="service-card">
<span class="service-category">{svc.category}</span>
<h3><a href={svc.url}>{svc.name}</a></h3>
<p>{svc.desc}</p>
</div>
))}
</div>
</section>
<section class="setup-section">
<h2>Networking</h2>
<div class="service-grid service-grid-half">
{networking.map((svc) => (
<div class="service-card">
<h3><a href={svc.url}>{svc.name}</a></h3>
<p>{svc.desc}</p>
</div>
))}
</div>
</section>
<section class="setup-section">
<h2>Software Stack</h2>
<div class="sw-grid">
{softwareStack.map((group) => (
<div class="sw-group">
<h3 class="sw-category">{group.category}</h3>
<ul class="sw-list">
{group.items.map((item) => <li>{item}</li>)}
</ul>
</div>
))}
</div>
</section>
<div class="setup-footer">
<p class="text-muted text-sm">
For a deeper dive into my Raspberry Pi setup, check out
<a href="/posts/raspi/everything-on-my-pi/">Everything on my Pi</a>.
</p>
</div>
</div>
</BaseLayout>
<style>
.setup-page {
max-width: var(--max-w-page);
margin-inline: auto;
}
.setup-header {
margin-bottom: var(--space-10);
}
.setup-header h1 {
margin-bottom: var(--space-3);
}
.setup-desc {
font-size: var(--text-lg);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
max-width: var(--max-w-prose);
}
.setup-section {
margin-bottom: var(--space-12);
}
.setup-section > h2 {
font-size: var(--text-xl);
margin-bottom: var(--space-6);
padding-bottom: var(--space-2);
border-bottom: 2px solid var(--accent);
display: inline-block;
}
.section-intro {
color: var(--text-secondary);
margin-bottom: var(--space-6);
line-height: var(--leading-relaxed);
}
/* Hardware */
.hw-group {
margin-bottom: var(--space-6);
}
.hw-category {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: var(--space-3);
}
.hw-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.hw-card {
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
}
.hw-card h4 {
font-size: var(--text-sm);
font-weight: 600;
margin-bottom: var(--space-1);
}
.hw-card h4 a {
color: var(--accent);
text-decoration: none;
}
.hw-card h4 a:hover {
text-decoration: underline;
}
.hw-card p {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* Services */
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-4);
}
.service-grid-half {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
max-width: 640px;
}
.service-card {
padding: var(--space-5);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.service-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow);
}
.service-category {
display: inline-block;
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: var(--space-2);
}
.service-card h3 {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-2);
}
.service-card h3 a {
color: var(--text);
text-decoration: none;
}
.service-card h3 a:hover {
color: var(--accent);
}
.service-card p {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
/* Software stack */
.sw-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-6);
}
.sw-category {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: var(--space-3);
}
.sw-list {
list-style: disc;
padding-left: 1.2em;
font-size: var(--text-sm);
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.setup-footer {
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--border);
text-align: center;
}
</style>