diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fc9e618..f8a4675 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,6 +35,9 @@ jobs: ai_image_gen) echo "EXTRA_ARGS=--env REPLICATE_API_TOKEN=${{ secrets.REPLICATE_API_TOKEN }}" >> $GITHUB_ENV ;; + weatherstack_app) + echo "EXTRA_ARGS=" >> $GITHUB_ENV + ;; text_annotation_app) echo "EXTRA_ARGS=" >> $GITHUB_ENV ;; diff --git a/templates.json b/templates.json index fdc4076..a092be8 100644 --- a/templates.json +++ b/templates.json @@ -165,6 +165,13 @@ "demo_url": "https://company-dashboard-navy-book.reflex.run/", "hidden": false, "reflex_build": true + }, + { + "name": "weatherstack_app", + "description": "A minimal weather app", + "demo_url": "https://company-dashboard-navy-book.reflex.run/", + "hidden": false, + "reflex_build": true }, { "name": "stock_graph_app", diff --git a/weatherstack_app/.gitignore b/weatherstack_app/.gitignore new file mode 100644 index 0000000..4a47328 --- /dev/null +++ b/weatherstack_app/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +.web +assets/external/ +*.py[cod] +*.db +.states +.DS_Store +.idea/ diff --git a/weatherstack_app/assets/favicon.ico b/weatherstack_app/assets/favicon.ico new file mode 100644 index 0000000..166ae99 Binary files /dev/null and b/weatherstack_app/assets/favicon.ico differ diff --git a/weatherstack_app/blocks/__init__.py b/weatherstack_app/blocks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/weatherstack_app/requirements.txt b/weatherstack_app/requirements.txt new file mode 100644 index 0000000..63ad58d --- /dev/null +++ b/weatherstack_app/requirements.txt @@ -0,0 +1,2 @@ +reflex>=0.7.10 +httpx diff --git a/weatherstack_app/rxconfig.py b/weatherstack_app/rxconfig.py new file mode 100644 index 0000000..05d6532 --- /dev/null +++ b/weatherstack_app/rxconfig.py @@ -0,0 +1,3 @@ +import reflex as rx + +config = rx.Config(app_name="weatherstack_app") diff --git a/weatherstack_app/weatherstack_app/__init__.py b/weatherstack_app/weatherstack_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/weatherstack_app/weatherstack_app/components/__init__.py b/weatherstack_app/weatherstack_app/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/weatherstack_app/weatherstack_app/components/preset_cards.py b/weatherstack_app/weatherstack_app/components/preset_cards.py new file mode 100644 index 0000000..40d49f9 --- /dev/null +++ b/weatherstack_app/weatherstack_app/components/preset_cards.py @@ -0,0 +1,36 @@ +import reflex as rx + +from weatherstack_app.states.weather_state import WeatherState + + +def card(flag: str, title: str, city: str) -> rx.Component: + return rx.el.button( + rx.el.div( + rx.el.span(flag, class_name="text-xl"), + rx.el.p(title, class_name="font-medium text-black text-base"), + class_name="flex flex-row gap-2 items-center", + ), + type="button", + class_name=( + "flex flex-col gap-1 border bg-white hover:bg-gray-100 " + "shadow-sm px-4 py-3.5 rounded-xl text-start transition-colors flex-1" + ), + on_click=WeatherState.get_weather_from_preset(city), + ) + + +def preset_cards() -> rx.Component: + return rx.el.div( + rx.el.div( + card("🇯🇵", "Tokyo, Japan", "Japan"), + card("🇫🇷", "Paris, France", "France"), + card("🇺🇸", "New York, USA", "USA"), + card("🇦🇺", "Sydney, Australia", "Australia"), + card("🇩🇪", "Berlin, Germany", "Germany"), + card("🇧🇷", "São Paulo, Brazil", "Brazil"), + card("🇨🇦", "Toronto, Canada", "Canada"), + card("🇮🇳", "Mumbai, India", "India"), + class_name="gap-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 w-full", + ), + class_name="flex flex-col justify-center items-center gap-8 w-full max-w-[55rem] px-6 md:pt-12", + ) diff --git a/weatherstack_app/weatherstack_app/components/weather_display.py b/weatherstack_app/weatherstack_app/components/weather_display.py new file mode 100644 index 0000000..a571075 --- /dev/null +++ b/weatherstack_app/weatherstack_app/components/weather_display.py @@ -0,0 +1,111 @@ +import reflex as rx + +from weatherstack_app.states.weather_state import WeatherState + + +def weather_display() -> rx.Component: + return rx.el.div( + rx.cond( + WeatherState.loading, + rx.el.div( + rx.spinner(class_name="text-blue-500"), + rx.el.p( + "Loading weather data...", + class_name="text-lg text-gray-600", + ), + class_name="flex flex-col items-center justify-center p-6 bg-white rounded-lg shadow-md", + ), + rx.cond( + WeatherState.error_message != "", + rx.el.div( + rx.el.p( + "Error:", + class_name="font-semibold text-red-600", + ), + rx.el.p( + WeatherState.error_message, + class_name="text-red-500", + ), + class_name="p-6 bg-red-50 rounded-lg shadow-md border border-red-200", + ), + rx.cond( + WeatherState.display_weather, + rx.el.div( + rx.el.p( + f"{WeatherState.weather_data['location']['name']}, {WeatherState.weather_data['location']['country']}", + class_name="text-3xl font-bold text-gray-800 mb-4", + ), + rx.el.div( + rx.el.img( + src=WeatherState.weather_icon_url, + alt="Weather icon", + class_name="w-20 h-20 mb-2", + ), + rx.el.p( + WeatherState.weather_description_text, + class_name="text-xl text-gray-700 capitalize", + ), + class_name="flex flex-col items-center mb-4", + ), + rx.el.div( + rx.el.div( + rx.el.p( + "Temperature:", + class_name="text-md text-gray-600", + ), + rx.el.p( + f"{WeatherState.weather_data['current']['temperature']}°C", + class_name="text-2xl font-semibold text-blue-600", + ), + class_name="p-4 bg-blue-50 rounded-lg shadow-sm text-center", + ), + rx.el.div( + rx.el.p( + "Humidity:", + class_name="text-md text-gray-600", + ), + rx.el.p( + f"{WeatherState.weather_data['current']['humidity']}%", + class_name="text-2xl font-semibold text-green-600", + ), + class_name="p-4 bg-green-50 rounded-lg shadow-sm text-center", + ), + rx.el.div( + rx.el.p( + "Feels Like:", + class_name="text-md text-gray-600", + ), + rx.el.p( + f"{WeatherState.weather_data['current']['feelslike']}°C", + class_name="text-2xl font-semibold text-orange-600", + ), + class_name="p-4 bg-orange-50 rounded-lg shadow-sm text-center", + ), + rx.el.div( + rx.el.p( + "Wind Speed:", + class_name="text-md text-gray-600", + ), + rx.el.p( + f"{WeatherState.weather_data['current']['wind_speed']} km/h", + class_name="text-2xl font-semibold text-purple-600", + ), + class_name="p-4 bg-purple-50 rounded-lg shadow-sm text-center", + ), + class_name="grid grid-cols-1 md:grid-cols-2 gap-4 w-full", + ), + rx.button("Reset", on_click=WeatherState.reset_app), + class_name="p-6 rounded-lg border shadow-sm border w-full max-w-2xl justify-center", + ), + rx.el.div( + rx.el.p( + "Enter a city to get the weather forecast.", + class_name="text-lg text-gray-500", + ), + class_name="p-6 bg-white rounded-lg shadow-md", + ), + ), + ), + ), + class_name="mt-8 w-full flex justify-center", + ) diff --git a/weatherstack_app/weatherstack_app/preview.png b/weatherstack_app/weatherstack_app/preview.png new file mode 100644 index 0000000..9a073e5 Binary files /dev/null and b/weatherstack_app/weatherstack_app/preview.png differ diff --git a/weatherstack_app/weatherstack_app/states/__init__.py b/weatherstack_app/weatherstack_app/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/weatherstack_app/weatherstack_app/states/weather_state.py b/weatherstack_app/weatherstack_app/states/weather_state.py new file mode 100644 index 0000000..4011324 --- /dev/null +++ b/weatherstack_app/weatherstack_app/states/weather_state.py @@ -0,0 +1,172 @@ +from typing import List, TypedDict + +import httpx +import reflex as rx + +WEATHERSTACK_API_KEY = "YOUR_WEATHERSTACK_API_KEY" +WEATHERSTACK_API_URL = "http://api.weatherstack.com/current" + + +class Location(TypedDict): + name: str + country: str + region: str + lat: str + lon: str + timezone_id: str + localtime: str + localtime_epoch: int + utc_offset: str + + +class CurrentWeather(TypedDict): + observation_time: str + temperature: int + weather_code: int + weather_icons: List[str] + weather_descriptions: List[str] + wind_speed: int + wind_degree: int + wind_dir: str + pressure: int + precip: float + humidity: int + cloudcover: int + feelslike: int + uv_index: int + visibility: int + is_day: str + + +class WeatherRequest(TypedDict): + type: str + query: str + language: str + unit: str + + +class WeatherData(TypedDict): + request: WeatherRequest | None + location: Location | None + current: CurrentWeather | None + + +class WeatherState(rx.State): + city: str = "" + weather_data: WeatherData | None = None + loading: bool = False + error_message: str = "" + api_key: str = WEATHERSTACK_API_KEY + + @rx.event + def reset_app(self): + self.reset() + return + + @rx.event + def handle_form_submit(self, form_data: dict): + self.city = form_data.get("city", "").strip() + if not self.city: + self.error_message = "City name cannot be empty." + self.weather_data = None + return + self.error_message = "" + return WeatherState.get_weather + + @rx.event + def get_weather_from_preset(self, city: str): + self.city = city + if not self.city: + self.error_message = "City name cannot be empty." + self.weather_data = None + return + self.error_message = "" + return WeatherState.get_weather + + @rx.event(background=True) + async def get_weather(self): + async with self: + if not self.city: + self.error_message = "City name cannot be empty." + self.loading = False + return + if self.api_key == "YOUR_WEATHERSTACK_API_KEY": + self.error_message = "Please replace 'YOUR_WEATHERSTACK_API_KEY' with your actual WeatherStack API key in app/states/weather_state.py." + self.weather_data = None + self.loading = False + return + self.loading = True + self.error_message = "" + self.weather_data = None + + try: + params = { + "access_key": self.api_key, + "query": self.city, + } + + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(WEATHERSTACK_API_URL, params=params) + response.raise_for_status() + data = response.json() + + async with self: + if "error" in data: + self.error_message = data["error"].get( + "info", + "An error occurred while fetching weather data.", + ) + self.weather_data = None + elif "current" in data and "location" in data: + self.weather_data = data + self.error_message = "" + else: + self.error_message = "Unexpected API response format." + self.weather_data = None + + except httpx.RequestError as e: + async with self: + self.error_message = f"Network error: {e}" + self.weather_data = None + except Exception as e: + async with self: + self.error_message = f"An unexpected error occurred: {e}" + self.weather_data = None + finally: + async with self: + self.loading = False + + def set_city(self, city: str): + self.city = city.strip() + self.error_message = "" + + @rx.var + def display_weather(self) -> bool: + return ( + self.weather_data is not None + and self.weather_data.get("current") is not None + and (self.weather_data.get("location") is not None) + and (not self.error_message) + ) + + @rx.var + def weather_icon_url(self) -> str: + if ( + self.display_weather + and self.weather_data + and self.weather_data.get("current") + and self.weather_data["current"].get("weather_icons") + ): + return self.weather_data["current"]["weather_icons"][0] + return "" + + @rx.var + def weather_description_text(self) -> str: + if ( + self.display_weather + and self.weather_data + and self.weather_data.get("current") + and self.weather_data["current"].get("weather_descriptions") + ): + return ", ".join(self.weather_data["current"]["weather_descriptions"]) + return "" diff --git a/weatherstack_app/weatherstack_app/weatherstack_app.py b/weatherstack_app/weatherstack_app/weatherstack_app.py new file mode 100644 index 0000000..fb76f8a --- /dev/null +++ b/weatherstack_app/weatherstack_app/weatherstack_app.py @@ -0,0 +1,59 @@ +import reflex as rx + +from weatherstack_app.components.preset_cards import preset_cards +from weatherstack_app.components.weather_display import weather_display +from weatherstack_app.states.weather_state import WeatherState + + +def index() -> rx.Component: + return rx.el.div( + rx.cond( + WeatherState.display_weather, + weather_display(), + rx.el.div( + rx.el.p( + "Where would you like the forecast for today?", + class_name="text-2xl md:text-3xl font-medium", + ), + rx.el.form( + rx.el.div( + rx.el.div( + rx.el.button( + rx.icon( + "forward", + size=20, + class_name="absolute right-2 top-1/2 transform -translate-y-1/2 rounded-full bg-blue-500 text-white p-2 disabled:opacity-50 shadow-sm size-7 self-flex items-center justify-center cursor-pointer", + ), + type="submit", + ), + rx.el.input( + name="city", + placeholder="Enter city name...", + default_value=WeatherState.city, + class_name="px-2 py-3 w-full text-sm rounded-xl bg-transparent border shadow-sm focus:outline-none focus:border-blue-500", + ), + class_name="relative focus:outline-none w-full max-w-[400px] py-4", + ), + class_name="flex w-full justify-center", + ), + on_submit=WeatherState.handle_form_submit, + reset_on_submit=False, + prevent_default=True, + class_name="w-full justify-center items-center flex", + ), + rx.cond( + WeatherState.error_message != "", + rx.el.p( + WeatherState.error_message, + class_name="text-sm font-medium text-red-500", + ), + ), + preset_cards(), + class_name="flex flex-col items-center justify-center align-center min-h-screen gap-y-4", + ), + ) + ) + + +app = rx.App(theme=rx.theme(appearance="light")) +app.add_page(index)