Skip to content

Commit 8ba2f01

Browse files
Merge pull request #97 from Priya-creates/feat/add-favourite
Add favourites feature to Weather Dashboard (Fixes #19)
2 parents dba1132 + 576547d commit 8ba2f01

File tree

4 files changed

+291
-28
lines changed

4 files changed

+291
-28
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"lucide-react": "^0.546.0",
1414
"react": "^18.3.1",
1515
"react-dom": "^18.3.1",
16+
"react-icons": "^5.5.0",
1617
"react-leaflet": "^4.2.1",
1718
"react-router-dom": "^6.27.0"
1819
},

src/components/SprinkleEffect.jsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, { useEffect } from "react";
2+
3+
const SprinkleEffect = ({ trigger }) => {
4+
useEffect(() => {
5+
if (!trigger) return; // do nothing if trigger is null
6+
7+
const { x, y } = trigger;
8+
9+
// Create multiple spark dots
10+
for (let i = 0; i < 10; i++) {
11+
const dot = document.createElement("span");
12+
dot.classList.add("spark");
13+
14+
// Fixed positioning so it appears at correct spot
15+
dot.style.position = "fixed";
16+
dot.style.left = x + "px";
17+
dot.style.top = y + "px";
18+
dot.style.pointerEvents = "none"; // prevent interference with clicks
19+
document.body.appendChild(dot);
20+
21+
const angle = Math.random() * 2 * Math.PI;
22+
const distance = Math.random() * 30 + 10;
23+
const dx = Math.cos(angle) * distance;
24+
const dy = Math.sin(angle) * distance;
25+
26+
dot.animate(
27+
[
28+
{ transform: "translate(0,0)", opacity: 1 },
29+
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0 },
30+
],
31+
{ duration: 700, easing: "ease-out" }
32+
);
33+
34+
// Remove dot after animation
35+
setTimeout(() => dot.remove(), 700);
36+
}
37+
}, [trigger]);
38+
39+
return null; // nothing to render
40+
};
41+
42+
export default SprinkleEffect;
43+
44+

src/pages/Weather.jsx

Lines changed: 138 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,21 @@
2121
* - [ ] Extract API call into /src/services/weather.js and add caching
2222
*/
2323

24-
import { useEffect, useState } from "react";
24+
import { useEffect, useState, useRef } from "react";
2525
import Loading from "../components/Loading.jsx";
2626
import ErrorMessage from "../components/ErrorMessage.jsx";
2727
import Card from "../components/Card.jsx";
2828
import Skeleton from "../components/Skeleton.jsx";
29-
import HeroSection from '../components/HeroSection';
30-
import Cloud from '../Images/Cloud.jpg';
29+
import HeroSection from "../components/HeroSection";
30+
import Cloud from "../Images/Cloud.jpg";
3131
import {
3232
getWeatherData,
3333
clearWeatherCache,
3434
getCacheStats,
3535
} from "../services/weather.js";
36+
import { IoMdHeartEmpty } from "react-icons/io";
37+
import { IoMdHeart } from "react-icons/io";
38+
import SprinkleEffect from "../components/SprinkleEffect.jsx";
3639

