feat: enhance Sidebar with collapsible functionality and update WeatherMap styling

This commit is contained in:
2026-03-26 15:14:32 +08:00
parent aade40fea2
commit 8527ea4fa3
6 changed files with 302 additions and 107 deletions
+16 -3
View File
@@ -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);
+136 -76
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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="&copy; 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="&copy; OpenStreetMap contributors &copy; 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;
}
}
+15 -16
View File
@@ -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);
}