feat: add city avatar upload functionality and enhance city management features

This commit is contained in:
2026-09-17 19:54:09 +03:00
parent d13cc92d82
commit acec6b4fc0
11 changed files with 292 additions and 26 deletions

View 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;

View File

@@ -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();

View File

@@ -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.'
});
}

View 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
});

View File

@@ -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;

View File

@@ -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:

View File

@@ -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() {

View File

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

View File

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

View File

@@ -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='&copy; <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>

View File

@@ -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 (привязка остановок к маршрутам)