split pages CreateUpdatePost on two part and fixed button map
This commit is contained in:
28
src/components/ClearableField.jsx
Normal file
28
src/components/ClearableField.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import CloseIcon from "../components/icons/CloseIcon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClearableField — обёртка над полем ввода, добавляющая справа крестик,
|
||||||
|
* который полностью стирает содержимое поля.
|
||||||
|
*
|
||||||
|
* @param {function} onClear — очистить поле
|
||||||
|
* @param {boolean} visible — показывать ли крестик (обычно наличие значения)
|
||||||
|
* @param {ReactNode} children — само поле ввода (input / textarea)
|
||||||
|
*/
|
||||||
|
export default function ClearableField({ onClear, visible, children }) {
|
||||||
|
return (
|
||||||
|
<div className="f-clr-wrap">
|
||||||
|
{children}
|
||||||
|
{visible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="f-clr-btn"
|
||||||
|
onClick={onClear}
|
||||||
|
aria-label="Очистить"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,18 +177,6 @@ export default function NahodkaLogo({
|
|||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Блик */}
|
|
||||||
|
|
||||||
<ellipse
|
|
||||||
cx="-22"
|
|
||||||
cy="-25"
|
|
||||||
rx="8"
|
|
||||||
ry="15"
|
|
||||||
transform="rotate(35 -22 -25)"
|
|
||||||
fill="white"
|
|
||||||
opacity="0.65"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ================================================= */}
|
{/* ================================================= */}
|
||||||
{/* ЛАПКА */}
|
{/* ЛАПКА */}
|
||||||
{/* ================================================= */}
|
{/* ================================================= */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { compressImage } from "../utils/compressImage";
|
import { compressImage } from "../utils/compressImage";
|
||||||
import { getImageUrl } from "../config/authConstants";
|
import { getImageUrl } from "../config/authConstants";
|
||||||
|
|
||||||
export const MAX_SIZE_MB = 5;
|
export const MAX_SIZE_MB = 10;
|
||||||
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
const [coords, setCoords] = useState(initialCoords);
|
const [coords, setCoords] = useState(initialCoords);
|
||||||
const [address, setAddress] = useState(initialAddress ?? "");
|
const [address, setAddress] = useState(initialAddress ?? "");
|
||||||
const addressRef = useRef(initialAddress ?? "");
|
const addressRef = useRef(initialAddress ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
// Всегда хранит последние координаты, чтобы при повторной инициализации
|
||||||
|
// карты (например, при повторном открытии модалки) маркер появлялся там,
|
||||||
|
// где пользователь его оставил, а не сбрасывался на initialCoords.
|
||||||
|
const coordsRef = useRef(initialCoords);
|
||||||
|
useEffect(() => { coordsRef.current = coords; }, [coords]);
|
||||||
|
|
||||||
const resolveAddress = useCallback((lat, lng) => {
|
const resolveAddress = useCallback((lat, lng) => {
|
||||||
const requestId = ++requestIdRef.current;
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
fetch(
|
fetch(
|
||||||
`https://geocode-maps.yandex.ru/1.x/?apikey=${YANDEX_GEOCODER_API_KEY}&geocode=${lng},${lat}&format=json&lang=ru_RU`
|
`https://geocode-maps.yandex.ru/1.x/?apikey=${YANDEX_GEOCODER_API_KEY}&geocode=${lng},${lat}&format=json&lang=ru_RU`
|
||||||
@@ -29,6 +36,7 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
const resolved = found || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
const resolved = found || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||||
setAddress(resolved);
|
setAddress(resolved);
|
||||||
addressRef.current = resolved;
|
addressRef.current = resolved;
|
||||||
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (requestId !== requestIdRef.current) return;
|
if (requestId !== requestIdRef.current) return;
|
||||||
@@ -36,6 +44,7 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
const fallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
const fallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||||
setAddress(fallback);
|
setAddress(fallback);
|
||||||
addressRef.current = fallback;
|
addressRef.current = fallback;
|
||||||
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -47,8 +56,9 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
const init = () => {
|
const init = () => {
|
||||||
if (ymapRef.current) return;
|
if (ymapRef.current) return;
|
||||||
|
|
||||||
const center = initialCoords
|
const startCoords = coordsRef.current;
|
||||||
? [initialCoords.latitude, initialCoords.longitude]
|
const center = startCoords
|
||||||
|
? [startCoords.latitude, startCoords.longitude]
|
||||||
: [55.751574, 37.573856];
|
: [55.751574, 37.573856];
|
||||||
|
|
||||||
const map = new window.ymaps.Map(mapRef.current, {
|
const map = new window.ymaps.Map(mapRef.current, {
|
||||||
@@ -58,11 +68,11 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
});
|
});
|
||||||
ymapRef.current = map;
|
ymapRef.current = map;
|
||||||
|
|
||||||
if (initialCoords) {
|
if (startCoords) {
|
||||||
const pm = new window.ymaps.Placemark(center, {}, { draggable: true });
|
const pm = new window.ymaps.Placemark(center, {}, { draggable: true });
|
||||||
map.geoObjects.add(pm);
|
map.geoObjects.add(pm);
|
||||||
placemarkRef.current = pm;
|
placemarkRef.current = pm;
|
||||||
resolveAddress(initialCoords.latitude, initialCoords.longitude);
|
resolveAddress(startCoords.latitude, startCoords.longitude);
|
||||||
|
|
||||||
pm.events.add("dragend", () => {
|
pm.events.add("dragend", () => {
|
||||||
const [lat, lng] = pm.geometry.getCoordinates();
|
const [lat, lng] = pm.geometry.getCoordinates();
|
||||||
@@ -102,12 +112,11 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
placemarkRef.current = null;
|
placemarkRef.current = null;
|
||||||
initializedRef.current = false;
|
initializedRef.current = false;
|
||||||
};
|
};
|
||||||
// initialCoords намеренно не в deps — нужен только при первой инициализации,
|
// enabled намеренно в deps — карта инициализируется/разрушается при открытии
|
||||||
// а initializedRef защищает от повторного запуска. Объект initialCoords
|
// и закрытии модалки. Начальные координаты читаются из coordsRef, чтобы
|
||||||
// пересоздаётся на каждый рендер родителя, поэтому включение его в deps
|
// при повторном открытии маркер вставал на последний выбранный адрес.
|
||||||
// приводило бы к пересозданию карты на каждый ввод в форме.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [enabled, resolveAddress]);
|
}, [enabled, resolveAddress]);
|
||||||
|
|
||||||
return { mapRef, coords, address, addressRef };
|
return { mapRef, coords, address, addressRef, loading };
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,10 @@
|
|||||||
"categoryLabel": "Category",
|
"categoryLabel": "Category",
|
||||||
"mapLabel": "Location on map",
|
"mapLabel": "Location on map",
|
||||||
"mapHint": "Click on the map to mark the location",
|
"mapHint": "Click on the map to mark the location",
|
||||||
|
"mapPickLabel": "Choose on map",
|
||||||
|
"mapEmpty": "Location not selected yet",
|
||||||
|
"selectedAddress": "Selected address",
|
||||||
|
"saveLocation": "Save location",
|
||||||
"addressLabel": "Address",
|
"addressLabel": "Address",
|
||||||
"addressPlaceholder": "E.g. 123 Main St, New York",
|
"addressPlaceholder": "E.g. 123 Main St, New York",
|
||||||
"phoneLabel": "Phone number",
|
"phoneLabel": "Phone number",
|
||||||
@@ -119,7 +123,9 @@
|
|||||||
"rulesLinkText": "platform rules",
|
"rulesLinkText": "platform rules",
|
||||||
"deleteError": "Failed to delete the ad",
|
"deleteError": "Failed to delete the ad",
|
||||||
"deleteErrorWithStatus": "Failed to delete ad (error {{status}})",
|
"deleteErrorWithStatus": "Failed to delete ad (error {{status}})",
|
||||||
"statusUpdateError": "Failed to change the ad status."
|
"statusUpdateError": "Failed to change the ad status.",
|
||||||
|
"nextStep": "Next",
|
||||||
|
"backStep": "Back"
|
||||||
},
|
},
|
||||||
"feed": {
|
"feed": {
|
||||||
"loading": "Loading ads...",
|
"loading": "Loading ads...",
|
||||||
|
|||||||
@@ -102,6 +102,10 @@
|
|||||||
"categoryLabel": "Категория",
|
"categoryLabel": "Категория",
|
||||||
"mapLabel": "Место на карте",
|
"mapLabel": "Место на карте",
|
||||||
"mapHint": "Нажмите на карту, чтобы отметить место",
|
"mapHint": "Нажмите на карту, чтобы отметить место",
|
||||||
|
"mapPickLabel": "Указать местоположение на карте",
|
||||||
|
"mapEmpty": "Место ещё не выбрано",
|
||||||
|
"selectedAddress": "Выбранный адрес",
|
||||||
|
"saveLocation": "Сохранить местоположение",
|
||||||
"addressLabel": "Адрес",
|
"addressLabel": "Адрес",
|
||||||
"addressPlaceholder": "Например: Москва, ул. Ленина, 5",
|
"addressPlaceholder": "Например: Москва, ул. Ленина, 5",
|
||||||
"phoneLabel": "Номер телефона",
|
"phoneLabel": "Номер телефона",
|
||||||
@@ -119,7 +123,9 @@
|
|||||||
"rulesLinkText": "правилами размещения площадки",
|
"rulesLinkText": "правилами размещения площадки",
|
||||||
"deleteError": "Не удалось удалить объявление",
|
"deleteError": "Не удалось удалить объявление",
|
||||||
"deleteErrorWithStatus": "Не удалось удалить объявление (ошибка {{status}})",
|
"deleteErrorWithStatus": "Не удалось удалить объявление (ошибка {{status}})",
|
||||||
"statusUpdateError": "Не удалось изменить статус объявления"
|
"statusUpdateError": "Не удалось изменить статус объявления",
|
||||||
|
"nextStep": "Далее",
|
||||||
|
"backStep": "Назад"
|
||||||
|
|
||||||
},
|
},
|
||||||
"feed": {
|
"feed": {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { useImageUpload, ACCEPT, MAX_SIZE_MB } from "../hooks/useImageUpload";
|
|||||||
import { validatePostForm } from "../utils/validatePostForm";
|
import { validatePostForm } from "../utils/validatePostForm";
|
||||||
import { formatPhone } from "../utils/formatPhone";
|
import { formatPhone } from "../utils/formatPhone";
|
||||||
import { useYandexMap } from "../hooks/useYandexMap";
|
import { useYandexMap } from "../hooks/useYandexMap";
|
||||||
import { isMobileDevice } from "../utils/isMobileDevice";
|
import MapModal from "./MapModal";
|
||||||
|
import ClearableField from "../components/ClearableField";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateUpdatePost — модальная форма создания и редактирования объявления.
|
* CreateUpdatePost — модальная форма создания и редактирования объявления.
|
||||||
@@ -38,7 +39,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [rulesModalOpen, setRulesModalOpen] = useState(false);
|
const [rulesModalOpen, setRulesModalOpen] = useState(false);
|
||||||
|
const [mapModalOpen, setMapModalOpen] = useState(false);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
const [step, setStep] = useState(1); // 1 — первая часть, 2 — вторая часть
|
||||||
|
|
||||||
const {
|
const {
|
||||||
images: uploadedImages,
|
images: uploadedImages,
|
||||||
@@ -59,8 +62,8 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
? { latitude: initialData.latitude, longitude: initialData.longitude }
|
? { latitude: initialData.latitude, longitude: initialData.longitude }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { mapRef, coords, address, addressRef } = useYandexMap(
|
const { mapRef, coords, address, addressRef, loading: mapLoading } = useYandexMap(
|
||||||
true,
|
mapModalOpen,
|
||||||
initialCoords,
|
initialCoords,
|
||||||
isEdit ? (initialData.location || "") : ""
|
isEdit ? (initialData.location || "") : ""
|
||||||
);
|
);
|
||||||
@@ -155,6 +158,8 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
{/* Тип: Потеря / Находка */}
|
{/* Тип: Потеря / Находка */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.typeLabel')}</label>
|
<label className="f-m-label">{t('post.typeLabel')}</label>
|
||||||
@@ -179,6 +184,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
{/* Название */}
|
{/* Название */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.titleLabel')}</label>
|
<label className="f-m-label">{t('post.titleLabel')}</label>
|
||||||
|
<ClearableField onClear={() => set("title", "")} visible={!!form.title}>
|
||||||
<input
|
<input
|
||||||
className={`f-m-field${errors.title ? " err" : ""}`}
|
className={`f-m-field${errors.title ? " err" : ""}`}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -186,12 +192,14 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={e => set("title", e.target.value)}
|
onChange={e => set("title", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</ClearableField>
|
||||||
{errors.title && <p className="f-err-msg">{errors.title}</p>}
|
{errors.title && <p className="f-err-msg">{errors.title}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Описание */}
|
{/* Описание */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.descriptionLabel')}</label>
|
<label className="f-m-label">{t('post.descriptionLabel')}</label>
|
||||||
|
<ClearableField onClear={() => set("description", "")} visible={!!form.description}>
|
||||||
<textarea
|
<textarea
|
||||||
className={`f-m-field${errors.description ? " err" : ""}`}
|
className={`f-m-field${errors.description ? " err" : ""}`}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -199,6 +207,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={e => set("description", e.target.value)}
|
onChange={e => set("description", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</ClearableField>
|
||||||
{errors.description && <p className="f-err-msg">{errors.description}</p>}
|
{errors.description && <p className="f-err-msg">{errors.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,7 +239,6 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPT}
|
accept={ACCEPT}
|
||||||
multiple
|
multiple
|
||||||
capture={isMobileDevice() ? "environment" : undefined}
|
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="f-img-input"
|
className="f-img-input"
|
||||||
disabled={compressing}
|
disabled={compressing}
|
||||||
@@ -246,8 +254,20 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
|
<p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="f-m-submit"
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
<div className="f-m-submit-row">{t('post.nextStep')}</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
{/* Категория */}
|
{/* Категория */}
|
||||||
<div className="f-m-row">
|
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.categoryLabel')}</label>
|
<label className="f-m-label">{t('post.categoryLabel')}</label>
|
||||||
<select
|
<select
|
||||||
@@ -260,21 +280,24 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
</select>
|
</select>
|
||||||
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
{errors.category && <p className="f-err-msg">{errors.category}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Карта */}
|
{/* Место на карте */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.mapLabel')}</label>
|
<label className="f-m-label">{t('post.mapLabel')}</label>
|
||||||
<div className="f-map-frame">
|
<button
|
||||||
<div ref={mapRef} className="f-map-inner" />
|
className="f-map-pick"
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => setMapModalOpen(true)}
|
||||||
|
>
|
||||||
|
{form.address ? `📍 ${form.address}` : t('post.mapPickLabel')}
|
||||||
|
</button>
|
||||||
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
|
{!coords && <p className="f-hint">{t('post.mapHint')}</p>}
|
||||||
{errors.map && <p className="f-err-msg">{errors.map}</p>}
|
{errors.map && <p className="f-err-msg">{errors.map}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.addressLabel')}</label>
|
<label className="f-m-label">{t('post.addressLabel')}</label>
|
||||||
|
<ClearableField onClear={() => set("address", "")} visible={!!form.address}>
|
||||||
<input
|
<input
|
||||||
className={`f-m-field${errors.address ? " err" : ""}`}
|
className={`f-m-field${errors.address ? " err" : ""}`}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -282,12 +305,14 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
value={form.address}
|
value={form.address}
|
||||||
onChange={e => set("address", e.target.value)}
|
onChange={e => set("address", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</ClearableField>
|
||||||
{errors.address && <p className="f-err-msg">{errors.address}</p>}
|
{errors.address && <p className="f-err-msg">{errors.address}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Телефон */}
|
{/* Телефон */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">{t('post.phoneLabel')}</label>
|
<label className="f-m-label">{t('post.phoneLabel')}</label>
|
||||||
|
<ClearableField onClear={() => set("phone", "")} visible={!!form.phone}>
|
||||||
<input
|
<input
|
||||||
className={`f-m-field${errors.phone ? " err" : ""}`}
|
className={`f-m-field${errors.phone ? " err" : ""}`}
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -295,6 +320,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={e => set("phone", formatPhone(e.target.value))}
|
onChange={e => set("phone", formatPhone(e.target.value))}
|
||||||
/>
|
/>
|
||||||
|
</ClearableField>
|
||||||
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
{errors.phone && <p className="f-err-msg">{errors.phone}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -343,12 +369,18 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
|
|
||||||
{apiError && <p className="f-err-msg">{apiError}</p>}
|
{apiError && <p className="f-err-msg">{apiError}</p>}
|
||||||
|
|
||||||
|
<button className="f-m-back" type="button" onClick={() => setStep(1)} disabled={loading}>
|
||||||
|
{t('post.backStep')}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button className="f-m-submit" type="button" onClick={handleSubmit} disabled={loading}>
|
<button className="f-m-submit" type="button" onClick={handleSubmit} disabled={loading}>
|
||||||
<div className="f-m-submit-row">
|
<div className="f-m-submit-row">
|
||||||
{loading && <div className="f-m-spinner" />}
|
{loading && <div className="f-m-spinner" />}
|
||||||
{isEdit ? t('post.saveChanges') : t('post.publish')}
|
{isEdit ? t('post.saveChanges') : t('post.publish')}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,6 +389,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mapModalOpen && (
|
||||||
|
<MapModal
|
||||||
|
mapRef={mapRef}
|
||||||
|
address={address}
|
||||||
|
mapLoading={mapLoading}
|
||||||
|
onClose={() => setMapModalOpen(false)}
|
||||||
|
onSave={() => setMapModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{resultModal && (
|
{resultModal && (
|
||||||
<div
|
<div
|
||||||
className="f-modal-bg"
|
className="f-modal-bg"
|
||||||
|
|||||||
53
src/pages/MapModal.jsx
Normal file
53
src/pages/MapModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import CloseIcon from "../components/icons/CloseIcon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapModal — модальное окно с картой для выбора местоположения.
|
||||||
|
*
|
||||||
|
* @param {object} mapRef — ref, в который монтируется Яндекс.Карта
|
||||||
|
* @param {string} address — текущий (выбранный) адрес
|
||||||
|
* @param {boolean} mapLoading — идёт ли подгрузка адреса по координатам
|
||||||
|
* @param {function} onClose — закрыть окно без сохранения
|
||||||
|
* @param {function} onSave — сохранить выбранное местоположение
|
||||||
|
*/
|
||||||
|
export default function MapModal({ mapRef, address, mapLoading, onClose, onSave }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="f-modal f-map-modal">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="f-modal-head">
|
||||||
|
<span className="f-modal-title">{t('post.mapLabel')}</span>
|
||||||
|
<button className="f-close-btn" type="button" onClick={onClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div className="f-map-frame f-map-modal-frame">
|
||||||
|
<div ref={mapRef} className="f-map-inner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected address */}
|
||||||
|
<div className="f-map-selected">
|
||||||
|
<span className="f-map-selected-label">{t('post.selectedAddress')}</span>
|
||||||
|
<span className="f-map-selected-value">
|
||||||
|
{mapLoading ? t('post.mapLoading') : (address || t('post.mapEmpty'))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="f-m-submit" type="button" onClick={onSave} disabled={mapLoading}>
|
||||||
|
<div className="f-m-submit-row">{t('post.saveLocation')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -336,4 +336,35 @@
|
|||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
max-width: 85%;
|
max-width: 85%;
|
||||||
}
|
}
|
||||||
|
.chat-list {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.chat-list-item {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
}
|
||||||
|
.chat-list-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.chat-list-delete-btn {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* На сенсорных экранах нет hover — показываем кнопку удаления всегда. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.chat-list-delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Безопасная зона под домашним индикатором iPhone (Dynamic Island / iPhone 15+). */
|
||||||
|
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||||
|
.chat-page {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.chat-list {
|
||||||
|
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,32 @@
|
|||||||
.f-m-field:focus { border-color: var(--teal); background: rgba(var(--teal-rgb), .07); box-shadow: 0 0 0 3px rgba(var(--teal-rgb), .12); }
|
.f-m-field:focus { border-color: var(--teal); background: rgba(var(--teal-rgb), .07); box-shadow: 0 0 0 3px rgba(var(--teal-rgb), .12); }
|
||||||
.f-m-field::placeholder { color: var(--muted); }
|
.f-m-field::placeholder { color: var(--muted); }
|
||||||
.f-m-field option { background: var(--bg-surface-solid); color: var(--text-primary); }
|
.f-m-field option { background: var(--bg-surface-solid); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* select высотой как текстовые поля */
|
||||||
|
select.f-m-field {
|
||||||
|
height: auto;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
select.f-m-field::selection { background: transparent; }
|
||||||
.f-m-field.err { border-color: rgba(237,73,86,.55); }
|
.f-m-field.err { border-color: rgba(237,73,86,.55); }
|
||||||
|
|
||||||
|
/* Clearable field */
|
||||||
|
.f-clr-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.f-clr-wrap .f-m-field { padding-right: 40px; }
|
||||||
|
.f-clr-wrap textarea.f-m-field { line-height: 1.4; }
|
||||||
|
.f-clr-btn {
|
||||||
|
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 26px; height: 26px; border: none; border-radius: 8px;
|
||||||
|
background: var(--hover-bg); color: var(--muted);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.f-clr-btn:hover { background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
.f-clr-wrap textarea + .f-clr-btn { top: 22px; transform: none; }
|
||||||
|
|
||||||
/* Type Toggle */
|
/* Type Toggle */
|
||||||
.f-type-toggle { display: flex; gap: 8px; }
|
.f-type-toggle { display: flex; gap: 8px; }
|
||||||
.f-type-opt {
|
.f-type-opt {
|
||||||
@@ -54,17 +78,20 @@
|
|||||||
font-weight: 800; cursor: pointer; text-align: center; transition: all .2s;
|
font-weight: 800; cursor: pointer; text-align: center; transition: all .2s;
|
||||||
}
|
}
|
||||||
.f-type-opt.loss { border-color: rgba(237,73,86,.5); background: rgba(237,73,86,.12); color: #ff6b6b; }
|
.f-type-opt.loss { border-color: rgba(237,73,86,.5); background: rgba(237,73,86,.12); color: #ff6b6b; }
|
||||||
.f-type-opt.found { border-color: rgba(var(--teal-rgb), .5); background: rgba(var(--teal-rgb), .1); color: var(--teal); }
|
.f-type-opt.found { border-color: rgba(48, 168, 94, .5); background: rgba(48, 168, 94, .1); color: #30a85e; }
|
||||||
|
|
||||||
/* Map picker */
|
/* Map picker */
|
||||||
.f-map-pick {
|
.f-map-pick {
|
||||||
height: 80px; background: var(--bg-card-img);
|
width: 100%; padding: 13px 16px;
|
||||||
border-radius: 12px; border: 1.5px dashed var(--border);
|
background: var(--teal);
|
||||||
|
border: none; border-radius: 13px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
gap: 8px; color: var(--muted); font-size: .83rem; cursor: pointer;
|
gap: 8px; color: #fff; font-size: .95rem; cursor: pointer;
|
||||||
transition: border-color .2s; font-family: var(--font); font-weight: 600;
|
transition: background .15s, transform .15s, box-shadow .15s;
|
||||||
|
font-family: var(--font); font-weight: 700;
|
||||||
}
|
}
|
||||||
.f-map-pick:hover { border-color: rgba(var(--teal-rgb), .4); color: var(--teal); }
|
.f-map-pick:hover { background: var(--teal2); transform: translateY(-1px); }
|
||||||
|
.f-map-pick:active { transform: translateY(0); }
|
||||||
|
|
||||||
/* Image Upload */
|
/* Image Upload */
|
||||||
.f-img-upload-label {
|
.f-img-upload-label {
|
||||||
@@ -217,6 +244,17 @@
|
|||||||
font-size: 1rem; font-weight: 800; cursor: pointer; margin-top: 6px;
|
font-size: 1rem; font-weight: 800; cursor: pointer; margin-top: 6px;
|
||||||
transition: background .15s, opacity .15s, transform .15s;
|
transition: background .15s, opacity .15s, transform .15s;
|
||||||
}
|
}
|
||||||
|
.f-m-back {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1.5px solid var(--border); border-radius: 13px;
|
||||||
|
padding: 12px; color: var(--text-primary); font-family: var(--font);
|
||||||
|
font-size: .95rem; font-weight: 700; cursor: pointer; margin-bottom: 6px;
|
||||||
|
transition: background .15s, border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.f-m-back:hover:not(:disabled) { border-color: var(--teal); color: var(--teal); transform: translateY(-1px); }
|
||||||
|
.f-m-back:active:not(:disabled) { transform: translateY(0); }
|
||||||
|
.f-m-back:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
.f-m-submit:hover:not(:disabled) { background: var(--teal2); transform: translateY(-1px); }
|
.f-m-submit:hover:not(:disabled) { background: var(--teal2); transform: translateY(-1px); }
|
||||||
.f-m-submit:active:not(:disabled) { transform: translateY(0); }
|
.f-m-submit:active:not(:disabled) { transform: translateY(0); }
|
||||||
.f-m-submit:disabled { opacity: .5; cursor: not-allowed; animation: none; background: var(--muted2); color: var(--muted); }
|
.f-m-submit:disabled { opacity: .5; cursor: not-allowed; animation: none; background: var(--muted2); color: var(--muted); }
|
||||||
@@ -271,7 +309,8 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.f-modal-bg {
|
.f-modal-bg {
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-modal-bg:has(.f-post-detail-modal) {
|
.f-modal-bg:has(.f-post-detail-modal) {
|
||||||
@@ -285,7 +324,7 @@
|
|||||||
.f-modal:not(.auth-modal) {
|
.f-modal:not(.auth-modal) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 8px);
|
max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 8px);
|
||||||
border-radius: 20px 20px 0 0;
|
border-radius: 20px;
|
||||||
padding: calc(20px + env(safe-area-inset-top, 0px)) 16px calc(32px + env(safe-area-inset-bottom, 0px));
|
padding: calc(20px + env(safe-area-inset-top, 0px)) 16px calc(32px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +440,29 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Map Modal */
|
||||||
|
.f-map-modal {
|
||||||
|
width: 520px;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
.f-map-modal-frame {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
.f-map-selected {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
margin: 14px 0 6px; padding: 12px 14px;
|
||||||
|
background: var(--bg-input); border: 1.5px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.f-map-selected-label {
|
||||||
|
color: var(--muted); font-size: .68rem; font-weight: 700;
|
||||||
|
letter-spacing: .5px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.f-map-selected-value {
|
||||||
|
color: var(--text-primary); font-size: .9rem; font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.f-modal-sm {
|
.f-modal-sm {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user