diff --git a/backend/migrations/011_city_avatars.sql b/backend/migrations/011_city_avatars.sql new file mode 100644 index 0000000..df1424a --- /dev/null +++ b/backend/migrations/011_city_avatars.sql @@ -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; diff --git a/backend/src/controllers/citiesController.js b/backend/src/controllers/citiesController.js index e62d696..f486c5a 100644 --- a/backend/src/controllers/citiesController.js +++ b/backend/src/controllers/citiesController.js @@ -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(); diff --git a/backend/src/index.js b/backend/src/index.js index 3cd8be3..24a7765 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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.' }); } diff --git a/backend/src/middleware/cityUpload.js b/backend/src/middleware/cityUpload.js new file mode 100644 index 0000000..4dbae44 --- /dev/null +++ b/backend/src/middleware/cityUpload.js @@ -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 +}); diff --git a/backend/src/routes/cities.js b/backend/src/routes/cities.js index a2a7170..0b37520 100644 --- a/backend/src/routes/cities.js +++ b/backend/src/routes/cities.js @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 8054d8c..51bf8c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/components/CitySelector.jsx b/frontend/src/components/CitySelector.jsx index 633e8f5..ca3aa13 100644 --- a/frontend/src/components/CitySelector.jsx +++ b/frontend/src/components/CitySelector.jsx @@ -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 ; + const avatarUrl = city?.avatar_url + ? (city.avatar_url.startsWith('http') ? city.avatar_url : `${API_ORIGIN}${city.avatar_url}`) + : null; + + return ; } export default function CitySelector() { diff --git a/frontend/src/contexts/CityContext.jsx b/frontend/src/contexts/CityContext.jsx index 1cacdc0..5947eca 100644 --- a/frontend/src/contexts/CityContext.jsx +++ b/frontend/src/contexts/CityContext.jsx @@ -41,7 +41,12 @@ export function CityProvider({ children }) { selectCity(city); } - return + function replaceCity(city) { + setCities(current => current.map(item => item.id === city.id ? city : item)); + setActiveCity(current => current?.id === city.id ? city : current); + } + + return {children} ; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 7848fd5..66a16c3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/pages/CitiesPage.jsx b/frontend/src/pages/CitiesPage.jsx index e7a4b47..58cdcd5 100644 --- a/frontend/src/pages/CitiesPage.jsx +++ b/frontend/src/pages/CitiesPage.jsx @@ -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 { + const point = markerRef.current?.getLatLng(); + if (point) onSelect(point.lat, point.lng); + } + }} />; +} + +function CityCenterPicker({ position, fallbackCenter, onSelect }) { + return
+ Центр города на карте +

Нажмите на карту, затем при необходимости перетащите маркер. Вручную координаты вводить не нужно.

+
+ + + + + {position && } + +
+

+ {position ? <>Выбрано: {position[0].toFixed(6)}, {position[1].toFixed(6)} : 'Точка ещё не выбрана.'} +

+
; +} + +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
+ + + {error &&

{error}

} +
; +} 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

Города

У каждого города — свои маршруты, остановки, расписания и уведомления.

-
+ {notice &&

{notice}

} + {showForm &&

Новый город

После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.

{error &&

{error}

}
- - - + + +
-
+
}
{cities.map(city =>
-

{city.name}

{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты можно указать при создании'}

+

{city.name}

{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}

+ {city.id === activeCity?.id && }
)}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a8981b1..bf7fbef 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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 (привязка остановок к маршрутам)