feat: add Spotify, Weather, and System widgets
This commit is contained in:
parent
f78b058303
commit
4e5d897273
3 changed files with 233 additions and 0 deletions
45
src/components/organisms/SpotifyWidget.tsx
Normal file
45
src/components/organisms/SpotifyWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/organisms/SystemWidget.tsx
Normal file
48
src/components/organisms/SystemWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/components/organisms/WeatherWidget.tsx
Normal file
140
src/components/organisms/WeatherWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue