diff --git a/src/components/organisms/SpotifyWidget.tsx b/src/components/organisms/SpotifyWidget.tsx new file mode 100644 index 0000000..a2d1736 --- /dev/null +++ b/src/components/organisms/SpotifyWidget.tsx @@ -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({ + queryKey: ['spotify-now-playing'], + queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()), + refetchInterval: alwaysOn ? 30_000 : 1_000, + }); + + return ( +
+

Now Playing

+ {data?.playing && data.track ? ( + <> +

{data.track.name}

+

{data.track.artist}

+

{data.track.album}

+ {!alwaysOn && ( +
+ {fmtMs(data.track.progressMs)} +
+
+
+ {fmtMs(data.track.durationMs)} +
+ )} + + ) : ( +

Nothing playing

+ )} +
+ ); +}; diff --git a/src/components/organisms/SystemWidget.tsx b/src/components/organisms/SystemWidget.tsx new file mode 100644 index 0000000..036bd7e --- /dev/null +++ b/src/components/organisms/SystemWidget.tsx @@ -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 }) => ( +
+
+
+); + +const Row = ({ label, percent, detail }: { label: string; percent: number; detail: string }) => ( +
+ {label} + + {percent}% + {detail} +
+); + +export const SystemWidget = () => { + const alwaysOn = useAlwaysOn(); + const { data } = useQuery({ + queryKey: ['system'], + queryFn: () => fetch('/api/system').then(r => r.json()), + refetchInterval: alwaysOn ? 60_000 : 2_000, + }); + + if (!data) return null; + + return ( +
+
+ {data.hostname} + up {data.uptime} +
+ + + +
+ ); +}; diff --git a/src/components/organisms/WeatherWidget.tsx b/src/components/organisms/WeatherWidget.tsx new file mode 100644 index 0000000..a59177b --- /dev/null +++ b/src/components/organisms/WeatherWidget.tsx @@ -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 = { + sunny: [ + ' \\ / ', + ' .-. ', + ' ― ( ) ―', + ' `-\' ', + ' / \\ ', + ], + partlycloudy: [ + ' \\ / ', + ' _ /"".--. ', + ' \\_( { ) ', + ' `-\'--\' ', + ' ', + ], + cloudy: [ + ' ', + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + ' ', + ], + overcast: [ + ' ', + ' .------. ', + '.-( ).', + '(__________.) ', + ' ', + ], + rain: [ + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + " ʻ ʻ ʻ ʻ ", + " ʻ ʻ ʻ ʻ ", + ], + drizzle: [ + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + " , , , , ", + " ", + ], + snow: [ + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + ' * * * ', + '* * * * ', + ], + thunder: [ + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + ' / \\ ', + ' / \\ ', + ], + fog: [ + ' ', + ' _ - _ - _ -', + ' _ - _ - _ ', + ' _ - _ - _ -', + ' ', + ], + sleet: [ + ' .--. ', + ' .-( ). ', + '(___.__)__) ', + " * ʻ * ʻ ", + " ʻ * ʻ * ", + ], +}; + +const MOON_CHAR: Record = { + '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({ + queryKey: ['weather'], + queryFn: () => fetch('/api/weather').then(r => r.json()), + refetchInterval: 10 * 60 * 1000, + }); + + if (isError || !data || (data as any).error) { + return
Weather unavailable
; + } + + const art = ASCII[getAsciiKey(data.description)] ?? ASCII.cloudy; + const moon = MOON_CHAR[data.moonPhase] ?? data.moonPhase; + + return ( +
+
{art.join('\n')}
+
+
+ {data.temperature}°C + {data.description} +
+
+ ↑ {data.sunrise} + ↓ {data.sunset} + {moon} {data.moonPhase} +
+ {data.hourly.length > 0 && ( +
+ {data.hourly.map((h, i) => ( +
+
{h.time}
+
{h.temp}°
+
+ ))} +
+ )} +
+
+ ); +};