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