diff --git a/pom.xml b/pom.xml
index 8d50792..2928ac7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,9 +10,9 @@
comWeatherViewer
- 1.1.0-RELEASE
+ 1.2.0WeatherViewer
- A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting.
+ A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. Supports English and Ukrainian locales.
diff --git a/src/main/java/com/weatherviewer/config/WebMvcConfig.java b/src/main/java/com/weatherviewer/config/WebMvcConfig.java
new file mode 100644
index 0000000..7b9af5a
--- /dev/null
+++ b/src/main/java/com/weatherviewer/config/WebMvcConfig.java
@@ -0,0 +1,30 @@
+package com.weatherviewer.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+/**
+ * Lets any request switch the active locale by adding a {@code lang} query
+ * parameter (e.g. {@code ?lang=uk}). The new locale is then persisted on
+ * the session by {@link AppConfig#localeResolver()}, so it applies to
+ * subsequent requests too, not just the one carrying the parameter.
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ @Bean
+ public LocaleChangeInterceptor localeChangeInterceptor() {
+ LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
+ interceptor.setParamName("lang");
+ return interceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(localeChangeInterceptor());
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
index 64c297b..534bc6c 100644
--- a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
+++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
@@ -178,24 +178,24 @@ private static String maskApiKey(String url) {
return APPID_PATTERN.matcher(url).replaceAll("$1***");
}
- /** Builds a request URL for a city-name-based endpoint, with metric units and English descriptions. */
+ /** Builds a request URL for a city-name-based endpoint, with metric units and locale-appropriate descriptions. */
private String buildCityUrl(String suffix, String city) {
return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix))
.queryParam("appid", apiKey)
.queryParam("units", "metric")
- .queryParam("lang", "en")
+ .queryParam("lang", WeatherApiLocale.resolve())
.queryParam("q", city)
.encode(StandardCharsets.UTF_8)
.build()
.toUriString();
}
- /** Builds a request URL for a coordinate-based endpoint, with metric units and English descriptions. */
+ /** Builds a request URL for a coordinate-based endpoint, with metric units and locale-appropriate descriptions. */
private String buildCoordsUrl(String suffix, double latitude, double longitude) {
return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix))
.queryParam("appid", apiKey)
.queryParam("units", "metric")
- .queryParam("lang", "en")
+ .queryParam("lang", WeatherApiLocale.resolve())
.queryParam("lat", latitude)
.queryParam("lon", longitude)
.encode(StandardCharsets.UTF_8)
diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java
new file mode 100644
index 0000000..a89ab74
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java
@@ -0,0 +1,42 @@
+package com.weatherviewer.service.integration;
+
+import org.springframework.context.i18n.LocaleContextHolder;
+
+import java.util.Set;
+
+/**
+ * Maps the current request's locale to a language code OpenWeatherMap
+ * accepts for its {@code lang} query parameter, controlling the language of
+ * weather {@code description} text in the API response.
+ *
+ * Used by both {@link WeatherApiClient} (to build the actual request) and
+ * {@link WeatherApiCache} (to key cached responses per resolved language —
+ * without that, a location's weather cached for one language would be
+ * served as-is to a request in another language, since the raw JSON,
+ * descriptions included, is what's cached).
+ */
+public final class WeatherApiLocale {
+
+ /**
+ * Languages we ask OpenWeatherMap to translate descriptions into.
+ * Keep in sync with the {@code messages_XX.properties} bundles this
+ * application ships — no point requesting a language the UI itself
+ * can't render around.
+ */
+ private static final Set SUPPORTED_LANGUAGES = Set.of("en", "uk");
+
+ private static final String DEFAULT_LANGUAGE = "en";
+
+ private WeatherApiLocale() {
+ }
+
+ /**
+ * @return the current {@link LocaleContextHolder} locale's language,
+ * if OpenWeatherMap is known to support it, otherwise {@code "en"}.
+ */
+ public static String resolve() {
+ String language = LocaleContextHolder.getLocale().getLanguage();
+ return SUPPORTED_LANGUAGES.contains(language) ? language : DEFAULT_LANGUAGE;
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 6d8610d..37012af 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -17,6 +17,11 @@ spring.jpa.open-in-view=false
spring.mvc.hiddenmethod.filter.enabled=true
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
+# --- Internationalization ---
+spring.messages.basename=messages
+spring.messages.encoding=UTF-8
+spring.messages.cache-duration=${MESSAGES_CACHE_SECONDS:0}
+
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
new file mode 100644
index 0000000..75e2a6b
--- /dev/null
+++ b/src/main/resources/messages.properties
@@ -0,0 +1,126 @@
+# Default (English) message bundle.
+
+# --- Header (header-component.html) ---
+header.sign-in=Sign in
+header.sign-up=Sign up
+header.sign-out=Sign out
+header.theme.dark-mode=Switch to dark mode
+header.language=Language
+
+# --- Sign-in page (sign-in.html) ---
+signin.title=Sign in
+signin.email.label=Email
+signin.email.placeholder=name@example.com
+signin.password.label=Password
+signin.password.placeholder=Password
+signin.remember-me=Remember me
+signin.forgot-password=Forgot password?
+signin.submit=Sign in
+signin.no-account=Don't have an account yet? -
+signin.sign-up-link=Sign up
+signin.resend.prompt=Didn't get a verification email?
+signin.resend.button=Resend verification email
+
+# --- Sign-up page (sign-up.html) ---
+signup.title=Sign up
+signup.first-name.label=First name
+signup.first-name.placeholder=John
+signup.last-name.label=Last name
+signup.last-name.placeholder=Doe
+signup.repeat-password.label=Repeat password
+signup.repeat-password.placeholder=Repeat password
+signup.submit=Sign up
+signup.have-account=Already have an account -
+signup.sign-in-link=Sign in
+
+# --- Shared auth links ---
+back-to-sign-in=Back to sign in
+
+# --- Forgot password page (forgot-password.html) ---
+forgot-password.title=Forgot password
+forgot-password.instructions=Enter the email address on your account and we'll send you a link to reset your password.
+forgot-password.submit=Send reset link
+
+# --- Reset password page (reset-password.html) ---
+reset-password.title=Reset password
+reset-password.password.label=New password
+reset-password.password.placeholder=New password
+reset-password.repeat-password.label=Repeat new password
+reset-password.repeat-password.placeholder=Repeat new password
+reset-password.submit=Set new password
+
+# --- Alert toasts (fragments/alerts.html) ---
+alerts.success-heading=Success
+alerts.error-heading=Error
+alerts.errors-heading=Errors
+alerts.close=Close
+
+# --- Error page (error.html) ---
+error.title=Oops! Something Went Wrong
+error.message=We're sorry, but an unexpected error has occurred. Please try again later.
+error.go-home=Go to Home
+
+# --- Footer (footer-component.html) ---
+footer.design-elements=Design elements
+footer.development=Development
+footer.copyright=Copyright \u00A9 2025 Pidlisnyi Lev
+
+# --- Search box (search-component.html) ---
+search.aria-label=Search for a location
+search.placeholder=Enter the location name
+search.submit=Search
+
+# --- Search results page (search.html) ---
+search.results-title=Search results
+search.no-data=No data
+search.add-location=Add to tracked locations
+
+# --- Forecast page (forecast.html) ---
+forecast.hourly-title=Hourly Forecast
+forecast.daily-title=Daily Forecast
+
+# Fallback labels for both forecast sections when a real per-reading
+weather-condition.THUNDERSTORM=Thunderstorm
+weather-condition.DRIZZLE=Drizzle
+weather-condition.RAIN=Rain
+weather-condition.SNOW=Snow
+weather-condition.ATMOSPHERE=Fog/Mist
+weather-condition.CLEAR=Clear
+weather-condition.CLOUDS=Clouds
+weather-condition.UNDEFINED=No data
+
+# --- Profile page (profile.html) ---
+profile.title=Account Settings
+profile.first-name.placeholder=First Name
+profile.last-name.placeholder=Last Name
+profile.password.placeholder=New Password
+profile.units.label=Units
+profile.units.metric=Metric (\u00B0C, m/s)
+profile.units.imperial=Imperial (\u00B0F, mph)
+profile.save=Save Changes
+
+# --- Home / dashboard page (home.html) ---
+home.current-weather=Current Weather
+home.sort-by=Sort by:
+home.sort.date=Date
+home.sort.name-asc=Name \u2191
+home.sort.name-desc=Name \u2193
+home.sort.favorite-first=Favorites First
+home.sort.favorites-only=Favorites Only
+home.delete=Delete
+home.add-favorite=Add to Favorites
+home.remove-favorite=Remove from Favorites
+home.feels-like=Feels like {0}{1}
+home.min=Min {0}{1}
+home.max=Max {0}{1}
+home.clouds=Clouds {0}%
+home.humidity=Humidity {0}%
+home.pressure=Pressure {0} hPa
+home.wind={0}\u00B0 {1} {2}
+home.sunrise=Sunrise {0}
+home.sunset=Sunset {0}
+home.forecast-button=Forecast
+home.no-location.title=Location Required for Weather Information
+home.no-location.instructions=Please add a location to view weather. You can do it by using the search bar above.
+home.pagination.previous=Previous
+home.pagination.next=Next
\ No newline at end of file
diff --git a/src/main/resources/messages_uk.properties b/src/main/resources/messages_uk.properties
new file mode 100644
index 0000000..d6836ba
--- /dev/null
+++ b/src/main/resources/messages_uk.properties
@@ -0,0 +1,125 @@
+# Ukrainian translation. Keep keys in sync with messages.properties.
+
+# --- Header (header-component.html) ---
+header.sign-in=Увійти
+header.sign-up=Реєстрація
+header.sign-out=Вийти
+header.theme.dark-mode=Перемкнути на темну тему
+header.language=Мова
+
+# --- Sign-in page (sign-in.html) ---
+signin.title=Вхід
+signin.email.label=Електронна пошта
+signin.email.placeholder=name@example.com
+signin.password.label=Пароль
+signin.password.placeholder=Пароль
+signin.remember-me=Запам'ятати мене
+signin.forgot-password=Забули пароль?
+signin.submit=Увійти
+signin.no-account=Ще не маєте акаунта? -
+signin.sign-up-link=Зареєструватися
+signin.resend.prompt=Не отримали лист для підтвердження?
+signin.resend.button=Надіслати повторно
+
+# --- Sign-up page (sign-up.html) ---
+signup.title=Реєстрація
+signup.first-name.label=Ім'я
+signup.first-name.placeholder=Іван
+signup.last-name.label=Прізвище
+signup.last-name.placeholder=Петренко
+signup.repeat-password.label=Повторіть пароль
+signup.repeat-password.placeholder=Повторіть пароль
+signup.submit=Зареєструватися
+signup.have-account=Вже маєте акаунт -
+signup.sign-in-link=Увійти
+
+# --- Shared auth links ---
+back-to-sign-in=Повернутися до входу
+
+# --- Forgot password page (forgot-password.html) ---
+forgot-password.title=Забули пароль
+forgot-password.instructions=Введіть електронну адресу вашого акаунта, і ми надішлемо посилання для скидання пароля.
+forgot-password.submit=Надіслати посилання
+
+# --- Reset password page (reset-password.html) ---
+reset-password.title=Скидання пароля
+reset-password.password.label=Новий пароль
+reset-password.password.placeholder=Новий пароль
+reset-password.repeat-password.label=Повторіть новий пароль
+reset-password.repeat-password.placeholder=Повторіть новий пароль
+reset-password.submit=Встановити новий пароль
+
+# --- Alert toasts (fragments/alerts.html) ---
+alerts.success-heading=Успіх
+alerts.error-heading=Помилка
+alerts.errors-heading=Помилки
+alerts.close=Закрити
+
+# --- Error page (error.html) ---
+error.title=Ой! Щось пішло не так
+error.message=На жаль, сталася непередбачена помилка. Спробуйте пізніше.
+error.go-home=На головну
+
+# --- Footer (footer-component.html) ---
+footer.design-elements=Елементи дизайну
+footer.development=Розробка
+footer.copyright=Copyright \u00A9 2025 Pidlisnyi Lev
+
+# --- Search box (search-component.html) ---
+search.aria-label=Пошук місця
+search.placeholder=Введіть назву місця
+search.submit=Пошук
+
+# --- Search results page (search.html) ---
+search.results-title=Результати пошуку
+search.no-data=Немає даних
+search.add-location=Додати до відстежуваних місць
+
+# --- Forecast page (forecast.html) ---
+forecast.hourly-title=Погодинний прогноз
+forecast.daily-title=Щоденний прогноз
+
+weather-condition.THUNDERSTORM=Гроза
+weather-condition.DRIZZLE=Мряка
+weather-condition.RAIN=Дощ
+weather-condition.SNOW=Сніг
+weather-condition.ATMOSPHERE=Туман
+weather-condition.CLEAR=Ясно
+weather-condition.CLOUDS=Хмарно
+weather-condition.UNDEFINED=Немає даних
+
+# --- Profile page (profile.html) ---
+profile.title=Налаштування акаунта
+profile.first-name.placeholder=Ім'я
+profile.last-name.placeholder=Прізвище
+profile.password.placeholder=Новий пароль
+profile.units.label=Одиниці виміру
+profile.units.metric=Метричні (\u00B0C, м/с)
+profile.units.imperial=Імперські (\u00B0F, миль/год)
+profile.save=Зберегти зміни
+
+# --- Home / dashboard page (home.html) ---
+home.current-weather=Поточна погода
+home.sort-by=Сортувати за:
+home.sort.date=Датою
+home.sort.name-asc=Назвою \u2191
+home.sort.name-desc=Назвою \u2193
+home.sort.favorite-first=Спочатку обрані
+home.sort.favorites-only=Лише обрані
+home.delete=Видалити
+home.add-favorite=Додати до обраних
+home.remove-favorite=Прибрати з обраних
+home.feels-like=Відчувається як {0}{1}
+home.min=Мін. {0}{1}
+home.max=Макс. {0}{1}
+home.clouds=Хмарність {0}%
+home.humidity=Вологість {0}%
+home.pressure=Тиск {0} гПа
+home.wind={0}\u00B0 {1} {2}
+home.sunrise=Схід сонця {0}
+home.sunset=Захід сонця {0}
+home.forecast-button=Прогноз
+home.no-location.title=Потрібне місце для отримання погоди
+home.no-location.instructions=Додайте місце, щоб переглянути погоду. Це можна зробити за допомогою рядка пошуку вище.
+home.pagination.previous=Попередня
+home.pagination.next=Наступна
\ No newline at end of file
diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html
index 85403df..a8a4d2d 100644
--- a/src/main/resources/templates/error.html
+++ b/src/main/resources/templates/error.html
@@ -7,9 +7,9 @@
-
Oops! Something Went Wrong
-
We're sorry, but an unexpected error has occurred. Please try again later.