Compare commits
33 Commits
v1.0.1-loc
...
debian12
Author | SHA1 | Date | |
---|---|---|---|
9b0f7daa2c | |||
0361a743ef | |||
f70629fa55 | |||
d84d876735 | |||
574f094bac | |||
71da186bd6 | |||
4f98bf4e9a | |||
43262846a5 | |||
b6b6146ad0 | |||
784822fa9a | |||
0da92ead3c | |||
16ea1aa4aa | |||
905dd6ad22 | |||
e67dce4113 | |||
5bc7bea4a1 | |||
59e6e2d9d7 | |||
65c7170acf | |||
2d1bbeb827 | |||
f6d29a279a | |||
f4fffb73ff | |||
e22fa64e69 | |||
c15d0869c6 | |||
bbbc8b9edd | |||
72754f5949 | |||
c1e96eb7d2 | |||
ae410bda2b | |||
400d77cd5a | |||
5708bfa1b3 | |||
71a29ad9de | |||
dbbca57f59 | |||
a2ee1b3d1f | |||
7d91eb64eb | |||
a3df60178b |
98
README.md
98
README.md
@@ -1,95 +1,15 @@
|
|||||||
# Weather App
|
# Weather App
|
||||||
|
|
||||||
This is a simple weather application that allows users to view current weather data for a specified location. The app is built using React and TypeScript, and it fetches weather data from an external API.
|
This version is only meant for publishing on the web. It is not meant for local development or use.
|
||||||
|
|
||||||
> For that we use the OpenWeatherMap API. You can get your own API key by signing up at [OpenWeatherMap](https://openweathermap.org/api).
|
You can find the web version of the Weather App at [https://weather.the1s.de](https://weather.the1s.de).
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Search for weather by city name
|
|
||||||
- Display current weather conditions including temperature, humidity, and wind speed
|
|
||||||
- Responsive design for mobile and desktop views
|
|
||||||
- Change API key preferences
|
|
||||||
- Display weather data in a user-friendly format
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
To install and run this application, you need the following tools:
|
|
||||||
|
|
||||||
- Git (for cloning the repository)
|
|
||||||
|
|
||||||
**and**
|
|
||||||
|
|
||||||
- Node.js (v14 or higher)
|
|
||||||
- npm (Node Package Manager)
|
|
||||||
|
|
||||||
**or**
|
|
||||||
|
|
||||||
- Docker (for running the app in a container)
|
|
||||||
|
|
||||||
### 1st step - Get the source code
|
|
||||||
**You can either clone the repository or download the latest release. Keep in mind that the cloned version may contain bugs.**
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone https://git.the1s.de/theis.gaedigk/weather-app.git
|
|
||||||
```
|
|
||||||
|
|
||||||
**or**
|
|
||||||
|
|
||||||
1. Download the latest release from the [releases page](https://git.the1s.de/theis.gaedigk/weather-app/releases/latest).
|
|
||||||
2. Unzip the downloaded file to your desired location.
|
|
||||||
|
|
||||||
#### 2nd step - Using Node.js and npm
|
|
||||||
|
|
||||||
1. Navigate to the frontend project directory:
|
|
||||||
```bash
|
|
||||||
cd weather-app/frontend
|
|
||||||
```
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
3. Start the development server:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
4. Open your browser and go to `http://localhost:7002` to view the app.
|
|
||||||
**Note:** There is also a backend server directory, which is currently not in use. - You can ignore it for now.
|
|
||||||
|
|
||||||
**or**
|
|
||||||
|
|
||||||
#### 2nd step - Using Docker
|
|
||||||
|
|
||||||
1. Navigate to the root path project directory:
|
|
||||||
```bash
|
|
||||||
cd weather-app
|
|
||||||
```
|
|
||||||
2. Run in a Docker container:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
3. Open your browser and go to `http://localhost:7002` to view the app.
|
|
||||||
**Note:** There is also a backend server directory, which is currently not in use. - You can ignore it for now.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Get an API key from [OpenWeatherMap](https://openweathermap.org/api).
|
|
||||||
2. Click on the "Set API Key" button in the header to enter your API key.
|
|
||||||
3. Enter a city name in the search bar and press Enter or click the "Get Weather" button.
|
|
||||||
|
|
||||||
**Now you can view the current weather data for the specified city!**
|
|
||||||
|
|
||||||
# Other Information
|
|
||||||
|
|
||||||
## Technologies Used
|
|
||||||
|
|
||||||
- React
|
|
||||||
- TypeScript
|
|
||||||
- Tailwind CSS
|
|
||||||
- OpenWeatherMap API
|
|
||||||
- Vite
|
|
||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**On this branch (main) you will find the latest version of the weather app, which includes several improvements and bug fixes. But it is not yet fully functional. The app is still in development, and some features may not work as expected.**
|
Currently hosted version: **development branch (latest)**
|
||||||
|
|
||||||
|
## Dev info
|
||||||
|
|
||||||
|
All changes that are made to this branch (`debian12`) will be automatically deployed to the [web version](https://weather.the1s.de) of the Weather App.
|
||||||
|
|
||||||
|
This is done by using an auto pull script (`auto-pull.sh`) that runs on the server where the web version is hosted.
|
6
auto-pull.sh
Normal file
6
auto-pull.sh
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /pfad/zu/deinem/repo
|
||||||
|
while true; do
|
||||||
|
git pull || echo "git pull failed"
|
||||||
|
sleep 10
|
||||||
|
done
|
63
backend/routes/api.js
Normal file
63
backend/routes/api.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
router.get("/fetchWeather", async (req, res) => {
|
||||||
|
const city = req.query.city;
|
||||||
|
const units = req.query.units;
|
||||||
|
const apiKey = process.env.API_KEY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locationResponse = await fetch(
|
||||||
|
`https://api.openweathermap.org/geo/1.0/direct?q=${city}&appid=${apiKey}`
|
||||||
|
);
|
||||||
|
if (!locationResponse.ok) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Error fetching location data. (Error: x32)",
|
||||||
|
success: "false",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const location = await locationResponse.json();
|
||||||
|
if (!location || !location[0]) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Location not found.",
|
||||||
|
success: "false",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = location[0].lat;
|
||||||
|
const lon = location[0].lon;
|
||||||
|
|
||||||
|
const weatherResponse = await fetch(
|
||||||
|
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=${units}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!weatherResponse.ok) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Unexpected error! (Error: x33)",
|
||||||
|
success: "false",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const weather = await weatherResponse.json();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
error: "false",
|
||||||
|
success: "Weather data fetched successfully!",
|
||||||
|
data: weather,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Server error",
|
||||||
|
success: "false",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
@@ -1,7 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import env from "dotenv";
|
import apiRouter from "./routes/api.js";
|
||||||
env.config();
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 7001;
|
const port = 7001;
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ app.use(express.urlencoded({ extended: true }));
|
|||||||
app.set("view engine", "ejs");
|
app.set("view engine", "ejs");
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use("/api", apiRouter);
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.render("index.ejs", { title: port });
|
res.render("index.ejs", { title: port });
|
||||||
});
|
});
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Backend | <%= title %></title>
|
<title>Backend | <%= title %></title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
You have reached the backend views index page.
|
<h1>You have reached the backend views index page!</h1>
|
||||||
|
<p>Currently, there is nothing to display!</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@@ -1,20 +1,29 @@
|
|||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
container_name: frontend
|
container_name: weather-frontend
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
ports:
|
||||||
- "7002:7002"
|
- "7002:7002"
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
environment:
|
environment:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# backend:
|
backend:
|
||||||
# container_name: backend
|
container_name: weather-backend
|
||||||
# build: ./backend
|
build: ./backend
|
||||||
# ports:
|
networks:
|
||||||
# - "7001:7001"
|
- proxynet
|
||||||
#volumes:
|
ports:
|
||||||
# - ./backend:/bikelane-backend
|
- "7001:7001"
|
||||||
# restart: unless-stopped
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxynet:
|
||||||
|
external: true
|
||||||
|
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jscookie": "^1.1.0",
|
"jscookie": "^1.1.0",
|
||||||
|
"lucide-react": "^0.536.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
@@ -3221,6 +3222,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.536.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
|
||||||
|
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
|
@@ -14,6 +14,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jscookie": "^1.1.0",
|
"jscookie": "^1.1.0",
|
||||||
|
"lucide-react": "^0.536.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { changeAPIcookie } from "../utils/changeAPIcookie";
|
import { changeAPIcookie } from "../utils/changeAPIcookie";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentAPIKey: string;
|
currentAPIKey: string;
|
||||||
@@ -38,20 +39,7 @@ const ChangeAPI: React.FC<Props> = ({ currentAPIKey, onClose }) => {
|
|||||||
API Key:
|
API Key:
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<svg
|
<KeyRound size={32} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="w-7 h-7 flex-shrink-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { myToast } from "../utils/toastify";
|
import { myToast } from "../utils/toastify";
|
||||||
|
import { Settings2, SunMoon, Thermometer } from "lucide-react";
|
||||||
|
|
||||||
const getInitialTheme = () => localStorage.getItem("theme") || "light";
|
const getInitialTheme = () => localStorage.getItem("theme") || "light";
|
||||||
const getInitialUnit = () => localStorage.getItem("unit") || "celsius";
|
const getInitialUnit = () => localStorage.getItem("unit") || "metric";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -35,43 +36,18 @@ const ChangePreferences: React.FC<Props> = ({ onClose }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl border border-blue-200 p-8">
|
<div className="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl border border-blue-200 p-8">
|
||||||
<h2 className="text-3xl font-extrabold mb-6 text-blue-700 flex items-center gap-2">
|
<h2 className="text-3xl font-extrabold mb-6 text-blue-700 flex items-center gap-2">
|
||||||
<svg
|
<Settings2 />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="w-7 h-7 text-blue-500"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Preferences
|
Preferences
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Unit */}
|
{/* Unit */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2 items-center gap-2">
|
<label className="block text-sm font-semibold text-gray-700 mb-2 items-center gap-2">
|
||||||
<svg
|
<Thermometer />
|
||||||
className="w-5 h-5 text-blue-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M8 12h8m-4-4v8"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Temperature Unit
|
Temperature Unit
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border-2 border-blue-200 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 transition"
|
className="cursor-pointer w-full border-2 border-blue-200 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 transition"
|
||||||
value={unit}
|
value={unit}
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -82,25 +58,12 @@ const ChangePreferences: React.FC<Props> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2 items-center gap-2">
|
<label className="cursor-pointer block text-sm font-semibold text-gray-700 mb-2 items-center gap-2">
|
||||||
<svg
|
<SunMoon />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="w-5 h-5 text-blue-400"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M3 8.25V18a2.25 2.25 0 0 0 2.25 2.25h13.5A2.25 2.25 0 0 0 21 18V8.25m-18 0V6a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 6v2.25m-18 0h18M5.25 6h.008v.008H5.25V6ZM7.5 6h.008v.008H7.5V6Zm2.25 0h.008v.008H9.75V6Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Theme
|
Theme
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border-2 border-blue-200 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 transition"
|
className="cursor-pointer w-full border-2 border-blue-200 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 transition"
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => setTheme(e.target.value)}
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -114,7 +77,7 @@ const ChangePreferences: React.FC<Props> = ({ onClose }) => {
|
|||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-400 text-white py-3 rounded-xl font-bold text-lg shadow-lg hover:from-blue-700 hover:to-blue-500 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
|
className="cursor-pointer w-full bg-gradient-to-r from-blue-600 to-blue-400 text-white py-3 rounded-xl font-bold text-lg shadow-lg hover:from-blue-700 hover:to-blue-500 transition-all focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
>
|
>
|
||||||
Save Preferences
|
Save Preferences
|
||||||
</button>
|
</button>
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ChangeAPI from "./ChangeAPI";
|
import ChangeAPI from "./ChangeAPI";
|
||||||
import ChangePreferences from "./ChangePreferences";
|
import ChangePreferences from "./ChangePreferences";
|
||||||
|
import { myToast } from "../utils/toastify";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import logo from "../assets/cloud-sun-fill.png";
|
import logo from "../assets/cloud-sun-fill.png";
|
||||||
|
import { Github, KeyRound, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const [apiCard, setApiCard] = useState(false);
|
const [apiCard, setApiCard] = useState(false);
|
||||||
@@ -20,58 +22,27 @@ const Header: React.FC = () => {
|
|||||||
className="w-10 h-10 drop-shadow-lg"
|
className="w-10 h-10 drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-4xl font-extrabold tracking-wide drop-shadow-lg">
|
<h1 className="text-4xl font-extrabold tracking-wide drop-shadow-lg">
|
||||||
Weather App
|
Weather App - <strong>Web</strong>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{" "}
|
{" "}
|
||||||
{/* Added container for buttons */}
|
{/* Added container for buttons */}
|
||||||
<button
|
<button
|
||||||
className="bg-white text-blue-700 font-bold px-6 py-3 rounded-xl shadow-lg hover:bg-blue-100 transition-all border border-blue-200 flex items-center gap-2"
|
className="bg-gray-200 text-gray-400 font-bold px-6 py-3 rounded-xl shadow-lg border border-gray-300 flex items-center gap-2 cursor-not-allowed opacity-60"
|
||||||
onClick={() => setApiCard(true)}
|
onClick={() => myToast("You don't need to set an API Key!", "info")}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg
|
<KeyRound />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="size-6"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Set API Key
|
Set API Key
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="bg-white text-blue-700 font-bold px-6 py-3 rounded-xl shadow-lg hover:bg-blue-100 transition-all border border-blue-200 flex items-center gap-2"
|
className="cursor-pointer bg-white text-blue-700 font-bold px-6 py-3 rounded-xl shadow-lg hover:bg-blue-100 transition-all border border-blue-200 flex items-center gap-2"
|
||||||
onClick={() => setPreferencesCard(true)}
|
onClick={() => setPreferencesCard(true)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg
|
<Settings2 />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="size-6"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Preferences
|
Preferences
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -80,23 +51,9 @@ const Header: React.FC = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<button className="bg-white text-blue-700 font-bold px-6 py-3 rounded-xl shadow-lg hover:bg-blue-100 transition-all border border-blue-200 flex items-center gap-2">
|
<button className="cursor-help bg-white text-blue-700 font-bold px-6 py-3 rounded-xl shadow-lg hover:bg-blue-100 transition-all border border-blue-200 flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<svg
|
<Github /> Docs
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="size-6"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Zm3.75 11.625a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Docs
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import sunriseIcon from "../assets/icons/sunrise-fill.svg";
|
import { Sunrise, Sunset } from "lucide-react";
|
||||||
import sunsetIcon from "../assets/icons/sunset-fill.svg";
|
import { getDateTime } from "../utils/utils";
|
||||||
|
|
||||||
const WeatherData: React.FC = () => {
|
const WeatherData: React.FC = () => {
|
||||||
const weatherRaw = localStorage.getItem("weather");
|
const weatherRaw = localStorage.getItem("weather");
|
||||||
@@ -53,7 +53,7 @@ const WeatherData: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-center gap-4 mb-2">
|
<div className="flex flex-col items-center gap-4 mb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img src={sunriseIcon} alt="Sunrise Icon" className="w-7 h-7" />
|
<Sunrise />
|
||||||
<span className="text-base text-blue-700 font-semibold">
|
<span className="text-base text-blue-700 font-semibold">
|
||||||
Sunrise:{" "}
|
Sunrise:{" "}
|
||||||
{weatherData?.sys?.sunrise
|
{weatherData?.sys?.sunrise
|
||||||
@@ -65,7 +65,7 @@ const WeatherData: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img src={sunsetIcon} alt="Sunset Icon" className="w-7 h-7" />
|
<Sunset />
|
||||||
<span className="text-base text-blue-700 font-semibold">
|
<span className="text-base text-blue-700 font-semibold">
|
||||||
Sunset:{" "}
|
Sunset:{" "}
|
||||||
{weatherData?.sys?.sunset
|
{weatherData?.sys?.sunset
|
||||||
@@ -76,6 +76,11 @@ const WeatherData: React.FC = () => {
|
|||||||
: "--:--"}
|
: "--:--"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-base text-gray-600 italic font-semibold">
|
||||||
|
Last updated: {getDateTime()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,16 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fetchWeather } from "../utils/apiFunc";
|
import { fetchWeather } from "../utils/apiFunc";
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import WeatherData from "./WeatherData";
|
import WeatherData from "./WeatherData";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
const WeatherCard: React.FC = () => {
|
const WeatherCard: React.FC = () => {
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [weatherData, setWeatherData] = useState(false);
|
const [weatherData, setWeatherData] = useState(false);
|
||||||
const getAPIKey = () => Cookies.get("apiKey") || "";
|
|
||||||
const getUnit = () => localStorage.getItem("unit") || "metric";
|
const getUnit = () => localStorage.getItem("unit") || "metric";
|
||||||
|
|
||||||
const handleCityChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCityChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -19,13 +17,10 @@ const WeatherCard: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setWeatherData(false);
|
||||||
toast
|
toast
|
||||||
.promise(fetchWeather(city, getAPIKey(), getUnit()), {
|
.promise(fetchWeather(city, getUnit()), {
|
||||||
pending: "Fetching weather data...",
|
pending: "Fetching weather data...",
|
||||||
success: "Weather data loaded successfully!",
|
|
||||||
error:
|
|
||||||
"Failed to load weather data. Please check your entered city name. (Error: x4040)",
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (localStorage.getItem("weather")) {
|
if (localStorage.getItem("weather")) {
|
||||||
@@ -33,7 +28,6 @@ const WeatherCard: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setWeatherData(false);
|
setWeatherData(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +48,9 @@ const WeatherCard: React.FC = () => {
|
|||||||
<p className="mb-2 text-gray-600">
|
<p className="mb-2 text-gray-600">
|
||||||
Current weather will be displayed here.
|
Current weather will be displayed here.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-2 text-gray-600">
|
||||||
|
<strong>You don't need an API Key!</strong>
|
||||||
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-4">
|
||||||
<label htmlFor="city" className="font-medium text-gray-700">
|
<label htmlFor="city" className="font-medium text-gray-700">
|
||||||
Enter City:
|
Enter City:
|
||||||
@@ -68,10 +65,10 @@ const WeatherCard: React.FC = () => {
|
|||||||
required
|
required
|
||||||
className="border border-blue-300 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-blue-50 text-blue-900 font-mono"
|
className="border border-blue-300 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-blue-50 text-blue-900 font-mono"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="cursor-pointer flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-400 text-white font-bold px-4 py-3 rounded-xl shadow-lg hover:from-blue-700 hover:to-blue-500 transition-all"
|
className="cursor-pointer flex-1 bg-gradient-to-r from-blue-600 to-blue-400 text-white font-bold px-4 py-3 rounded-xl shadow-lg hover:from-blue-700 hover:to-blue-500 transition-all"
|
||||||
>
|
>
|
||||||
Get Weather
|
Get Weather
|
||||||
</button>
|
</button>
|
||||||
@@ -82,23 +79,10 @@ const WeatherCard: React.FC = () => {
|
|||||||
setWeatherData(false);
|
setWeatherData(false);
|
||||||
localStorage.removeItem("weather");
|
localStorage.removeItem("weather");
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 bg-red-500 hover:bg-red-600 text-white rounded-xl p-3 shadow-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
className="cursor-pointer flex-shrink-0 bg-red-500 hover:bg-red-600 text-white rounded-xl p-3 shadow-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||||
aria-label="Close weather data"
|
aria-label="Close weather data"
|
||||||
>
|
>
|
||||||
<svg
|
<X />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="w-5 h-5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M6 18 18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,31 +1,31 @@
|
|||||||
import { myToast } from "./toastify";
|
import { myToast } from "./toastify";
|
||||||
|
|
||||||
export const fetchWeather = async (
|
export const fetchWeather = async (city: string, units: string) => {
|
||||||
city: string,
|
try {
|
||||||
apiKey: string,
|
const response = await fetch(
|
||||||
units: string
|
`https://backend.weather.the1s.de/api/fetchWeather?city=${encodeURIComponent(
|
||||||
) => {
|
city
|
||||||
// Get location data
|
)}&units=${encodeURIComponent(units)}`,
|
||||||
const location = await fetch(
|
{
|
||||||
`http://api.openweathermap.org/geo/1.0/direct?q=${city}&appid=${apiKey}`
|
method: "GET",
|
||||||
).then((response) => {
|
headers: {
|
||||||
if (response.status === 401) {
|
"Content-Type": "application/json",
|
||||||
myToast(
|
},
|
||||||
"You are not authorized to access this resource. Please check your API key. (Error: x4010)",
|
|
||||||
"error"
|
|
||||||
);
|
|
||||||
} else if (response.ok) {
|
|
||||||
return response.json();
|
|
||||||
} else {
|
|
||||||
myToast("Error fetching location data. (Error: x32)", "error");
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
const lat = location[0].lat;
|
|
||||||
const lon = location[0].lon;
|
|
||||||
|
|
||||||
// Get weather data
|
const responseData = await response.json();
|
||||||
const weather = await fetch(
|
|
||||||
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=${units}`
|
if (!response.ok) {
|
||||||
).then((response) => response.json());
|
myToast(responseData.error, "error");
|
||||||
localStorage.setItem("weather", JSON.stringify(weather));
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem("weather", JSON.stringify(responseData.data));
|
||||||
|
myToast(responseData.success, "success");
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = JSON.stringify(error);
|
||||||
|
myToast(errorMsg, "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
24
frontend/src/utils/utils.ts
Normal file
24
frontend/src/utils/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const getDateTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDate();
|
||||||
|
const month = now.toLocaleString("en-GB", { month: "long" });
|
||||||
|
const hours = now.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = now.getMinutes().toString().padStart(2, "0");
|
||||||
|
|
||||||
|
// Ordinal suffix
|
||||||
|
const getOrdinal = (n: number) => {
|
||||||
|
if (n > 3 && n < 21) return "th";
|
||||||
|
switch (n % 10) {
|
||||||
|
case 1:
|
||||||
|
return "st";
|
||||||
|
case 2:
|
||||||
|
return "nd";
|
||||||
|
case 3:
|
||||||
|
return "rd";
|
||||||
|
default:
|
||||||
|
return "th";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${day}${getOrdinal(day)} ${month}, ${hours}:${minutes}`;
|
||||||
|
};
|
@@ -1,12 +1,17 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
allowedHosts: ["weather.the1s.de"],
|
||||||
port: 7002,
|
port: 7002,
|
||||||
watch: {
|
watch: { usePolling: true },
|
||||||
usePolling: true,
|
hmr: {
|
||||||
|
host: "weather.the1s.de",
|
||||||
|
port: 7002,
|
||||||
|
protocol: "wss",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user