Workspaces in Workspaces all around
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { MapPin, Settings } from 'lucide-react';
|
||||
import { Widget } from '../Widget';
|
||||
import { getWeatherInfo } from './weather-codes';
|
||||
|
||||
type WeatherLocation = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GeocodingResult = {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
admin1?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type CurrentWeather = {
|
||||
temperature_2m: number;
|
||||
apparent_temperature: number;
|
||||
weather_code: number;
|
||||
wind_speed_10m: number;
|
||||
relative_humidity_2m: number;
|
||||
is_day: number;
|
||||
};
|
||||
|
||||
type DailyWeather = {
|
||||
time: string[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
weather_code: number[];
|
||||
};
|
||||
|
||||
type ForecastResponse = {
|
||||
current: CurrentWeather;
|
||||
daily: DailyWeather;
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
const LOCATION_STATE_KEY = 'weather-location';
|
||||
|
||||
function useWeatherLocation() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const location = (state[LOCATION_STATE_KEY] as WeatherLocation | undefined) ?? null;
|
||||
|
||||
const setLocation = useCallback(
|
||||
(loc: WeatherLocation | null) => {
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [LOCATION_STATE_KEY]: loc });
|
||||
clientRef.current.patch('/user/state', { [LOCATION_STATE_KEY]: loc }).catch(() => {});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return [location, setLocation] as const;
|
||||
}
|
||||
|
||||
const LocationSetup = ({ onSelect }: { onSelect: (loc: WeatherLocation) => void }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [geoLoading, setGeoLoading] = useState(false);
|
||||
|
||||
const { data: results = [] } = useQuery<GeocodingResult[]>({
|
||||
queryKey: ['geocoding', query],
|
||||
enabled: query.length >= 2,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5`);
|
||||
const data = await res.json();
|
||||
return data.results ?? [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const useGeolocation = () => {
|
||||
if (!navigator.geolocation) return;
|
||||
setGeoLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const { latitude, longitude } = pos.coords;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${latitude.toFixed(2)},${longitude.toFixed(2)}&count=1`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const name = data.results?.[0]?.name ?? `${latitude.toFixed(2)}, ${longitude.toFixed(2)}`;
|
||||
onSelect({ latitude, longitude, name });
|
||||
} catch {
|
||||
onSelect({ latitude, longitude, name: `${latitude.toFixed(2)}, ${longitude.toFixed(2)}` });
|
||||
} finally {
|
||||
setGeoLoading(false);
|
||||
}
|
||||
},
|
||||
() => setGeoLoading(false),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:bg-accent transition-colors cursor-pointer disabled:opacity-50"
|
||||
onClick={useGeolocation}
|
||||
disabled={geoLoading}
|
||||
>
|
||||
<MapPin size={14} />
|
||||
{geoLoading ? 'Locating...' : 'Use my location'}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search city..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => onSelect({ latitude: r.latitude, longitude: r.longitude, name: r.name })}
|
||||
>
|
||||
<MapPin size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
{r.name}
|
||||
{r.admin1 ? `, ${r.admin1}` : ''}, {r.country}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dayName = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
};
|
||||
|
||||
const WeatherDisplay = ({ location, onReset }: { location: WeatherLocation; onReset: () => void }) => {
|
||||
const { data, isLoading } = useQuery<ForecastResponse>({
|
||||
queryKey: ['weather', location.latitude, location.longitude],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
latitude: String(location.latitude),
|
||||
longitude: String(location.longitude),
|
||||
current: 'temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,is_day',
|
||||
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
|
||||
timezone: 'auto',
|
||||
forecast_days: '5',
|
||||
});
|
||||
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 15 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<div className="h-16 animate-pulse rounded bg-muted" />
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 flex-1 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { current, daily } = data;
|
||||
const info = getWeatherInfo(current.weather_code, current.is_day === 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{info.icon}</span>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums">{Math.round(current.temperature_2m)}°C</div>
|
||||
<div className="text-xs text-muted-foreground">{info.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={onReset}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>Feels {Math.round(current.apparent_temperature)}°C</span>
|
||||
<span>💧 {current.relative_humidity_2m}%</span>
|
||||
<span>💨 {Math.round(current.wind_speed_10m)} km/h</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin size={10} />
|
||||
<span>{location.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{daily.time.map((date, i) => {
|
||||
const dayInfo = getWeatherInfo(daily.weather_code[i]!, true);
|
||||
return (
|
||||
<div key={date} className="flex flex-1 flex-col items-center gap-0.5 rounded-md bg-muted/50 py-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">{dayName(date)}</span>
|
||||
<span className="text-sm">{dayInfo.icon}</span>
|
||||
<span className="text-[10px] tabular-nums">
|
||||
{Math.round(daily.temperature_2m_max[i]!)}° / {Math.round(daily.temperature_2m_min[i]!)}°
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Weather = () => {
|
||||
const [location, setLocation] = useWeatherLocation();
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
|
||||
if (!location || showSetup) {
|
||||
return (
|
||||
<Widget title="Weather">
|
||||
<LocationSetup
|
||||
onSelect={(loc) => {
|
||||
setLocation(loc);
|
||||
setShowSetup(false);
|
||||
}}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Weather">
|
||||
<WeatherDisplay
|
||||
location={location}
|
||||
onReset={() => setShowSetup(true)}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user