3740
export default function Weather() {
3841
const [city, setCity] = useState("");
@@ -44,6 +47,10 @@ export default function Weather() {
4447
const [prevBg, setPrevBg] = useState(null);
4548
const [isLocAllowed, setIsLocAllowed] = useState(null);
4649
const [isRequestingLoc, setIsRequestingLoc] = useState(false);
50+
const [trigger, setTrigger] = useState(null);
51+
const [favourites, setFavourites] = useState([]);
52+
const [showFavourites, setShowFavourites] = useState(false);
53+
const btnRef = useRef(null);
4754

4855
useEffect(() => {
4956
const storedCity = localStorage.getItem("userLocation");
@@ -156,15 +163,15 @@ export default function Weather() {
156163
if (variant === "cloud") {
157164
return (
158165
<>
159-
<HeroSection
160-
image={Cloud}
161-
title={
162-
<>
163-
Weather <span style={{ color: "black" }}>Wonders</span>
164-
</>
165-
}
166-
subtitle="Stay ahead of the weather with real-time updates and accurate forecasts tailored just for you"
167-
/>
166+
<HeroSection
167+
image={Cloud}
168+
title={
169+
<>
170+
Weather <span style={{ color: "black" }}>Wonders</span>
171+
</>
172+
}
173+
subtitle="Stay ahead of the weather with real-time updates and accurate forecasts tailored just for you"
174+
/>
168175
<svg
169176
className="cloud-svg cloud--left"
170177
viewBox="0 0 220 80"
@@ -345,6 +352,44 @@ export default function Weather() {
345352
return { color: "#E0E0E0", label: "Clear 🌤️" };
346353
};
347354

355+
useEffect(() => {
356+
setFavourites(JSON.parse(localStorage.getItem("favourites")) || []);
357+
}, []);
358+
359+
// handle add to favourite
360+
const handleAddToFav = () => {
361+
const rect = btnRef.current.getBoundingClientRect();
362+
const x = rect.left + rect.width / 2;
363+
const y = rect.top + rect.height / 2;
364+
365+
// Set trigger
366+
setTrigger({ x, y });
367+
368+
// Reset trigger after a short delay to prevent unwanted reruns
369+
setTimeout(() => setTrigger(null), 50);
370+
371+
let updatedFav;
372+
const formattedCity = formatCityName(city);
373+
const isFav = favourites.some(
374+
(c) => c.toLowerCase() === city.toLowerCase()
375+
);
376+
updatedFav = isFav
377+
? favourites.filter((item) => item.toLowerCase() !== city.toLowerCase())
378+
: [...favourites, formattedCity];
379+
380+
setFavourites(updatedFav);
381+
localStorage.setItem("favourites", JSON.stringify(updatedFav));
382+
};
383+
384+
const isFav = favourites.some((c) => c.toLowerCase() === city.toLowerCase());
385+
386+
function formatCityName(str) {
387+
return str
388+
.split(" ")
389+
.map((word) => word[0].toUpperCase() + word.slice(1))
390+
.join(" ");
391+
}
392+
348393
return (
349394
<div
350395
className="weather-page"
@@ -390,7 +435,7 @@ export default function Weather() {
390435
</button>
391436
</form>
392437

393-
<div className="dev-tools">
438+
<div className="dev-tools" style={{ position: "relative" }}>
394439
<button onClick={handleClearCache} className="dev-btn">
395440
Clear Cache
396441
</button>
@@ -403,18 +448,70 @@ export default function Weather() {
403448
>
404449
Switch to °{unit === "C" ? "F" : "C"}
405450
</button>
451+
<button
452+
className="dev-btn"
453+
onClick={() => setShowFavourites((prev) => !prev)}
454+
>
455+
{showFavourites ? "Hide Favourites" : " See Favourites"}
456+
</button>
457+
{showFavourites && (
458+
<div className="favourites-dropdown">
459+
<select
460+
value=""
461+
onChange={(e) => {
462+
fetchWeather(e.target.value);
463+
setCity(e.target.value);
464+
setShowFavourites(false);
465+
}}
466+
>
467+
<option value="" disabled>
468+
Select a favourite
469+
</option>
470+
{favourites.map((fav, i) => (
471+
<option
472+
className="favourites-option"
473+
value={formatCityName(fav)}
474+
key={formatCityName(fav)}
475+
>
476+
{formatCityName(fav)}
477+
</option>
478+
))}
479+
</select>
480+
</div>
481+
)}
406482
</div>
407483

408484
{loading && <Loading />}
409485
{error && (
410-
<ErrorMessage message={error.message} onRetry={() => fetchWeather(city)} />
486+
<ErrorMessage
487+
message={error.message}
488+
onRetry={() => fetchWeather(city)}
489+
/>
411490
)}
412491

413492
{data && !loading && (
414493
<div className="dashboard-grid">
415494
{/* Current Weather */}
416495
<Card title="Current Weather" size="large">
417-
<h2>{data.nearest_area?.[0]?.areaName?.[0]?.value || city}</h2>
496+
<div
497+
style={{ display: "flex", gap: "1rem", alignItems: "baseline" }}
498+
>
499+
<h2>{data.nearest_area?.[0]?.areaName?.[0]?.value || city}</h2>
500+
<div
501+
className="fav-icon"
502+
ref={btnRef}
503+
onClick={handleAddToFav}
504+
title={isFav ? "Remove from favourites" : "Add to favourites"}
505+
>
506+
{isFav ? (
507+
<IoMdHeart size={18} color="#b22222" />
508+
) : (
509+
<IoMdHeartEmpty size={18} color="#b22222" />
510+
)}
511+
</div>
512+
<SprinkleEffect trigger={trigger} />
513+
</div>
514+
418515
<p style={{ display: "flex", alignItems: "center", gap: "12px" }}>
419516
{current && getIconUrl(current.weatherIconUrl) && (
420517
<img
@@ -446,23 +543,36 @@ export default function Weather() {
446543

447544
{/* 3-Day Forecast */}
448545
{forecast.map((day, i) => {
449-
const condition = day.hourly?.[0]?.weatherDesc?.[0]?.value || "Clear";
546+
const condition =
547+
day.hourly?.[0]?.weatherDesc?.[0]?.value || "Clear";
450548
const badge = getBadgeStyle(condition);
451549

452550
return (
453551
<Card key={i} title={i === 0 ? "Today" : `Day ${i + 1}`}>
454-
{day.hourly?.[0] && getIconUrl(day.hourly?.[0]?.weatherIconUrl) && (
455-
<div style={{ marginTop: 8 }}>
456-
<img
457-
src={getIconUrl(day.hourly?.[0]?.weatherIconUrl)}
458-
alt={day.hourly?.[0]?.weatherDesc?.[0]?.value || "forecast icon"}
459-
style={{ width: 40, height: 40, objectFit: "contain" }}
460-
onError={(e) => (e.currentTarget.style.display = "none")}
461-
/>
462-
</div>
463-
)}
464-
465-
<div style={{ display: "flex", gap: "8px", marginTop: "17px" }}>
552+
{day.hourly?.[0] &&
553+
getIconUrl(day.hourly?.[0]?.weatherIconUrl) && (
554+
<div style={{ marginTop: 8 }}>
555+
<img
556+
src={getIconUrl(day.hourly?.[0]?.weatherIconUrl)}
557+
alt={
558+
day.hourly?.[0]?.weatherDesc?.[0]?.value ||
559+
"forecast icon"
560+
}
561+
style={{
562+
width: 40,
563+
height: 40,
564+
objectFit: "contain",
565+
}}
566+
onError={(e) =>
567+
(e.currentTarget.style.display = "none")
568+
}
569+
/>
570+
</div>
571+
)}
572+
573+
<div
574+
style={{ display: "flex", gap: "8px", marginTop: "17px" }}
575+
>
466576
<strong>Avg Temp:</strong>{" "}
467577
{displayTemp(Number(day.avgtempC))}°{unit}
468578
<div

0 commit comments

Comments
 (0)