Fixes to strange bars on mobile, and addition of server which currently powers a new Last.FM-based music tracker.
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s

This commit is contained in:
Cameron Redmore 2025-08-08 09:53:34 +01:00
parent c65b8465b5
commit be5a61185b
No known key found for this signature in database
8 changed files with 662 additions and 1251 deletions

150
src/client.ts Normal file
View file

@ -0,0 +1,150 @@
import { treaty } from '@elysiajs/eden';
import type { App } from './server';
// Create Eden Treaty client using current page origin
const client = treaty<App>(window.location.origin);
interface LastSongImage {
size: 'small' | 'medium' | 'large' | 'extralarge';
'#text': string;
}
interface LastSong {
name: string;
artist: string;
album: string;
url: string;
nowPlaying: boolean;
image: LastSongImage[];
}
// Function to fetch and display last song
async function fetchLastSong(): Promise<void> {
try {
const { data, error } = await client.api['last-song'].get();
if (error) {
console.error('Error fetching last song:', error);
updateLastSongDisplay(null, 'Failed to load song data');
return;
}
const song = data as LastSong;
updateLastSongDisplay(song);
} catch (error) {
console.error('Network error:', error);
updateLastSongDisplay(null, 'Network error occurred');
}
}
// Function to select appropriate image size based on screen width
function getOptimalImageUrl(images: LastSongImage[]): string {
if (!images || images.length === 0) return '';
const screenWidth = window.innerWidth;
// Select image size based on screen width and device pixel ratio
const pixelRatio = window.devicePixelRatio || 1;
const effectiveWidth = screenWidth * pixelRatio;
let targetSize: string;
if (effectiveWidth <= 400) {
targetSize = 'small'; // 34px
} else if (effectiveWidth <= 800) {
targetSize = 'medium'; // 64px
} else if (effectiveWidth <= 1400) {
targetSize = 'large'; // 174px
} else {
targetSize = 'extralarge'; // 300px
}
// Find the target size, fallback to largest available
const targetImage = images.find(img => img.size === targetSize);
if (targetImage) return targetImage['#text'];
// Fallback: return the largest available image
const fallbackOrder = ['extralarge', 'large', 'medium', 'small'];
for (const size of fallbackOrder) {
const image = images.find(img => img.size === size);
if (image) return image['#text'];
}
return '';
}
// Function to update the UI with song information
function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): void {
const containers = [
document.getElementById('last-song-container'),
document.getElementById('last-song-container-mobile')
];
containers.forEach(container => {
if (!container) return;
if (errorMessage || !song) {
container.innerHTML = `
<div class="text-red-400 text-sm">
${errorMessage || 'No song data available'}
</div>
`;
return;
}
const statusText = song.nowPlaying ? 'Currently listening to' : 'Last listened to';
const imageUrl = getOptimalImageUrl(song.image);
const imageElement = imageUrl
? `<img src="${imageUrl}" alt="${song.name}" class="w-24 h-24 rounded-lg object-cover mx-auto border border-white/20" loading="lazy">`
: `<div class="w-16 h-16 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<svg class="w-8 h-8 text-cyan-300" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 3a1 1 0 00-1.447-.894L8.763 6H5a3 3 0 000 6h.28l1.771 5.316A1 1 0 008 18a1 1 0 001-1v-4.382l6.553 3.276A1 1 0 0017 15V3z" clip-rule="evenodd" />
</svg>
</div>`;
container.innerHTML = `
<div class="flex flex-col items-center space-y-3">
${imageElement}
<div class="text-center w-full">
<p class="text-sm text-cyan-300 font-medium">${statusText}</p>
<a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200">
<p class="font-semibold text-white">${song.name}</p>
<p class="text-sm text-gray-300">by ${song.artist}</p>
${song.album ? `<p class="text-xs text-white-400">from ${song.album}</p>` : ''}
</a>
</div>
</div>
`;
});
}
// Auto-refresh functionality
let refreshInterval: number;
function startAutoRefresh(): void {
// Fetch immediately
fetchLastSong();
// Then fetch every 30 seconds
refreshInterval = window.setInterval(fetchLastSong, 30000);
}
function stopAutoRefresh(): void {
if (refreshInterval) {
clearInterval(refreshInterval);
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
startAutoRefresh();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
// Export functions for manual control if needed
export { fetchLastSong, startAutoRefresh, stopAutoRefresh };

48
src/server.ts Normal file
View file

@ -0,0 +1,48 @@
import { Elysia, t } from 'elysia';
import { staticPlugin } from '@elysiajs/static';
const app = new Elysia()
.use(staticPlugin({
assets: 'dist',
prefix: ''
}))
.get('/api/last-song', async () => {
const apiKey = Bun.env.LASTFM_API_KEY;
const username = Bun.env.LASTFM_USERNAME;
if (!apiKey || !username) {
return new Response(
JSON.stringify({ error: 'Last.fm API key or username not configured' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`;
try {
const response = await fetch(url);
const data = await response.json();
const track = data.recenttracks.track[0];
return {
name: track.name,
artist: track.artist['#text'],
album: track.album['#text'],
url: track.url,
nowPlaying: track['@attr']?.nowplaying === 'true',
image: track.image || [],
};
} catch (error) {
console.error('Error fetching from Last.fm:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch last song' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
})
.listen(3000);
console.log(
`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`
);
export type App = typeof app;

View file

@ -2,11 +2,18 @@
@import "@fontsource-variable/exo-2";
/* Custom styles for the glassmorphism effect and background */
body {
html {
font-family: 'Exo 2 Variable', sans-serif;
/* Deep blue theme */
background: linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%);
overflow: hidden;
padding: 0;
margin: 0;
}
body {
overflow-x: hidden;
margin: 0;
padding: 0;
}
/* The main card with the glass effect */
@ -25,14 +32,16 @@ body {
filter: blur(100px);
z-index: -1;
animation: float 20s infinite ease-in-out alternate;
pointer-events: none;
}
.shape-1 {
width: 300px;
height: 300px;
background: rgba(76, 161, 175, 0.4);
top: -50px;
left: -50px;
top: 0;
left: 0;
transform: translate(-25%, -25%);
animation-duration: 25s;
}
@ -40,8 +49,9 @@ body {
width: 400px;
height: 400px;
background: rgba(108, 99, 255, 0.3);
bottom: -100px;
right: -100px;
bottom: 0;
right: 0;
transform: translate(25%, 25%);
animation-duration: 20s;
}