mirror of
https://github.com/mue/mue.git
synced 2026-07-08 21:24:57 +02:00
feat(LocationSearch): add LocationSearch component for improved location input
This commit is contained in:
435
src/components/Form/Settings/LocationSearch/LocationSearch.jsx
Normal file
435
src/components/Form/Settings/LocationSearch/LocationSearch.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`location-search ${id} ${disabled ? 'disabled' : ''}`}
|
||||
ref={containerRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label && (
|
||||
<div className="location-search-header">
|
||||
<label className="location-search-label">{label}</label>
|
||||
<span className="location-search-auto" onClick={handleAutoLocation}>
|
||||
<MdMyLocation />
|
||||
{variables.getMessage('modals.main.settings.sections.weather.auto')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={controlRef}
|
||||
className={`location-search-control ${isOpen || searchQuery ? 'searching' : ''}`}
|
||||
onClick={(e) => {
|
||||
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}
|
||||
>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="location-search-input"
|
||||
placeholder={displayValue}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onClick={handleInputClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{searchQuery && <MdClose className="location-search-clear" onClick={clearSearch} />}
|
||||
{isLoading ? (
|
||||
<span className="location-search-loading" />
|
||||
) : (
|
||||
<MdExpandMore className={`location-search-arrow ${isOpen ? 'open' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
{(isOpen || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`location-search-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{suggestions.length > 0 ? (
|
||||
suggestions.map((item, index) => (
|
||||
<div
|
||||
key={`${item.lat}-${item.lon}`}
|
||||
className={`location-search-option ${index === focusedIndex ? 'focused' : ''}`}
|
||||
onClick={() => selectLocation(item)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="location-search-option-text">
|
||||
<span className="location-search-option-name">{item.name}</span>
|
||||
{(item.state || item.country) && (
|
||||
<span className="location-search-option-detail">
|
||||
{[item.state, item.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<MdCheck className="location-search-option-check" />
|
||||
</div>
|
||||
))
|
||||
) : searchQuery.length >= 2 && !isLoading ? (
|
||||
<div className="location-search-empty">
|
||||
{variables.getMessage('widgets.weather.not_found')}
|
||||
</div>
|
||||
) : searchQuery.length < 2 && searchQuery.length > 0 ? (
|
||||
<div className="location-search-empty">
|
||||
{variables.getMessage('modals.main.settings.sections.weather.location')}...
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LocationSearch.displayName = 'LocationSearch';
|
||||
|
||||
export { LocationSearch as default, LocationSearch };
|
||||
351
src/components/Form/Settings/LocationSearch/LocationSearch.scss
Normal file
351
src/components/Form/Settings/LocationSearch/LocationSearch.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/LocationSearch/index.jsx
Normal file
1
src/components/Form/Settings/LocationSearch/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './LocationSearch';
|
||||
@@ -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';
|
||||
|
||||
@@ -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 <WeatherSkeleton weatherType={weatherType} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="weather">
|
||||
<span className="loc">{location}</span>
|
||||
<span className="loc">{locationDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +105,7 @@ const WeatherWidget = memo(() => {
|
||||
amount: `${formatNumber(weatherData.weather.feels_like)}${weatherData.temp_text}`,
|
||||
})}
|
||||
</span>
|
||||
<span className="loc">{location}</span>
|
||||
<span className="loc">{locationDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
<Row>
|
||||
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
|
||||
<Action>
|
||||
<div className="text-field-container">
|
||||
<div className="text-field">
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">
|
||||
{variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
</label>
|
||||
<span className="text-field-reset" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
placeholder="London"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LocationSearch
|
||||
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
name="location"
|
||||
category="weather"
|
||||
placeholder="London"
|
||||
/>
|
||||
</Action>
|
||||
</Row>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user