feat: add Spotify, Weather, and System widgets

This commit is contained in:
cediackermann 2026-04-20 18:27:24 +02:00
parent f78b058303
commit 4e5d897273
3 changed files with 233 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
import { SpotifyNowPlaying } from '../../types';
function fmtMs(ms: number) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
export const SpotifyWidget = () => {
const alwaysOn = useAlwaysOn();
const { data } = useQuery<SpotifyNowPlaying>({
queryKey: ['spotify-now-playing'],
queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()),
refetchInterval: alwaysOn ? 30_000 : 1_000,
});
return (
<div className="h-full flex flex-col justify-center">
<h2 className="text-sm uppercase tracking-wider text-gray-400 mb-1 font-normal">Now Playing</h2>
{data?.playing && data.track ? (
<>
<p className={`text-2xl leading-tight ${alwaysOn ? 'font-normal' : 'font-bold'}`}>{data.track.name}</p>
<p className="text-lg text-gray-300 mt-0.5">{data.track.artist}</p>
<p className="text-sm text-gray-500">{data.track.album}</p>
{!alwaysOn && (
<div className="mt-1.5 flex items-center gap-2">
<span className="text-xs text-gray-500 font-mono w-10 shrink-0">{fmtMs(data.track.progressMs)}</span>
<div className="flex-1 h-1.5 bg-zinc-800 relative">
<div
className="absolute inset-y-0 left-0 bg-white"
style={{ width: `${(data.track.progressMs / data.track.durationMs) * 100}%` }}
/>
</div>
<span className="text-xs text-gray-500 font-mono w-10 shrink-0 text-right">{fmtMs(data.track.durationMs)}</span>
</div>
)}
</>
) : (
<p className="text-gray-500 text-lg">Nothing playing</p>
)}
</div>
);
};

View file

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
interface SystemData {
cpu: { percent: number; load: string[] };
memory: { percent: number; usedGb: string; totalGb: string };
disk: { percent: number; usedGb: string; totalGb: string };
uptime: string;
hostname: string;
}
const Bar = ({ percent }: { percent: number }) => (
<div className="flex-1 h-1.5 bg-zinc-800 relative">
<div className="absolute inset-y-0 left-0 bg-white" style={{ width: `${percent}%` }} />
</div>
);
const Row = ({ label, percent, detail }: { label: string; percent: number; detail: string }) => (
<div className="flex items-center gap-3 text-sm font-mono">
<span className="w-10 text-gray-400 shrink-0">{label}</span>
<Bar percent={percent} />
<span className="w-8 text-right shrink-0">{percent}%</span>
<span className="text-gray-500 shrink-0">{detail}</span>
</div>
);
export const SystemWidget = () => {
const alwaysOn = useAlwaysOn();
const { data } = useQuery<SystemData>({
queryKey: ['system'],
queryFn: () => fetch('/api/system').then(r => r.json()),
refetchInterval: alwaysOn ? 60_000 : 2_000,
});
if (!data) return null;
return (
<div className="h-full flex flex-col justify-center gap-3">
<div className="flex justify-between items-baseline">
<span className="font-bold text-lg">{data.hostname}</span>
<span className="text-gray-500 text-sm font-mono">up {data.uptime}</span>
</div>
<Row label="CPU" percent={data.cpu.percent} detail={`load ${data.cpu.load[0]} ${data.cpu.load[1]} ${data.cpu.load[2]}`} />
<Row label="MEM" percent={data.memory.percent} detail={`${data.memory.usedGb} / ${data.memory.totalGb} GB`} />
<Row label="DISK" percent={data.disk.percent} detail={`${data.disk.usedGb} / ${data.disk.totalGb} GB`} />
</div>
);
};

View file

@ -0,0 +1,140 @@
import { useQuery } from '@tanstack/react-query';
import { WeatherData } from '../../types';
// 5-line ASCII art keyed by condition keyword
const ASCII: Record<string, string[]> = {
sunny: [
' \\ / ',
' .-. ',
' ― ( ) ―',
' `-\' ',
' / \\ ',
],
partlycloudy: [
' \\ / ',
' _ /"".--. ',
' \\_( { ) ',
' `-\'--\' ',
' ',
],
cloudy: [
' ',
' .--. ',
' .-( ). ',
'(___.__)__) ',
' ',
],
overcast: [
' ',
' .------. ',
'.-( ).',
'(__________.) ',
' ',
],
rain: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" ʻ ʻ ʻ ʻ ",
" ʻ ʻ ʻ ʻ ",
],
drizzle: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" , , , , ",
" ",
],
snow: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
' * * * ',
'* * * * ',
],
thunder: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
' / \\ ',
' / \\ ',
],
fog: [
' ',
' _ - _ - _ -',
' _ - _ - _ ',
' _ - _ - _ -',
' ',
],
sleet: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" * ʻ * ʻ ",
" ʻ * ʻ * ",
],
};
const MOON_CHAR: Record<string, string> = {
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
};
function getAsciiKey(desc: string): string {
const d = desc.toLowerCase();
if (d.includes('thunder')) return 'thunder';
if (d.includes('snow')) return 'snow';
if (d.includes('sleet')) return 'sleet';
if (d.includes('drizzle')) return 'drizzle';
if (d.includes('rain')) return 'rain';
if (d.includes('fog') || d.includes('mist') || d.includes('haze')) return 'fog';
if (d.includes('overcast')) return 'overcast';
if (d.includes('mostly cloudy') || d.includes('very cloudy')) return 'cloudy';
if (d.includes('partly cloudy') || d.includes('mostly sunny')) return 'partlycloudy';
if (d.includes('cloudy')) return 'cloudy';
if (d.includes('sunny') || d.includes('clear')) return 'sunny';
return 'cloudy';
}
export const WeatherWidget = () => {
const { data, isError } = useQuery<WeatherData>({
queryKey: ['weather'],
queryFn: () => fetch('/api/weather').then(r => r.json()),
refetchInterval: 10 * 60 * 1000,
});
if (isError || !data || (data as any).error) {
return <div className="text-gray-500 text-sm">Weather unavailable</div>;
}
const art = ASCII[getAsciiKey(data.description)] ?? ASCII.cloudy;
const moon = MOON_CHAR[data.moonPhase] ?? data.moonPhase;
return (
<div className="flex items-start gap-4">
<pre className="text-gray-300 text-[11px] leading-tight select-none font-mono">{art.join('\n')}</pre>
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold">{data.temperature}°C</span>
<span className="text-lg text-gray-300">{data.description}</span>
</div>
<div className="flex gap-4 text-sm text-gray-400 font-mono">
<span> {data.sunrise}</span>
<span> {data.sunset}</span>
<span title={data.moonPhase}>{moon} {data.moonPhase}</span>
</div>
{data.hourly.length > 0 && (
<div className="flex gap-3 mt-1">
{data.hourly.map((h, i) => (
<div key={i} className="text-center">
<div className="text-xs text-gray-500">{h.time}</div>
<div className="text-sm font-bold">{h.temp}°</div>
</div>
))}
</div>
)}
</div>
</div>
);
};