diff --git a/src/components/Form/Settings/LocationSearch/LocationSearch.jsx b/src/components/Form/Settings/LocationSearch/LocationSearch.jsx new file mode 100644 index 00000000..63e77ea7 --- /dev/null +++ b/src/components/Form/Settings/LocationSearch/LocationSearch.jsx @@ -0,0 +1,435 @@ +import variables from 'config/variables'; +import { memo, useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { MdExpandMore, MdCheck, MdClose, MdMyLocation } from 'react-icons/md'; +import { useDebouncedCallback } from 'use-debounce'; + +import EventBus from 'utils/eventbus'; + +import './LocationSearch.scss'; + +const LocationSearch = memo((props) => { + const { label, name, category, placeholder, disabled } = props; + + // Load location data from localStorage (new JSON format or legacy string) + const [locationData, setLocationData] = useState(() => { + const stored = localStorage.getItem(name); + if (!stored) return null; + + try { + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object' && parsed.displayName) { + return parsed; + } + } catch { + // Legacy format: plain string city name + return { displayName: stored, legacy: true }; + } + return null; + }); + + const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 }); + + const containerRef = useRef(null); + const controlRef = useRef(null); + const menuRef = useRef(null); + const searchInputRef = useRef(null); + const abortControllerRef = useRef(null); + + const closeDropdown = useCallback(() => { + setIsClosing(true); + setTimeout(() => { + setIsOpen(false); + setIsClosing(false); + setFocusedIndex(-1); + }, 200); + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target) && + menuRef.current && + !menuRef.current.contains(event.target) + ) { + closeDropdown(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [closeDropdown]); + + const itemsCount = useMemo(() => suggestions.length, [suggestions]); + + const calculatePosition = useCallback(() => { + if (controlRef.current) { + const rect = controlRef.current.getBoundingClientRect(); + const gap = 4; + const viewportHeight = window.innerHeight; + const estimatedMenuHeight = Math.min(Math.max(itemsCount, 1) * 56, 250); + const spaceBelow = viewportHeight - rect.bottom - gap; + const spaceAbove = rect.top - gap; + const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; + + return { + top: shouldFlipUp ? rect.top - gap : rect.bottom + gap, + left: rect.left, + width: rect.width, + maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow), + flipped: shouldFlipUp, + }; + } + return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false }; + }, [itemsCount]); + + const openDropdown = useCallback(() => { + const position = calculatePosition(); + setMenuPosition(position); + setIsOpen(true); + }, [calculatePosition]); + + useEffect(() => { + if (!isOpen) return; + + let rafId = null; + const updatePosition = () => { + if (rafId) window.cancelAnimationFrame(rafId); + rafId = window.requestAnimationFrame(() => { + const newPosition = calculatePosition(); + setMenuPosition(newPosition); + }); + }; + + window.addEventListener('scroll', updatePosition, { passive: true }); + window.addEventListener('resize', updatePosition, { passive: true }); + + let element = controlRef.current?.parentElement; + const scrollableElements = []; + while (element) { + const hasScrollableContent = element.scrollHeight > element.clientHeight; + const overflowYStyle = window.getComputedStyle(element).overflowY; + const isOverflowYScrollable = overflowYStyle !== 'visible' && overflowYStyle !== 'hidden'; + + if (hasScrollableContent && isOverflowYScrollable) { + scrollableElements.push(element); + element.addEventListener('scroll', updatePosition, { passive: true }); + } + element = element.parentElement; + } + + return () => { + if (rafId) window.cancelAnimationFrame(rafId); + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + scrollableElements.forEach((el) => el.removeEventListener('scroll', updatePosition)); + }; + }, [isOpen, calculatePosition]); + + useEffect(() => { + if (isOpen && searchInputRef.current) { + setTimeout(() => searchInputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Debounced search function + const debouncedSearch = useDebouncedCallback(async (query) => { + if (query.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + // Cancel previous request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + try { + const response = await fetch( + `${variables.constants.API_URL}/geocode?q=${encodeURIComponent(query)}`, + { signal: abortControllerRef.current.signal }, + ); + + if (!response.ok) throw new Error('Search failed'); + + const data = await response.json(); + setSuggestions(data); + } catch (err) { + if (err.name !== 'AbortError') { + console.error('Location search error:', err); + setSuggestions([]); + } + } finally { + setIsLoading(false); + } + }, 300); + + const handleSearchChange = useCallback( + (e) => { + const value = e.target.value; + setSearchQuery(value); + setIsLoading(value.length >= 2); + debouncedSearch(value); + if (!isOpen && value.length > 0) { + openDropdown(); + } + }, + [isOpen, debouncedSearch, openDropdown], + ); + + const handleInputClick = useCallback( + (e) => { + e.stopPropagation(); + if (!isOpen) { + openDropdown(); + } + }, + [isOpen, openDropdown], + ); + + const selectLocation = useCallback( + (location) => { + const locationObj = { + name: location.name, + displayName: location.displayName, + lat: location.lat, + lon: location.lon, + country: location.country, + state: location.state, + }; + + localStorage.setItem(name, JSON.stringify(locationObj)); + localStorage.removeItem('currentWeather'); + setLocationData(locationObj); + setSearchQuery(''); + setSuggestions([]); + closeDropdown(); + + EventBus.emit('refresh', category); + + document.querySelector('.reminder-info').style.display = 'flex'; + localStorage.setItem('showReminder', true); + }, + [name, category, closeDropdown], + ); + + const handleAutoLocation = useCallback(() => { + setSearchQuery(variables.getMessage('modals.main.loading')); + setSuggestions([]); + + navigator.geolocation.getCurrentPosition( + async (position) => { + try { + const data = await ( + await fetch( + `${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`, + ) + ).json(); + + if (data && data[0]) { + const loc = data[0]; + const locationObj = { + name: loc.name, + displayName: [loc.name, loc.state, loc.country].filter(Boolean).join(', '), + lat: position.coords.latitude, + lon: position.coords.longitude, + country: loc.country, + state: loc.state, + }; + + localStorage.setItem(name, JSON.stringify(locationObj)); + localStorage.removeItem('currentWeather'); + setLocationData(locationObj); + setSearchQuery(''); + + EventBus.emit('refresh', category); + document.querySelector('.reminder-info').style.display = 'flex'; + localStorage.setItem('showReminder', true); + } + } catch (err) { + console.error('Auto location error:', err); + setSearchQuery(''); + } + }, + (error) => { + console.error('Geolocation error:', error); + setSearchQuery(''); + }, + { enableHighAccuracy: true }, + ); + }, [name, category]); + + const handleKeyDown = useCallback( + (e) => { + if (disabled) return; + + switch (e.key) { + case 'Enter': + e.preventDefault(); + if (isOpen && focusedIndex >= 0 && suggestions[focusedIndex]) { + selectLocation(suggestions[focusedIndex]); + } + break; + case 'Escape': + if (searchQuery) { + setSearchQuery(''); + setSuggestions([]); + } else { + closeDropdown(); + } + break; + case 'ArrowDown': + e.preventDefault(); + if (!isOpen && searchQuery.length >= 2) { + openDropdown(); + } else { + setFocusedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev)); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (isOpen) { + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } + break; + } + }, + [ + isOpen, + suggestions, + focusedIndex, + disabled, + searchQuery, + openDropdown, + closeDropdown, + selectLocation, + ], + ); + + const clearSearch = useCallback( + (e) => { + e.stopPropagation(); + setSearchQuery(''); + setSuggestions([]); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, + [], + ); + + const id = 'location-search-' + name; + const displayValue = locationData?.displayName || placeholder || 'Search location...'; + + return ( +
e.stopPropagation()} + > + {label && ( +
+ + + + {variables.getMessage('modals.main.settings.sections.weather.auto')} + +
+ )} +
{ + e.stopPropagation(); + if (disabled) return; + if (!isOpen) { + openDropdown(); + } + }} + onKeyDown={handleKeyDown} + role="combobox" + aria-haspopup="listbox" + aria-expanded={isOpen} + aria-label={label || name} + tabIndex={disabled ? -1 : 0} + > + + {searchQuery && } + {isLoading ? ( + + ) : ( + + )} +
+ {(isOpen || isClosing) && + createPortal( +
+ {suggestions.length > 0 ? ( + suggestions.map((item, index) => ( +
selectLocation(item)} + role="option" + tabIndex={0} + > + + {item.name} + {(item.state || item.country) && ( + + {[item.state, item.country].filter(Boolean).join(', ')} + + )} + + +
+ )) + ) : searchQuery.length >= 2 && !isLoading ? ( +
+ {variables.getMessage('widgets.weather.not_found')} +
+ ) : searchQuery.length < 2 && searchQuery.length > 0 ? ( +
+ {variables.getMessage('modals.main.settings.sections.weather.location')}... +
+ ) : null} +
, + document.body, + )} +
+ ); +}); + +LocationSearch.displayName = 'LocationSearch'; + +export { LocationSearch as default, LocationSearch }; diff --git a/src/components/Form/Settings/LocationSearch/LocationSearch.scss b/src/components/Form/Settings/LocationSearch/LocationSearch.scss new file mode 100644 index 00000000..e5608643 --- /dev/null +++ b/src/components/Form/Settings/LocationSearch/LocationSearch.scss @@ -0,0 +1,351 @@ +@use 'scss/variables' as *; +@use 'scss/mixins' as *; + +@include keyframes(locationSearchSlideIn) { + 0% { + opacity: 0; + transform: translateY(-10px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@include keyframes(locationSearchSlideOut) { + 0% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 0; + transform: translateY(-10px); + } +} + +@include keyframes(locationSearchSlideInUp) { + 0% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } + + 100% { + opacity: 1; + transform: translateY(-100%); + } +} + +@include keyframes(locationSearchSlideOutUp) { + 0% { + opacity: 1; + transform: translateY(-100%); + } + + 100% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } +} + +@include keyframes(locationSearchSpin) { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.location-search { + position: relative; + width: 300px; + gap: 8px; + display: flex; + flex-flow: column; + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + + .location-search-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .location-search-label { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + + @include themed { + color: t($subColor); + } + } + + .location-search-auto { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + + @include themed { + color: t($link); + } + + &:hover { + opacity: 0.8; + } + + svg { + font-size: 14px; + } + } + + .location-search-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 56px; + padding: 0 16px; + cursor: text; + transition: all 0.2s ease; + outline: none; + + @include themed { + background: t($modal-sidebar); + border: 1px solid t($modal-sidebarActive); + border-radius: t($borderRadius); + color: t($color); + + &:hover { + border-color: t($color); + } + } + + &:focus-within { + @include themed { + border-color: t($link); + box-shadow: 0 0 0 3px rgba(t($link), 0.2); + } + } + } + + .location-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + height: 100%; + font-size: 14px; + padding: 0; + min-width: 0; + + @include themed { + color: t($color); + + &::placeholder { + color: t($color); + opacity: 1; + } + } + } + + .location-search-clear { + flex-shrink: 0; + font-size: 20px; + cursor: pointer; + transition: all 0.2s ease; + padding: 4px; + border-radius: 50%; + + @include themed { + color: t($subColor); + + &:hover { + background: t($modal-sidebarActive); + color: t($color); + } + } + } + + .location-search-loading { + width: 20px; + height: 20px; + border: 2px solid transparent; + border-radius: 50%; + @include animation(locationSearchSpin 0.8s linear infinite); + + @include themed { + border-top-color: t($link); + border-right-color: t($link); + } + } + + .location-search-arrow { + flex-shrink: 0; + font-size: 24px; + transition: all 0.2s ease; + cursor: pointer; + padding: 4px; + border-radius: 50%; + margin: -4px; + + @include themed { + color: t($subColor); + + &:hover { + background: t($modal-sidebarActive); + color: t($color); + } + } + + &.open { + transform: rotate(180deg); + } + } +} + +.location-search-menu { + max-height: 250px; + overflow-y: auto; + overflow-x: hidden; + z-index: 9999; + @include animation(locationSearchSlideIn 0.2s ease-out); + will-change: transform, opacity; + + @include themed { + background: t($modal-background); + border: 1px solid t($modal-sidebarActive); + border-radius: t($borderRadius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &.flipped { + @include animation(locationSearchSlideInUp 0.2s ease-out); + + &.closing { + @include animation(locationSearchSlideOutUp 0.2s ease-out forwards); + } + } + + &.closing:not(.flipped) { + @include animation(locationSearchSlideOut 0.2s ease-out forwards); + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + @include themed { + background: t($modal-sidebar); + } + } + + &::-webkit-scrollbar-thumb { + @include themed { + background: t($modal-sidebarActive); + border-radius: 3px; + } + + &:hover { + @include themed { + background: t($color); + } + } + } +} + +.location-search-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px; + cursor: pointer; + transition: all 0.15s ease; + outline: none; + + @include themed { + color: t($color); + + &:hover { + background: t($modal-sidebarActive); + padding-left: 20px; + } + + &.focused { + background: t($modal-sidebarActive); + border-left: 2px solid t($link); + } + } + + .location-search-option-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; + } + + .location-search-option-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .location-search-option-detail { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @include themed { + color: t($subColor); + } + } + + .location-search-option-check { + flex-shrink: 0; + font-size: 14px; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + + @include themed { + background: t($link); + color: white; + } + } + + &:hover .location-search-option-check { + opacity: 0.5; + } +} + +.location-search-empty { + padding: 16px; + text-align: center; + font-size: 14px; + + @include themed { + color: t($subColor); + } +} diff --git a/src/components/Form/Settings/LocationSearch/index.jsx b/src/components/Form/Settings/LocationSearch/index.jsx new file mode 100644 index 00000000..63ad5979 --- /dev/null +++ b/src/components/Form/Settings/LocationSearch/index.jsx @@ -0,0 +1 @@ +export * from './LocationSearch'; diff --git a/src/components/Form/Settings/index.jsx b/src/components/Form/Settings/index.jsx index e3179fc6..dc696e7a 100644 --- a/src/components/Form/Settings/index.jsx +++ b/src/components/Form/Settings/index.jsx @@ -2,6 +2,7 @@ export * from './Checkbox'; export * from './ChipSelect'; export * from './Dropdown'; export * from './FileUpload'; +export * from './LocationSearch'; export * from './Radio'; export * from './SearchInput'; export * from './Slider'; diff --git a/src/features/weather/Weather.jsx b/src/features/weather/Weather.jsx index e1515ba2..4e97a685 100644 --- a/src/features/weather/Weather.jsx +++ b/src/features/weather/Weather.jsx @@ -13,7 +13,21 @@ import { getWeather } from './api/getWeather.js'; import './weather.scss'; const WeatherWidget = memo(() => { - const [location, setLocation] = useState(localStorage.getItem('location') || 'London'); + const [location, setLocation] = useState(() => { + const stored = localStorage.getItem('location'); + if (!stored) return 'London'; + + // Try parsing as new JSON format + try { + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch { + // Legacy string format + } + return stored; + }); const [done, setDone] = useState(false); const [weatherData, setWeatherData] = useState({}); @@ -57,10 +71,14 @@ const WeatherWidget = memo(() => { return ; } + // Get display name from location (handles both object and string formats) + const locationDisplay = + typeof location === 'object' ? location.displayName || location.name : location; + if (!weatherData.weather) { return (
- {location} + {locationDisplay}
); } @@ -87,7 +105,7 @@ const WeatherWidget = memo(() => { amount: `${formatNumber(weatherData.weather.feels_like)}${weatherData.temp_text}`, })} - {location} + {locationDisplay} )} diff --git a/src/features/weather/api/getWeather.js b/src/features/weather/api/getWeather.js index 86650948..be2d9c74 100644 --- a/src/features/weather/api/getWeather.js +++ b/src/features/weather/api/getWeather.js @@ -49,7 +49,19 @@ export const getWeather = async (location) => { } try { - const response = await fetch(variables.constants.API_URL + `/weather?city=${location}`); + // Build URL based on location type + let url; + if (typeof location === 'object' && location.lat && location.lon) { + // New format: use coordinates (preferred) + url = `${variables.constants.API_URL}/weather?lat=${location.lat}&lon=${location.lon}`; + } else { + // Legacy format: use city name string + const cityName = + typeof location === 'object' ? location.displayName || location.name : location; + url = `${variables.constants.API_URL}/weather?city=${encodeURIComponent(cityName)}`; + } + + const response = await fetch(url); if (!response.ok) { console.error('Weather API response not ok:', response.status, response.statusText); diff --git a/src/features/weather/options/WeatherOptions.jsx b/src/features/weather/options/WeatherOptions.jsx index 3f7b74be..ee7704c7 100644 --- a/src/features/weather/options/WeatherOptions.jsx +++ b/src/features/weather/options/WeatherOptions.jsx @@ -1,59 +1,19 @@ -import { useCallback } from 'react'; -import { MdAutoAwesome } from 'react-icons/md'; import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings'; import { useLocalStorageState } from 'utils/useLocalStorageState'; -import { Radio, Dropdown, Checkbox } from 'components/Form/Settings'; +import { Radio, Dropdown, Checkbox, LocationSearch } from 'components/Form/Settings'; import variables from 'config/variables'; const useWeatherSettings = () => { - const [location, setLocation] = useLocalStorageState('location', ''); const [windSpeed, setWindSpeed] = useLocalStorageState('windspeed', 'true'); - const showReminder = useCallback(() => { - document.querySelector('.reminder-info').style.display = 'flex'; - localStorage.setItem('showReminder', true); - }, []); - - const changeLocation = (e) => { - localStorage.removeItem('currentWeather'); - setLocation(e.target.value); - showReminder(); - }; - - const getAutoLocation = useCallback(() => { - setLocation(variables.getMessage('modals.main.loading')); - - navigator.geolocation.getCurrentPosition( - async (position) => { - const data = await ( - await fetch( - `${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`, - ) - ).json(); - setLocation(data[0].name); - showReminder(); - }, - (error) => { - console.error(error); - }, - { - enableHighAccuracy: true, - }, - ); - }, [setLocation, showReminder]); - return { - location, windSpeed: windSpeed !== 'true', setWindSpeed, - changeLocation, - getAutoLocation, }; }; const WeatherOptions = () => { - const { location, windSpeed, setWindSpeed, changeLocation, getAutoLocation } = - useWeatherSettings(); + const { windSpeed, setWindSpeed } = useWeatherSettings(); const weatherType = localStorage.getItem('weatherType'); const WEATHER_SECTION = 'modals.main.settings.sections.weather'; @@ -81,26 +41,12 @@ const WeatherOptions = () => { -
-
-
- - - - {variables.getMessage(`${WEATHER_SECTION}.auto`)} - -
- -
-
+
);