mirror of
https://github.com/avinal/avinal.github.io.git
synced 2026-07-04 07:40:09 +05:30
feat: add events page and music widget
- add music source from Listenbrainz, easter egg and evets page - update design of resume page Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
@@ -18,10 +18,15 @@ const hasWaka = activity.wakatime.available;
|
||||
<div class="activity-card card">
|
||||
<div class="activity-header">
|
||||
<h3 class="widget-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span class="icon-trigger" id="gol-trigger">
|
||||
<svg class="icon-heart" width="24" height="24" viewBox="0 0 24 24" fill="#e11d48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<svg class="icon-signal" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</span>
|
||||
Activity
|
||||
</h3>
|
||||
<span class="text-muted text-xs">past year</span>
|
||||
<span class="text-muted text-xs" id="activity-subtitle">past year</span>
|
||||
</div>
|
||||
|
||||
{activity.weeks.length > 0 ? (
|
||||
@@ -43,18 +48,18 @@ const hasWaka = activity.wakatime.available;
|
||||
|
||||
const hasGh = ghLevel > 0;
|
||||
const hasWk = wakaLvl > 0;
|
||||
const isSplit = hasGh && hasWk;
|
||||
|
||||
if (isSplit) {
|
||||
return (
|
||||
<div class="graph-cell split-cell" title={tip}>
|
||||
<div class="cell-gh" style={`background-color: var(--graph-${ghLevel})`}></div>
|
||||
<div class="cell-waka" style={`background-color: var(--waka-${wakaLvl})`}></div>
|
||||
</div>
|
||||
);
|
||||
let color: string;
|
||||
if (hasGh && hasWk) {
|
||||
const ghPct = Math.round((ghLevel / (ghLevel + wakaLvl)) * 100);
|
||||
color = `color-mix(in oklch, var(--graph-${ghLevel}) ${ghPct}%, var(--waka-${wakaLvl}))`;
|
||||
} else if (hasWk) {
|
||||
color = `var(--waka-${wakaLvl})`;
|
||||
} else {
|
||||
color = `var(--graph-${ghLevel})`;
|
||||
}
|
||||
const color = hasWk ? `var(--waka-${wakaLvl})` : `var(--graph-${ghLevel})`;
|
||||
return <div class="graph-cell" style={`background-color: ${color}`} title={tip}></div>;
|
||||
const isAlive = hasGh || hasWk;
|
||||
return <div class="graph-cell" style={`background-color: ${color}`} title={tip} data-alive={isAlive ? "1" : "0"}></div>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
@@ -68,6 +73,13 @@ const hasWaka = activity.wakatime.available;
|
||||
<div class="legend-cell" style="background-color: var(--graph-3)"></div>
|
||||
<div class="legend-cell" style="background-color: var(--graph-4)"></div>
|
||||
</span>
|
||||
{hasWaka && (
|
||||
<span class="legend-group">
|
||||
<span class="text-xs text-muted">Both</span>
|
||||
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-2) 50%, var(--waka-2))"></div>
|
||||
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-3) 50%, var(--waka-3))"></div>
|
||||
</span>
|
||||
)}
|
||||
{hasWaka && (
|
||||
<span class="legend-group">
|
||||
<div class="legend-cell" style="background-color: var(--waka-1)"></div>
|
||||
@@ -162,15 +174,6 @@ const hasWaka = activity.wakatime.available;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.split-cell {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cell-gh { width: 50%; height: 11px; border-radius: 2px 0 0 2px; }
|
||||
.cell-waka { width: 50%; height: 11px; border-radius: 0 2px 2px 0; }
|
||||
|
||||
.graph-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -229,4 +232,214 @@ const hasWaka = activity.wakatime.available;
|
||||
.stat-item.waka dd {
|
||||
color: var(--waka-3);
|
||||
}
|
||||
|
||||
/* Easter egg: heart icon trigger */
|
||||
.icon-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.icon-heart {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-signal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: opacity 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.icon-trigger:hover .icon-heart {
|
||||
opacity: 0.4;
|
||||
transform: scale(1);
|
||||
animation: heart-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.icon-trigger:hover .icon-signal {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:global(.gol-active) .icon-heart {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
animation: heart-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:global(.gol-active) .icon-signal {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes heart-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.88); }
|
||||
50% { opacity: 0.6; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
:global(.gol-active) .graph-cell {
|
||||
cursor: crosshair;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const trigger = document.getElementById("gol-trigger");
|
||||
const card = document.querySelector(".activity-card");
|
||||
const graphGrid = document.querySelector(".graph-grid") as HTMLElement | null;
|
||||
const subtitle = document.getElementById("activity-subtitle");
|
||||
|
||||
if (trigger && graphGrid && card) {
|
||||
let active = false;
|
||||
let tickId: number | null = null;
|
||||
let grid: number[][] = [];
|
||||
let saved: { bg: string; title: string }[][] = [];
|
||||
|
||||
const columns = () => graphGrid.querySelectorAll(".graph-col");
|
||||
|
||||
function readGrid(): number[][] {
|
||||
return Array.from(columns()).map((col) =>
|
||||
Array.from(col.querySelectorAll(".graph-cell")).map((c) => {
|
||||
if (c.getAttribute("data-alive") !== "1") return 0;
|
||||
return Math.random() < 0.45 ? 1 : 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
saved = Array.from(columns()).map((col) =>
|
||||
Array.from(col.querySelectorAll(".graph-cell")).map((c) => ({
|
||||
bg: (c as HTMLElement).style.backgroundColor,
|
||||
title: c.getAttribute("title") || "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function restoreState() {
|
||||
const cols = columns();
|
||||
cols.forEach((col, ci) => {
|
||||
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
|
||||
const el = c as HTMLElement;
|
||||
el.style.backgroundColor = saved[ci]?.[ri]?.bg || "";
|
||||
el.setAttribute("title", saved[ci]?.[ri]?.title || "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function countAlive(): number {
|
||||
return grid.reduce((s, col) => s + col.reduce((a, v) => a + v, 0), 0);
|
||||
}
|
||||
|
||||
function step() {
|
||||
const numC = grid.length;
|
||||
const numR = grid[0]?.length || 0;
|
||||
const out = grid.map((col) => col.map(() => 0));
|
||||
|
||||
for (let c = 0; c < numC; c++) {
|
||||
for (let r = 0; r < numR; r++) {
|
||||
let n = 0;
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
if (dc === 0 && dr === 0) continue;
|
||||
n += grid[(c + dc + numC) % numC][(r + dr + numR) % numR];
|
||||
}
|
||||
}
|
||||
const a = grid[c][r];
|
||||
out[c][r] = a ? (n === 2 || n === 3 ? 1 : 0) : n === 3 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
grid = out;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const deadColor = style.getPropertyValue("--graph-0").trim();
|
||||
const fallbackAlive = style.getPropertyValue("--accent").trim();
|
||||
|
||||
const cols = columns();
|
||||
cols.forEach((col, ci) => {
|
||||
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
|
||||
const el = c as HTMLElement;
|
||||
const alive = grid[ci]?.[ri];
|
||||
const origColor = saved[ci]?.[ri]?.bg;
|
||||
const isOrigAlive = origColor && origColor !== deadColor;
|
||||
el.style.backgroundColor = alive
|
||||
? (isOrigAlive ? origColor : fallbackAlive)
|
||||
: deadColor;
|
||||
el.setAttribute("title", "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
step();
|
||||
if (countAlive() === 0) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
render();
|
||||
tickId = window.setTimeout(tick, 280);
|
||||
}
|
||||
|
||||
function start() {
|
||||
saveState();
|
||||
grid = readGrid();
|
||||
|
||||
if (countAlive() < 10) {
|
||||
for (let c = 0; c < grid.length; c++) {
|
||||
for (let r = 0; r < (grid[0]?.length || 0); r++) {
|
||||
if (Math.random() < 0.3) grid[c][r] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active = true;
|
||||
card.classList.add("gol-active");
|
||||
if (subtitle) subtitle.textContent = "hover to bring cells alive";
|
||||
render();
|
||||
tickId = window.setTimeout(tick, 280);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (tickId !== null) clearTimeout(tickId);
|
||||
tickId = null;
|
||||
active = false;
|
||||
card.classList.remove("gol-active");
|
||||
if (subtitle) subtitle.textContent = "past year";
|
||||
restoreState();
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
active ? stop() : start();
|
||||
});
|
||||
|
||||
graphGrid.addEventListener("mousemove", (e) => {
|
||||
if (!active) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains("graph-cell")) return;
|
||||
|
||||
const col = target.parentElement;
|
||||
if (!col) return;
|
||||
const allCols = Array.from(columns());
|
||||
const ci = allCols.indexOf(col);
|
||||
const cells = Array.from(col.querySelectorAll(".graph-cell"));
|
||||
const ri = cells.indexOf(target);
|
||||
|
||||
if (ci < 0 || ri < 0) return;
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
const nc = ci + dc;
|
||||
const nr = ri + dr;
|
||||
if (nc >= 0 && nc < grid.length && nr >= 0 && nr < (grid[0]?.length || 0)) {
|
||||
grid[nc][nr] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,13 +7,10 @@ const year = new Date().getFullYear();
|
||||
<p class="footer-copy">
|
||||
© {year} Avinal Kumar
|
||||
</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/avinal" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<span class="footer-sep" aria-hidden="true">·</span>
|
||||
<a href="https://linkedin.com/in/avinal" target="_blank" rel="noopener noreferrer">LinkedIn</a>
|
||||
<span class="footer-sep" aria-hidden="true">·</span>
|
||||
<a href="/posts">Posts</a>
|
||||
</div>
|
||||
<a href="https://github.com/avinal/avinal.github.io/issues/new" class="footer-report" target="_blank" rel="noopener noreferrer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
Report an issue
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -39,24 +36,17 @@ const year = new Date().getFullYear();
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
.footer-report {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
.footer-report:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footer-sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* Conway's Game of Life widget.
|
||||
* Runs continuously; hovering over the canvas brings cells to life.
|
||||
* Resumes simulation after hover interaction.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="gol-card card">
|
||||
<h3 class="widget-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Game of Life
|
||||
</h3>
|
||||
<canvas id="gol-canvas"></canvas>
|
||||
<p class="gol-hint text-muted text-xs">hover to bring cells alive</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gol-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#gol-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: crosshair;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gol-card {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.gol-hint {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById("gol-canvas") as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const CELL = 8;
|
||||
const GAP = 1;
|
||||
const STEP = CELL + GAP;
|
||||
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let grid: Uint8Array;
|
||||
let next: Uint8Array;
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const newCols = Math.floor(rect.width / STEP);
|
||||
const newRows = Math.floor(rect.height / STEP);
|
||||
|
||||
if (newCols !== cols || newRows !== rows) {
|
||||
const oldGrid = grid;
|
||||
const oldCols = cols;
|
||||
const oldRows = rows;
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
grid = new Uint8Array(cols * rows);
|
||||
next = new Uint8Array(cols * rows);
|
||||
|
||||
if (oldGrid) {
|
||||
const mc = Math.min(oldCols, cols);
|
||||
const mr = Math.min(oldRows, rows);
|
||||
for (let r = 0; r < mr; r++) {
|
||||
for (let c = 0; c < mc; c++) {
|
||||
grid[r * cols + c] = oldGrid[r * oldCols + c];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seed() {
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
grid[i] = Math.random() < 0.3 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
function idx(r: number, c: number) {
|
||||
return ((r + rows) % rows) * cols + ((c + cols) % cols);
|
||||
}
|
||||
|
||||
function step() {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
let neighbors = 0;
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
neighbors += grid[idx(r + dr, c + dc)];
|
||||
}
|
||||
}
|
||||
const alive = grid[r * cols + c];
|
||||
next[r * cols + c] =
|
||||
alive ? (neighbors === 2 || neighbors === 3 ? 1 : 0) : (neighbors === 3 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
[grid, next] = [next, grid];
|
||||
}
|
||||
|
||||
function getColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
bg: style.getPropertyValue("--bg-surface").trim() || "#f8f9fa",
|
||||
alive: style.getPropertyValue("--accent").trim() || "#2563eb",
|
||||
dim: style.getPropertyValue("--border").trim() || "#e5e7eb",
|
||||
};
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const colors = getColors();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * STEP;
|
||||
const y = r * STEP;
|
||||
ctx.fillStyle = grid[r * cols + c] ? colors.alive : colors.dim;
|
||||
ctx.globalAlpha = grid[r * cols + c] ? 0.85 : 0.15;
|
||||
ctx.fillRect(x, y, CELL, CELL);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
let animId: number;
|
||||
let lastTick = 0;
|
||||
const TICK_MS = 300;
|
||||
|
||||
function loop(ts: number) {
|
||||
if (ts - lastTick >= TICK_MS) {
|
||||
step();
|
||||
draw();
|
||||
lastTick = ts;
|
||||
}
|
||||
animId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function paintCells(e: MouseEvent | TouchEvent) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX: number, clientY: number;
|
||||
if ("touches" in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const c = Math.floor(x / STEP);
|
||||
const r = Math.floor(y / STEP);
|
||||
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
const nr = r + dr;
|
||||
const nc = c + dc;
|
||||
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
|
||||
grid[nr * cols + nc] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let isDrawing = false;
|
||||
canvas.addEventListener("mouseenter", () => { isDrawing = true; });
|
||||
canvas.addEventListener("mouseleave", () => { isDrawing = false; });
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (isDrawing) paintCells(e);
|
||||
});
|
||||
canvas.addEventListener("touchmove", (e) => {
|
||||
e.preventDefault();
|
||||
paintCells(e);
|
||||
}, { passive: false });
|
||||
canvas.addEventListener("click", (e) => paintCells(e));
|
||||
|
||||
const resizeObs = new ResizeObserver(() => {
|
||||
resize();
|
||||
draw();
|
||||
});
|
||||
resizeObs.observe(canvas);
|
||||
|
||||
resize();
|
||||
animId = requestAnimationFrame(loop);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,237 @@
|
||||
---
|
||||
import type { LBData } from "@/lib/listenbrainz";
|
||||
|
||||
interface Props {
|
||||
lb: LBData;
|
||||
}
|
||||
|
||||
const { lb } = Astro.props;
|
||||
const profileUrl = `https://listenbrainz.org/user/${lb.username}`;
|
||||
const embedUrl = `https://listenbrainz.org/user/${lb.username}/embed/playing-now`;
|
||||
|
||||
function timeAgo(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
---
|
||||
|
||||
<div class="music-card card">
|
||||
<div class="music-header">
|
||||
<h3 class="widget-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<a href={profileUrl} target="_blank" rel="noopener noreferrer" class="lb-title-link">Listening</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{lb.available ? (
|
||||
<div class="lb-content">
|
||||
<div class="lb-now-embed">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title="Now playing on ListenBrainz"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
{lb.recentTracks.length > 0 && (
|
||||
<div class="lb-recent-section">
|
||||
<span class="lb-recent-label">Recent</span>
|
||||
<ul class="lb-recent">
|
||||
{lb.recentTracks.slice(0, 4).map((t) => (
|
||||
<li class="lb-track">
|
||||
{t.coverArtUrl && (
|
||||
<img
|
||||
class="lb-cover"
|
||||
src={t.coverArtUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
)}
|
||||
<div class="lb-info">
|
||||
<span class="lb-name">
|
||||
{t.listenUrl ? (
|
||||
<a href={t.listenUrl} target="_blank" rel="noopener noreferrer">{t.trackName}</a>
|
||||
) : t.trackName}
|
||||
</span>
|
||||
<span class="lb-artist">{t.artistName}</span>
|
||||
</div>
|
||||
<span class="lb-time">{timeAgo(t.listenedAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lb.recentTracks.length === 0 && (
|
||||
<div class="lb-empty">
|
||||
<span class="text-muted text-xs">No recent listens yet</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class="lb-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<span class="text-muted text-xs">Connect ListenBrainz to see listening activity</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.music-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.music-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lb-title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lb-title-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lb-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lb-now-embed {
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
height: 85px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.lb-now-embed iframe {
|
||||
width: calc(100% + 2px);
|
||||
height: 85px;
|
||||
border: none;
|
||||
display: block;
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
.lb-recent-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lb-recent-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lb-recent {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lb-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.lb-track:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.lb-cover {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.lb-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lb-name {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lb-name a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lb-name a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lb-artist {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lb-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lb-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.music-card {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ const navLinks: { href: string; label: string; external?: boolean }[] = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/posts", label: "Posts" },
|
||||
{ href: "/resume", label: "Resume" },
|
||||
{ href: "/events", label: "Events" },
|
||||
{ href: "/meeting", label: "Meet" },
|
||||
{ href: "https://todo.avinal.space/explore", label: "Memos", external: true },
|
||||
{ href: "/setup", label: "Setup" },
|
||||
|
||||
Reference in New Issue
Block a user