fixed code

This commit is contained in:
SlimusMinus
2026-08-11 00:24:07 +03:00
parent c50b75eece
commit 7f59ee1f38
95 changed files with 3623 additions and 2830 deletions

View File

@@ -0,0 +1,183 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
/**
* CityFilter — мультиселект городов с поиском и свободным вводом.
*
* @param {string[]} cities — доступные города (из /posts/cities)
* @param {string[]} value — выбранные города
* @param {(next: string[]) => void} onChange — изменение выбора
*/
export default function CityFilter({ cities, value, onChange }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const inputRef = useRef(null);
// Закрываем дропдаун при клике вне компонента
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open]);
const normalizedQuery = query.trim().toLowerCase();
// Города, совпадающие с введённым текстом (выбранные помечаются галочкой)
const options = useMemo(() => {
const q = normalizedQuery;
return cities.filter(c => c && (!q || c.toLowerCase().includes(q)));
}, [cities, normalizedQuery]);
// Свободный ввод доступен, если введён текст, которого нет ни в списке, ни в выбранных
const canAddCustom = normalizedQuery.length > 0 &&
!value.some(c => c.toLowerCase() === normalizedQuery) &&
!cities.some(c => c.toLowerCase() === normalizedQuery);
const toggle = (city) => {
if (value.includes(city)) {
onChange(value.filter(c => c !== city));
} else {
onChange([...value, city]);
}
setQuery("");
inputRef.current?.focus();
};
const addCustom = () => {
const city = query.trim();
if (city && !value.includes(city)) {
onChange([...value, city]);
}
setQuery("");
inputRef.current?.focus();
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (canAddCustom) {
addCustom();
} else if (options.length) {
toggle(options[0]);
}
} else if (e.key === "Backspace" && query === "" && value.length) {
onChange(value.slice(0, -1));
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className="f-city-multiselect" ref={rootRef}>
<div className={`f-city-ms-control${open ? " open" : ""}`}
onClick={() => setOpen(v => !v)}>
<svg className="f-city-ms-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<circle cx="12" cy="10" r="3"/>
<path d="M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11z"/>
</svg>
<div className="f-city-ms-tags">
{value.map(city => (
<span key={city} className="f-city-ms-tag">
{city}
<button
type="button"
className="f-city-ms-tag-remove"
aria-label={t('feed.filterCitiesRemove')}
onClick={(e) => {
e.stopPropagation();
toggle(city);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" width="10"
height="10">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</span>
))}
<input
ref={inputRef}
className="f-city-ms-input"
type="text"
value={query}
placeholder={value.length ? "" : t('feed.filterCitiesPlaceholder')}
onChange={e => {
setQuery(e.target.value);
setOpen(true);
}}
onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown}
/>
</div>
{value.length > 0 && (
<button
type="button"
className="f-city-ms-clear"
aria-label={t('feed.filterCitiesClear')}
onClick={(e) => {
e.stopPropagation();
onChange([]);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
width="16" height="16">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
<svg className="f-city-ms-chevron" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
{open && (
<div className="f-city-ms-dropdown">
{options.length === 0 && !canAddCustom && (
<div className="f-city-ms-empty">{t('feed.filterCitiesEmpty')}</div>
)}
{canAddCustom && (
<button type="button" className="f-city-ms-option f-city-ms-add"
onClick={() => {
addCustom();
setOpen(false);
}}>
<span className="f-city-ms-check">+</span>
<span className="f-city-ms-label">
{t('feed.filterCitiesAdd', { city: query.trim() })}
</span>
</button>
)}
{options.map(city => {
const selected = value.includes(city);
return (
<button key={city} type="button"
className={`f-city-ms-option${selected ? " selected" : ""}`}
onClick={() => toggle(city)}>
<span className="f-city-ms-check">{selected ? "✓" : ""}</span>
<span className="f-city-ms-label">{city}</span>
</button>
);
})}
</div>
)}
</div>
);
}