feat: add city avatar upload functionality and enhance city management features
This commit is contained in:
6
backend/migrations/011_city_avatars.sql
Normal file
6
backend/migrations/011_city_avatars.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- A city may use a raster coat of arms or another custom image instead of the
|
||||
-- text/emoji emblem. The file itself is stored under public/uploads/cities.
|
||||
ALTER TABLE cities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cities_avatar ON cities(avatar_url)
|
||||
WHERE avatar_url IS NOT NULL;
|
||||
@@ -1,5 +1,9 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const cityUploadDir = path.join(__dirname, '../../public/uploads/cities');
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
@@ -25,7 +29,7 @@ class CitiesController {
|
||||
async getAllCities(req, res) {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
SELECT id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
FROM cities
|
||||
WHERE is_active = true
|
||||
ORDER BY name COLLATE "ru-RU-x-icu"
|
||||
@@ -46,7 +50,7 @@ class CitiesController {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO cities (name, slug, emblem, center_lat, center_lon, schedule_source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, slug, name, emblem, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [
|
||||
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
|
||||
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
|
||||
@@ -60,6 +64,42 @@ class CitiesController {
|
||||
res.status(500).json({ error: 'Не удалось добавить город.' });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadCityAvatar(req, res) {
|
||||
const cityId = Number(req.params.cityId);
|
||||
if (!Number.isInteger(cityId) || cityId <= 0) {
|
||||
if (req.file) await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Выберите изображение.' });
|
||||
|
||||
try {
|
||||
const current = await pool.query('SELECT avatar_url FROM cities WHERE id = $1', [cityId]);
|
||||
if (!current.rows.length) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
return res.status(404).json({ error: 'Город не найден.' });
|
||||
}
|
||||
|
||||
const avatarUrl = `/uploads/cities/${req.file.filename}`;
|
||||
const result = await pool.query(`
|
||||
UPDATE cities SET avatar_url = $1 WHERE id = $2
|
||||
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [avatarUrl, cityId]);
|
||||
|
||||
const previous = current.rows[0].avatar_url;
|
||||
if (previous?.startsWith('/uploads/cities/')) {
|
||||
await fs.unlink(path.join(cityUploadDir, path.basename(previous))).catch(error => {
|
||||
logger.warn('Failed to remove previous city avatar', { cityId, message: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Изображение города сохранено.', city: result.rows[0] });
|
||||
} catch (error) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
logger.error('Error uploading city avatar', { cityId, message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось сохранить изображение города.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CitiesController();
|
||||
|
||||
@@ -99,15 +99,13 @@ app.use((err, req, res, next) => {
|
||||
// Multer errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'File too large',
|
||||
message: 'Maximum file size is 5MB'
|
||||
error: 'Размер файла не должен превышать 5 МБ.'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.message && err.message.includes('Only image files are allowed')) {
|
||||
if (err.message && (err.message.includes('Only image files are allowed') || err.message.includes('Only JPEG, PNG, and WEBP images are allowed'))) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid file type',
|
||||
message: err.message
|
||||
error: 'Поддерживаются только изображения JPG, PNG и WEBP.'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
30
backend/src/middleware/cityUpload.js
Normal file
30
backend/src/middleware/cityUpload.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads/cities');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const allowedExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, callback) => callback(null, uploadDir),
|
||||
filename: (req, file, callback) => {
|
||||
const cityId = Number(req.params.cityId);
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
callback(null, `city-${cityId}-${Date.now()}${extension}`);
|
||||
}
|
||||
});
|
||||
|
||||
function fileFilter(_req, file, callback) {
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
if (allowedExtensions.has(extension) && allowedMimeTypes.has(file.mimetype)) return callback(null, true);
|
||||
return callback(new Error('Only JPEG, PNG, and WEBP images are allowed'));
|
||||
}
|
||||
|
||||
module.exports = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
const express = require('express');
|
||||
const citiesController = require('../controllers/citiesController');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const cityUpload = require('../middleware/cityUpload');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', citiesController.getAllCities.bind(citiesController));
|
||||
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
|
||||
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -73,6 +73,7 @@ services:
|
||||
volumes:
|
||||
- ./backend/logs:/app/logs
|
||||
- avatars_data:/app/public/uploads/avatars
|
||||
- city_images_data:/app/public/uploads/cities
|
||||
- ${FIREBASE_SECRETS_DIR:-./secrets}:/run/secrets:ro
|
||||
|
||||
frontend:
|
||||
@@ -112,6 +113,7 @@ volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
avatars_data:
|
||||
city_images_data:
|
||||
|
||||
networks:
|
||||
transit-network:
|
||||
|
||||
@@ -3,9 +3,19 @@ import { Check, ChevronDown, MapPinned, Plus } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useCity } from '../contexts/CityContext';
|
||||
import { getApiOrigin } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_ORIGIN = getApiOrigin();
|
||||
|
||||
function CityEmblem({ city, className = '' }) {
|
||||
return <span className={`city-emblem ${className}`} aria-hidden="true">{city?.emblem || <MapPinned size={20} />}</span>;
|
||||
const avatarUrl = city?.avatar_url
|
||||
? (city.avatar_url.startsWith('http') ? city.avatar_url : `${API_ORIGIN}${city.avatar_url}`)
|
||||
: null;
|
||||
|
||||
return <span className={`city-emblem ${className}`} aria-hidden="true">
|
||||
{avatarUrl && <img src={avatarUrl} alt="" onError={event => { event.currentTarget.style.display = 'none'; }} />}
|
||||
<span className="city-emblem-fallback">{city?.emblem || <MapPinned size={20} />}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
export default function CitySelector() {
|
||||
|
||||
@@ -41,7 +41,12 @@ export function CityProvider({ children }) {
|
||||
selectCity(city);
|
||||
}
|
||||
|
||||
return <CityContext.Provider value={{ cities, activeCity, loading, error, selectCity, addCity, reloadCities: loadCities }}>
|
||||
function replaceCity(city) {
|
||||
setCities(current => current.map(item => item.id === city.id ? city : item));
|
||||
setActiveCity(current => current?.id === city.id ? city : current);
|
||||
}
|
||||
|
||||
return <CityContext.Provider value={{ cities, activeCity, loading, error, selectCity, addCity, replaceCity, reloadCities: loadCities }}>
|
||||
{children}
|
||||
</CityContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
.sidebar-account { @apply flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm; min-height: 56px; }
|
||||
.city-selector { position: relative; margin-top: 12px; }
|
||||
.sidebar-city-card { @apply flex w-full items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm transition-colors hover:bg-gray-50; min-height: 64px; }
|
||||
.city-emblem { @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-lg; overflow: hidden; }
|
||||
.city-emblem { @apply relative flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-lg; overflow: hidden; }
|
||||
.city-emblem > img { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; }
|
||||
.city-emblem-fallback { @apply flex h-full w-full items-center justify-center; }
|
||||
.city-emblem.is-small { @apply h-8 w-8 text-base; }
|
||||
.city-emblem.is-large { @apply h-14 w-14 rounded-xl text-3xl; }
|
||||
.city-switcher { position: absolute; z-index: 50; top: calc(100% + 8px); left: 0; right: 0; min-width: 232px; padding: 8px; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px #00000026, 0 2px 8px #0000000d; }
|
||||
@@ -69,8 +71,18 @@
|
||||
.city-form-grid { @apply grid gap-4 sm:grid-cols-2; }
|
||||
.city-form-hint { @apply mt-1 block text-xs text-gray-500; }
|
||||
.city-form-error { @apply rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700; }
|
||||
.city-form-notice { @apply rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800; }
|
||||
.city-avatar-field { @apply flex flex-col gap-3; }
|
||||
.city-avatar-preview { @apply h-20 w-20 rounded-xl border border-gray-200 bg-gray-50 object-cover; }
|
||||
.city-center-picker { @apply space-y-2 sm:col-span-2; }
|
||||
.city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; }
|
||||
.city-center-coordinates { @apply text-sm text-gray-600; }
|
||||
.city-center-coordinates strong { @apply text-gray-900; }
|
||||
.city-avatar-editor { @apply ml-auto flex shrink-0 flex-col items-end gap-2; }
|
||||
.city-avatar-editor .btn-secondary { @apply inline-flex items-center gap-2; }
|
||||
.city-avatar-editor-error { @apply max-w-[200px] text-right text-xs text-red-700; }
|
||||
.cities-grid { @apply grid gap-4 md:grid-cols-2; }
|
||||
.city-network-card { @apply flex items-center gap-4; min-height: 104px; }
|
||||
.city-network-card { @apply flex flex-wrap items-center gap-4; min-height: 104px; }
|
||||
.city-network-card.is-active { border-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; }
|
||||
.city-network-card h2 { @apply text-lg font-semibold; }
|
||||
.city-network-card p { @apply mt-1 text-sm text-gray-500; }
|
||||
|
||||
@@ -1,27 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, MapPinned, Plus } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Plus } from 'lucide-react';
|
||||
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { citiesApi } from '../services/api';
|
||||
import { CityEmblem } from '../components/CitySelector';
|
||||
import { useCity } from '../contexts/CityContext';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
|
||||
});
|
||||
|
||||
const BELARUS_CENTER = [53.7098, 27.9534];
|
||||
const acceptedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const transliteration = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya'
|
||||
};
|
||||
const emptyForm = { name: '', slug: '', emblem: '🏙️', center_lat: '', center_lon: '' };
|
||||
|
||||
function slugFromName(name) {
|
||||
return String(name || '').toLowerCase().split('').map(char => transliteration[char] ?? char)
|
||||
.join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
const emptyForm = { name: '', slug: '', emblem: '🏙️', center_lat: '', center_lon: '' };
|
||||
function isImageValid(file) {
|
||||
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
|
||||
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function MapViewport({ position }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (position) map.setView(position, Math.max(map.getZoom(), 12));
|
||||
}, [map, position]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CenterMapEvents({ onSelect }) {
|
||||
useMapEvents({ click: event => onSelect(event.latlng.lat, event.latlng.lng) });
|
||||
return null;
|
||||
}
|
||||
|
||||
function DraggableCenterMarker({ position, onSelect }) {
|
||||
const markerRef = useRef(null);
|
||||
return <Marker position={position} draggable ref={markerRef} eventHandlers={{
|
||||
dragend: () => {
|
||||
const point = markerRef.current?.getLatLng();
|
||||
if (point) onSelect(point.lat, point.lng);
|
||||
}
|
||||
}} />;
|
||||
}
|
||||
|
||||
function CityCenterPicker({ position, fallbackCenter, onSelect }) {
|
||||
return <div className="city-center-picker">
|
||||
<span className="label">Центр города на карте</span>
|
||||
<p className="city-form-hint">Нажмите на карту, затем при необходимости перетащите маркер. Вручную координаты вводить не нужно.</p>
|
||||
<div className="city-center-map">
|
||||
<MapContainer center={position || fallbackCenter} zoom={position ? 13 : 8} className="h-full w-full" scrollWheelZoom>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapViewport position={position} />
|
||||
<CenterMapEvents onSelect={onSelect} />
|
||||
{position && <DraggableCenterMarker position={position} onSelect={onSelect} />}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<p className="city-center-coordinates">
|
||||
{position ? <>Выбрано: <strong>{position[0].toFixed(6)}, {position[1].toFixed(6)}</strong></> : 'Точка ещё не выбрана.'}
|
||||
</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CityAvatarEditor({ city }) {
|
||||
const { replaceCity } = useCity();
|
||||
const inputRef = useRef(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function selectImage(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
const validationError = isImageValid(file);
|
||||
if (validationError) return setError(validationError);
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await citiesApi.uploadAvatar(city.id, file);
|
||||
replaceCity(data.city);
|
||||
} catch (requestError) {
|
||||
setError(requestError.response?.data?.error || 'Не удалось загрузить изображение.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="city-avatar-editor">
|
||||
<input ref={inputRef} type="file" className="hidden" accept="image/jpeg,image/png,image/webp" onChange={selectImage} />
|
||||
<button type="button" className="btn-secondary" disabled={uploading} onClick={() => inputRef.current?.click()}>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" aria-hidden="true" /> : <ImagePlus size={16} aria-hidden="true" />}
|
||||
{city.avatar_url ? 'Заменить герб' : 'Загрузить герб'}
|
||||
</button>
|
||||
{error && <p className="city-avatar-editor-error" role="alert">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function CitiesPage() {
|
||||
const { cities, activeCity, addCity } = useCity();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
|
||||
? [Number(form.center_lat), Number(form.center_lon)]
|
||||
: null;
|
||||
const fallbackCenter = activeCity?.center_lat != null && activeCity?.center_lon != null
|
||||
? [Number(activeCity.center_lat), Number(activeCity.center_lon)]
|
||||
: BELARUS_CENTER;
|
||||
|
||||
useEffect(() => () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
}, [avatarPreview]);
|
||||
|
||||
function clearForm() {
|
||||
setForm(emptyForm);
|
||||
setSlugEdited(false);
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview('');
|
||||
}
|
||||
|
||||
function updateForm(field, value) {
|
||||
setForm(current => {
|
||||
@@ -31,15 +148,49 @@ export default function CitiesPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function selectCenter(latitude, longitude) {
|
||||
setForm(current => ({ ...current, center_lat: latitude.toFixed(6), center_lon: longitude.toFixed(6) }));
|
||||
}
|
||||
|
||||
function selectNewAvatar(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
const validationError = isImageValid(file);
|
||||
if (validationError) return setError(validationError);
|
||||
|
||||
setError('');
|
||||
setAvatarFile(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
if (!selectedPosition) {
|
||||
setError('Укажите центр города точкой на карте.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const { data } = await citiesApi.create(form);
|
||||
addCity(data);
|
||||
setForm(emptyForm);
|
||||
setSlugEdited(false);
|
||||
let { data: city } = await citiesApi.create(form);
|
||||
let avatarWarning = '';
|
||||
|
||||
if (avatarFile) {
|
||||
try {
|
||||
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
|
||||
city = upload.data.city;
|
||||
} catch (uploadError) {
|
||||
avatarWarning = uploadError.response?.data?.error || 'Город создан, но изображение не удалось загрузить.';
|
||||
}
|
||||
}
|
||||
|
||||
addCity(city);
|
||||
clearForm();
|
||||
setShowForm(false);
|
||||
if (avatarWarning) setNotice(avatarWarning);
|
||||
} catch (requestError) {
|
||||
setError(requestError.response?.data?.error || 'Не удалось добавить город.');
|
||||
} finally {
|
||||
@@ -50,28 +201,31 @@ export default function CitiesPage() {
|
||||
return <div className="cities-page">
|
||||
<header className="cities-heading">
|
||||
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
|
||||
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => setShowForm(current => !current)}>
|
||||
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => { setShowForm(current => !current); setError(''); setNotice(''); }}>
|
||||
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{notice && <p className="city-form-notice" role="status">{notice}</p>}
|
||||
|
||||
{showForm && <form className="card city-form" onSubmit={submit}>
|
||||
<div><h2>Новый город</h2><p>После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.</p></div>
|
||||
{error && <p className="city-form-error" role="alert">{error}</p>}
|
||||
<div className="city-form-grid">
|
||||
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
|
||||
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
|
||||
<label><span className="label">Иконка или герб</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /></label>
|
||||
<label><span className="label">Широта центра</span><input type="number" step="any" className="input" value={form.center_lat} onChange={event => updateForm('center_lat', event.target.value)} placeholder="55.5318" /></label>
|
||||
<label><span className="label">Долгота центра</span><input type="number" step="any" className="input" value={form.center_lon} onChange={event => updateForm('center_lon', event.target.value)} placeholder="28.5987" /></label>
|
||||
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
|
||||
<label className="city-avatar-field"><span className="label">Картинка для герба</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
|
||||
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { setShowForm(false); setError(''); }}>Отмена</button></div>
|
||||
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { clearForm(); setShowForm(false); setError(''); }}>Отмена</button></div>
|
||||
</form>}
|
||||
|
||||
<section className="cities-grid" aria-label="Доступные города">
|
||||
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
|
||||
<CityEmblem city={city} className="is-large" />
|
||||
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}</p></div>
|
||||
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
|
||||
<CityAvatarEditor city={city} />
|
||||
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
|
||||
</article>)}
|
||||
</section>
|
||||
|
||||
@@ -101,7 +101,14 @@ export const routesApi = {
|
||||
|
||||
export const citiesApi = {
|
||||
getAll: () => api.get('/cities'),
|
||||
create: (data) => api.post('/cities', data)
|
||||
create: (data) => api.post('/cities', data),
|
||||
uploadAvatar: (cityId, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return api.post(`/cities/${cityId}/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Route Stops API (привязка остановок к маршрутам)
|
||||
|
||||
Reference in New Issue
Block a user