mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 02:06:47 +00:00
feat: enhance Sidebar with collapsible functionality and update WeatherMap styling
This commit is contained in:
@@ -2,7 +2,7 @@ import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { FaBars } from "react-icons/fa"
|
||||
import { FiLogOut } from "react-icons/fi"
|
||||
import { FiChevronLeft, FiChevronRight, FiLogOut } from "react-icons/fi"
|
||||
|
||||
import type { UserPublic } from "@/client"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
@@ -21,6 +21,7 @@ const Sidebar = () => {
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { logout } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,12 +83,24 @@ const Sidebar = () => {
|
||||
position="sticky"
|
||||
bg="bg.subtle"
|
||||
top={0}
|
||||
minW="xs"
|
||||
minW={collapsed ? "72px" : "xs"}
|
||||
w={collapsed ? "72px" : "xs"}
|
||||
h="100vh"
|
||||
p={4}
|
||||
transition="width 0.2s ease, min-width 0.2s ease"
|
||||
>
|
||||
<Box w="100%">
|
||||
<SidebarItems />
|
||||
<Flex justify={collapsed ? "center" : "flex-end"} mb={2}>
|
||||
<IconButton
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
{collapsed ? <FiChevronRight /> : <FiChevronLeft />}
|
||||
</IconButton>
|
||||
</Flex>
|
||||
<SidebarItems collapsed={collapsed} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ const items = [
|
||||
|
||||
interface SidebarItemsProps {
|
||||
onClose?: () => void
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
interface Item {
|
||||
@@ -24,7 +25,7 @@ interface Item {
|
||||
path: string
|
||||
}
|
||||
|
||||
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
const SidebarItems = ({ onClose, collapsed = false }: SidebarItemsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
|
||||
@@ -33,28 +34,32 @@ const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
: items
|
||||
|
||||
const listItems = finalItems.map(({ icon, title, path }) => (
|
||||
<RouterLink key={title} to={path} onClick={onClose}>
|
||||
<RouterLink key={title} to={path} onClick={onClose} title={title}>
|
||||
<Flex
|
||||
gap={4}
|
||||
px={4}
|
||||
gap={collapsed ? 0 : 4}
|
||||
px={collapsed ? 2 : 4}
|
||||
py={2}
|
||||
_hover={{
|
||||
background: "gray.subtle",
|
||||
}}
|
||||
alignItems="center"
|
||||
justifyContent={collapsed ? "center" : "flex-start"}
|
||||
fontSize="sm"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
{!collapsed && <Text ml={2}>{title}</Text>}
|
||||
</Flex>
|
||||
</RouterLink>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
|
||||
Menu
|
||||
</Text>
|
||||
{!collapsed && (
|
||||
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
|
||||
Menu
|
||||
</Text>
|
||||
)}
|
||||
<Box>{listItems}</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -7,8 +7,9 @@ export function MapInvalidator() {
|
||||
|
||||
useEffect(() => {
|
||||
// Invalidate size after mount and on window resize
|
||||
const timer = setTimeout(() => map.invalidateSize(), 100);
|
||||
const handleResize = () => map.invalidateSize();
|
||||
const invalidateWithoutPan = () => map.invalidateSize({ pan: false, animate: false });
|
||||
const timer = setTimeout(invalidateWithoutPan, 100);
|
||||
const handleResize = () => invalidateWithoutPan();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Spinner, Text, Box } from '@chakra-ui/react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, Popup, ScaleControl, ZoomControl } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './leaflet-overrides.css';
|
||||
import { WeatherService } from '@/client/sdk.gen';
|
||||
import { getWeatherIcon } from './weatherUtils';
|
||||
import { MapInvalidator, MapCenter } from './MapComponents';
|
||||
import { MapInvalidator } from './MapComponents';
|
||||
|
||||
type ApiNestedResponse<P> = { data?: P };
|
||||
|
||||
@@ -44,6 +44,61 @@ interface WeatherMapProps {
|
||||
// Singapore coordinates (centered on the island)
|
||||
const center: [number, number] = [1.3521, 103.8198];
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const formatTime = (timestamp?: string) => {
|
||||
if (!timestamp) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return date.toLocaleString('en-SG', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const makeTemperatureMarkerHtml = ({
|
||||
forecastIcon,
|
||||
temperature,
|
||||
cardinal,
|
||||
}: {
|
||||
forecastIcon: string;
|
||||
temperature: string;
|
||||
cardinal: string;
|
||||
}) => `
|
||||
<div class="weather-marker weather-marker--temp">
|
||||
<div class="weather-marker__icon">${forecastIcon}</div>
|
||||
<div class="weather-marker__temp">${temperature}</div>
|
||||
<div class="weather-marker__wind">${cardinal}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const makeForecastMarkerHtml = ({
|
||||
area,
|
||||
forecastIcon,
|
||||
}: {
|
||||
area: string;
|
||||
forecastIcon: string;
|
||||
}) => `
|
||||
<div class="weather-marker weather-marker--forecast">
|
||||
<div class="weather-marker__icon">${forecastIcon}</div>
|
||||
<div class="weather-marker__area">${escapeHtml(area)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windData }) => {
|
||||
// If props are not provided, fetch the data
|
||||
const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery<ApiNestedResponse<AirTemperaturePayload>>({
|
||||
@@ -102,6 +157,8 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
|
||||
return <Text>No map data available</Text>;
|
||||
}
|
||||
|
||||
const latestReadingTime = formatTime(latestTempReading.timestamp);
|
||||
|
||||
const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
|
||||
const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
|
||||
|
||||
@@ -122,80 +179,83 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
|
||||
.filter(f => f.location) as ForecastWithLocation[];
|
||||
|
||||
return (
|
||||
<MapContainer center={center} zoom={11} style={{ height: '500px', width: '100%' }}>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution="© OpenStreetMap contributors"
|
||||
/>
|
||||
<MapCenter center={center} />
|
||||
<MapInvalidator />
|
||||
|
||||
{/* Temperature markers */}
|
||||
{latestTempReading.data.map((reading: AirTempDataPoint) => {
|
||||
const station = tempStationMap.get(reading.stationId);
|
||||
if (!station || reading.value === null) return null;
|
||||
const windDeg = windDataMap.get(reading.stationId) as number | null | undefined;
|
||||
const cardinal = getCardinalDirection(windDeg);
|
||||
return (
|
||||
<Marker
|
||||
key={reading.stationId}
|
||||
position={[station.location.latitude, station.location.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: `
|
||||
<div style="background: rgba(255,255,255,0.95); border:2px solid #4A90E2; border-radius:8px; padding:4px 6px; font-size:11px; font-weight:bold; text-align:center; box-shadow:0 2px 4px rgba(0,0,0,0.2); min-width:60px; transform:translate(-50%,-100%);">
|
||||
<div style="font-size:14px; margin-bottom:2px;">
|
||||
${getWeatherIcon(forecastsWithLocation.find(f => f.area === station.name)?.forecast || 'Fair')}
|
||||
</div>
|
||||
<div style="color:#E53E3E; font-size:12px;">${reading.value.toFixed(1)}°C</div>
|
||||
<div style="color:#2D3748; font-size:10px;">${cardinal}</div>
|
||||
</div>
|
||||
`,
|
||||
className: 'custom-weather-marker',
|
||||
iconSize: [60,50],
|
||||
iconAnchor: [30,50]
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<Box p={1}>
|
||||
<Box fontWeight="bold">{station.name}</Box>
|
||||
<Box fontSize="sm">Temperature: {reading.value.toFixed(1)}°C</Box>
|
||||
<Box fontSize="sm">Wind Direction: {cardinal}</Box>
|
||||
</Box>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add forecast markers for areas without temperature stations */}
|
||||
{forecastsWithLocation
|
||||
.filter(f => !tempStations.some((s: AirTempStation) => s.name === f.area))
|
||||
.map(f => (
|
||||
<Marker
|
||||
key={f.area}
|
||||
position={[f.location.latitude, f.location.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: `
|
||||
<div style="background: rgba(255,255,255,0.95); border:2px solid #4A90E2; border-radius:8px; padding:4px 6px; font-size:11px; font-weight:bold; text-align:center; box-shadow:0 2px 4px rgba(0,0,0,0.2); min-width:60px; transform:translate(-50%,-100%);">
|
||||
<div style="font-size:14px; margin-bottom:2px;">
|
||||
${getWeatherIcon(f.forecast)}
|
||||
</div>
|
||||
<div style="color:#2D3748; font-size:12px;">${f.area}</div>
|
||||
</div>
|
||||
`,
|
||||
className: 'custom-weather-marker',
|
||||
iconSize: [60,50],
|
||||
iconAnchor: [30,50]
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<Box p={1}>
|
||||
<Box fontWeight="bold">{f.area}</Box>
|
||||
<Box fontSize="sm">Forecast: {f.forecast}</Box>
|
||||
</Box>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
<Box className="weather-map-shell">
|
||||
<Box className="weather-map-banner">
|
||||
<Text className="weather-map-title">Singapore Live Weather Map</Text>
|
||||
<Text className="weather-map-meta">
|
||||
Updated {latestReadingTime} | {tempStations.length} stations
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<MapContainer center={center} zoom={11} className="weather-map-container" zoomControl={false}>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution="© OpenStreetMap contributors © CARTO"
|
||||
/>
|
||||
<MapInvalidator />
|
||||
<ZoomControl position="bottomright" />
|
||||
<ScaleControl position="bottomleft" imperial={false} />
|
||||
|
||||
{/* Temperature markers */}
|
||||
{latestTempReading.data.map((reading: AirTempDataPoint) => {
|
||||
const station = tempStationMap.get(reading.stationId);
|
||||
if (!station || reading.value === null) return null;
|
||||
const windDeg = windDataMap.get(reading.stationId) as number | null | undefined;
|
||||
const cardinal = getCardinalDirection(windDeg);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={reading.stationId}
|
||||
position={[station.location.latitude, station.location.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: makeTemperatureMarkerHtml({
|
||||
forecastIcon: getWeatherIcon(forecastsWithLocation.find(f => f.area === station.name)?.forecast || 'Fair'),
|
||||
temperature: `${reading.value.toFixed(1)}°C`,
|
||||
cardinal,
|
||||
}),
|
||||
className: 'custom-weather-marker',
|
||||
iconSize: [78, 72],
|
||||
iconAnchor: [39, 72],
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<Box p={1}>
|
||||
<Box fontWeight="bold">{station.name}</Box>
|
||||
<Box fontSize="sm">Temperature: {reading.value.toFixed(1)}°C</Box>
|
||||
<Box fontSize="sm">Wind Direction: {cardinal}</Box>
|
||||
</Box>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add forecast markers for areas without temperature stations */}
|
||||
{forecastsWithLocation
|
||||
.filter(f => !tempStations.some((s: AirTempStation) => s.name === f.area))
|
||||
.map(f => (
|
||||
<Marker
|
||||
key={f.area}
|
||||
position={[f.location.latitude, f.location.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: makeForecastMarkerHtml({
|
||||
area: f.area,
|
||||
forecastIcon: getWeatherIcon(f.forecast),
|
||||
}),
|
||||
className: 'custom-weather-marker',
|
||||
iconSize: [92, 62],
|
||||
iconAnchor: [46, 62],
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<Box p={1}>
|
||||
<Box fontWeight="bold">{f.area}</Box>
|
||||
<Box fontSize="sm">Forecast: {f.forecast}</Box>
|
||||
</Box>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,48 @@
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.weather-map-shell {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(48, 72, 94, 0.18);
|
||||
background:
|
||||
radial-gradient(circle at 5% 10%, rgba(153, 205, 255, 0.18), transparent 36%),
|
||||
radial-gradient(circle at 95% 90%, rgba(255, 191, 133, 0.16), transparent 42%),
|
||||
linear-gradient(160deg, #f8fbff 0%, #f4f8ff 48%, #fdf7ef 100%);
|
||||
box-shadow: 0 16px 34px rgba(15, 37, 58, 0.14);
|
||||
}
|
||||
|
||||
.weather-map-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(48, 72, 94, 0.16);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.weather-map-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #1f2f45;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.weather-map-meta {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: #38516d;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.weather-map-container {
|
||||
height: min(58vh, 560px);
|
||||
min-height: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Remove default leaflet icon shadows */
|
||||
.leaflet-shadow-pane {
|
||||
display: none;
|
||||
@@ -11,12 +53,14 @@
|
||||
|
||||
/* Style popup better */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
box-shadow: 0 8px 18px rgba(16, 29, 43, 0.22);
|
||||
border: 1px solid rgba(35, 63, 91, 0.18);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 8px;
|
||||
margin: 10px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -29,3 +73,76 @@
|
||||
.leaflet-container {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.weather-marker {
|
||||
border-radius: 12px;
|
||||
padding: 6px 8px;
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(45, 89, 132, 0.34);
|
||||
box-shadow: 0 7px 15px rgba(16, 36, 58, 0.22);
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.weather-marker--temp {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(245, 250, 255, 0.94));
|
||||
}
|
||||
|
||||
.weather-marker--forecast {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(255, 248, 237, 0.95));
|
||||
}
|
||||
|
||||
.weather-marker__icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.weather-marker__temp {
|
||||
color: #be2d2d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.weather-marker__wind {
|
||||
color: #2b445d;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.weather-marker__area {
|
||||
color: #253c53;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
max-width: 92px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.weather-map-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.weather-map-container {
|
||||
height: 54vh;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.weather-marker {
|
||||
min-width: 58px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.weather-marker__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ async function listModels() {
|
||||
async function getModel(modelId: string) {
|
||||
try {
|
||||
const response = await ModelsService.getLocalModelV1ModelsModelIdGet({
|
||||
path: { model_id: modelId }
|
||||
modelId
|
||||
});
|
||||
console.log('Model details:', response.data);
|
||||
return response.data;
|
||||
console.log('Model details:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error getting model:', error);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async function getModel(modelId: string) {
|
||||
async function chatCompletion() {
|
||||
try {
|
||||
const response = await DefaultService.handleCompletionsV1ChatCompletionsPost({
|
||||
body: {
|
||||
requestBody: {
|
||||
model: 'llama2', // Replace with your model
|
||||
messages: [
|
||||
{
|
||||
@@ -50,8 +50,8 @@ async function chatCompletion() {
|
||||
]
|
||||
}
|
||||
});
|
||||
console.log('Chat response:', response.data);
|
||||
return response.data;
|
||||
console.log('Chat response:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error in chat completion:', error);
|
||||
}
|
||||
@@ -60,15 +60,14 @@ async function chatCompletion() {
|
||||
// Example: Transcribe audio
|
||||
async function transcribeAudio(audioFile: File) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioFile);
|
||||
formData.append('model', 'whisper-1');
|
||||
|
||||
const response = await AutomaticSpeechRecognitionService.transcribeFileV1AudioTranscriptionsPost({
|
||||
formData
|
||||
formData: {
|
||||
file: audioFile,
|
||||
model: 'whisper-1'
|
||||
}
|
||||
});
|
||||
console.log('Transcription:', response.data);
|
||||
return response.data;
|
||||
console.log('Transcription:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio:', error);
|
||||
}
|
||||
@@ -78,14 +77,14 @@ async function transcribeAudio(audioFile: File) {
|
||||
async function synthesizeSpeech(text: string, voice: string = 'default') {
|
||||
try {
|
||||
const response = await SpeechToTextService.synthesizeV1AudioSpeechPost({
|
||||
body: {
|
||||
requestBody: {
|
||||
model: 'tts-1',
|
||||
input: text,
|
||||
voice: voice
|
||||
}
|
||||
});
|
||||
console.log('Speech synthesis response:', response.data);
|
||||
return response.data;
|
||||
console.log('Speech synthesis response:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error synthesizing speech:', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user