Версия 1.2.18 Добавлены кнопки массового сдвига подписей при пред-просмотре данных статей для импорта. Очистка подписи, фотографа. Тултип на подпись.

This commit is contained in:
anibilag 2025-12-10 23:00:40 +03:00
parent 95ae42b4e9
commit ac328c1f23
2 changed files with 183 additions and 99 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "vite-react-typescript-starter", "name": "vite-react-typescript-starter",
"private": true, "private": true,
"version": "1.2.17", "version": "1.2.18",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -1,8 +1,20 @@
// Full file: ImportArticlesPage.tsx (combined)
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Header } from '../components/Header'; import { Header } from '../components/Header';
import { AuthGuard } from '../components/AuthGuard'; import { AuthGuard } from '../components/AuthGuard';
import { Article, CategoryTitles, CityTitles } from '../types'; import { Article, CategoryTitles, CityTitles } from '../types';
import { FileJson, Save, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react'; import {
FileJson,
Save,
ChevronLeft,
ChevronRight,
Edit2,
Trash2,
ArrowLeft,
ArrowRight,
ChevronsLeft,
ChevronsRight
} from 'lucide-react';
const ARTICLES_PER_PAGE = 10; const ARTICLES_PER_PAGE = 10;
const fallbackImg = '/images/lost-image.webp'; const fallbackImg = '/images/lost-image.webp';
@ -10,7 +22,7 @@ const fallbackImg = '/images/lost-image.webp';
export function ImportArticlesPage() { export function ImportArticlesPage() {
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limitIndexMap, setLimitIndexMap] = useState<Record<string, number | null>>({});
const [editingArticle, setEditingArticle] = useState<Article | null>(null); const [editingArticle, setEditingArticle] = useState<Article | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false);
@ -26,41 +38,39 @@ export function ImportArticlesPage() {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}, [currentPage]); }, [currentPage]);
// загрузка JSON файла
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const text = e.target?.result; const text = e.target?.result;
if (!text) throw new Error('Файл пустой или не читается'); if (!text) throw new Error('Файл пустой или не читается');
const jsonData = JSON.parse(text as string); const jsonData = JSON.parse(text as string);
if (Array.isArray(jsonData)) { if (Array.isArray(jsonData)) {
const normalized = jsonData.map((article: Article) => ({ const normalized = jsonData.map((article: Article, idx: number) => ({
...article, ...article,
id: String(article.importId), id: String((article as Article).importId ?? `imp-${idx}`),
images: Array.isArray(article.images) ? [...article.images] : [], images: Array.isArray(article.images) ? [...article.images] : [],
imageSubs: Array.isArray(article.imageSubs) ? [...article.imageSubs] : [], imageSubs: Array.isArray(article.imageSubs) ? [...article.imageSubs] : [],
})); }));
setArticles(normalized); setArticles(normalized);
const newMap: Record<string, number | null> = {};
normalized.forEach(a => { newMap[a.id] = null; });
setLimitIndexMap(newMap);
setCurrentPage(1); setCurrentPage(1);
setError(null); setError(null);
} else { } else {
throw new Error('Неправильный формат JSON. Ожидается массив статей.'); throw new Error('Неправильный формат JSON. Ожидается массив статей.');
} }
} catch { } catch (err) {
console.error(err);
setError('Ошибка разбора JSON файла. Проверьте формат файла.'); setError('Ошибка разбора JSON файла. Проверьте формат файла.');
} }
}; };
reader.readAsText(file); reader.readAsText(file);
}; };
// универсальное редактирование полей статьи
const handleEditField = <K extends keyof Article>( const handleEditField = <K extends keyof Article>(
articleId: string, articleId: string,
field: K, field: K,
@ -78,55 +88,94 @@ export function ImportArticlesPage() {
); );
}; };
// редактирование текста подписи
const handleImageSubEdit = (articleId: string, index: number, newValue: string) => { const handleImageSubEdit = (articleId: string, index: number, newValue: string) => {
setArticles(prev => setArticles(prev =>
prev.map(article => { prev.map(article => {
if (article.id !== articleId) return article; if (article.id !== articleId) return article;
const subs = [...(article.imageSubs ?? [])]; const subs = [...(article.imageSubs ?? [])];
if (index >= subs.length) {
for (let i = subs.length; i <= index; i++) subs[i] = '';
}
subs[index] = newValue; subs[index] = newValue;
return { ...article, imageSubs: subs }; return { ...article, imageSubs: subs };
}) })
); );
}; };
// перенос подписи ВЛЕВО const swapLeft = (articleId: string, index: number) => {
const moveSubLeft = (articleId: string, index: number) => { if (index <= 0) return;
if (index === 0) return;
setArticles(prev => setArticles(prev =>
prev.map(article => { prev.map(article => {
if (article.id !== articleId) return article; if (article.id !== articleId) return article;
const subs = [...(article.imageSubs ?? [])]; const subs = [...(article.imageSubs ?? [])];
if (index >= subs.length) return article;
[subs[index - 1], subs[index]] = [subs[index], subs[index - 1]]; [subs[index - 1], subs[index]] = [subs[index], subs[index - 1]];
return { ...article, imageSubs: subs }; return { ...article, imageSubs: subs };
}) })
); );
}; };
// перенос подписи ВПРАВО const swapRight = (articleId: string, index: number) => {
const moveSubRight = (articleId: string, index: number) => {
setArticles(prev => setArticles(prev =>
prev.map(article => { prev.map(article => {
if (article.id !== articleId) return article; if (article.id !== articleId) return article;
if (index >= (article.imageSubs ?? []).length - 1) return article;
const subs = [...(article.imageSubs ?? [])]; const subs = [...(article.imageSubs ?? [])];
if (index < 0 || index >= subs.length - 1) return article;
[subs[index], subs[index + 1]] = [subs[index + 1], subs[index]]; [subs[index], subs[index + 1]] = [subs[index + 1], subs[index]];
return { ...article, imageSubs: subs }; return { ...article, imageSubs: subs };
}) })
); );
}; };
// отправка на backend const shiftRight = (articleId: string, index: number) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
const subs = [...(article.imageSubs ?? [])];
const total = subs.length;
if (index < 0 || index >= total) return article;
const limit = limitIndexMap[articleId];
const end = (limit !== undefined && limit !== null && limit > index) ? Math.min(limit, total - 1) : total - 1;
if (end <= index) return article;
for (let i = end; i > index; i--) {
subs[i] = subs[i - 1];
}
subs[index] = '';
return { ...article, imageSubs: subs };
})
);
};
const shiftLeft = (articleId: string, index: number) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
const subs = [...(article.imageSubs ?? [])];
const total = subs.length;
if (index < 0 || index >= total) return article;
const limit = limitIndexMap[articleId];
const end = (limit !== undefined && limit !== null && limit > index) ? Math.min(limit, total - 1) : total - 1;
if (end <= index) return article;
for (let i = index; i < end; i++) {
subs[i] = subs[i + 1];
}
subs[end] = '';
return { ...article, imageSubs: subs };
})
);
};
const toggleLimiter = (articleId: string, index: number) => {
setLimitIndexMap(prev => {
const prevVal = prev[articleId] ?? null;
return { ...prev, [articleId]: prevVal === index ? null : index };
});
};
const handleSaveToBackend = useCallback(async () => { const handleSaveToBackend = useCallback(async () => {
if (articles.length === 0) return; if (articles.length === 0) return;
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const response = await fetch('/api/articles/import', { const response = await fetch('/api/articles/import', {
method: 'POST', method: 'POST',
@ -136,13 +185,12 @@ export function ImportArticlesPage() {
}, },
body: JSON.stringify(articles), body: JSON.stringify(articles),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Ошибка сервера: ${response.statusText}`); throw new Error(`Ошибка сервера: ${response.statusText}`);
} }
setShowSuccessModal(true); setShowSuccessModal(true);
} catch { } catch (err) {
console.error(err);
setError('Не удалось сохранить статьи. Попробуйте снова.'); setError('Не удалось сохранить статьи. Попробуйте снова.');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@ -157,7 +205,6 @@ export function ImportArticlesPage() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0"> <div className="px-4 py-6 sm:px-0">
{/* Header panel */}
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Импорт статей</h1> <h1 className="text-2xl font-bold text-gray-900">Импорт статей</h1>
@ -191,24 +238,19 @@ export function ImportArticlesPage() {
</div> </div>
</div> </div>
{/* Errors */}
{error && ( {error && (
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md"> <div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
{error} {error}
</div> </div>
)} )}
{/* List of articles */}
{articles.length > 0 ? ( {articles.length > 0 ? (
<div className="bg-white shadow sm:rounded-md overflow-hidden"> <div className="bg-white shadow sm:rounded-md overflow-hidden">
<ul className="divide-y divide-gray-200"> <ul className="divide-y divide-gray-200">
{currentArticles.map(article => ( {currentArticles.map(article => (
<li key={article.id} className="px-6 py-4"> <li key={article.id} className="px-6 py-4">
<div className="flex-1 min-w-0 space-y-3"> <div className="flex-1 min-w-0 space-y-3">
{/* Title */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<input <input
type="text" type="text"
@ -216,7 +258,6 @@ export function ImportArticlesPage() {
onChange={(e) => handleEditField(article.id, 'title', e.target.value)} onChange={(e) => handleEditField(article.id, 'title', e.target.value)}
className="block w-full text-sm font-medium border-0 focus:ring-0 p-0" className="block w-full text-sm font-medium border-0 focus:ring-0 p-0"
/> />
<button <button
onClick={() => setEditingArticle(article)} onClick={() => setEditingArticle(article)}
className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50" className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
@ -225,7 +266,6 @@ export function ImportArticlesPage() {
</button> </button>
</div> </div>
{/* Excerpt */}
<input <input
type="text" type="text"
value={article.excerpt} value={article.excerpt}
@ -233,12 +273,10 @@ export function ImportArticlesPage() {
className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0" className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0"
/> />
{/* Meta */}
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
</div> </div>
{/* Cover image */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mt-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mt-4">
<div className="border rounded-md p-2 bg-gray-50"> <div className="border rounded-md p-2 bg-gray-50">
<img <img
@ -254,7 +292,6 @@ export function ImportArticlesPage() {
</div> </div>
</div> </div>
{/* Author fields */}
<div className="mt-2"> <div className="mt-2">
<label className="block text-sm font-medium mb-1">Автор:</label> <label className="block text-sm font-medium mb-1">Автор:</label>
<input <input
@ -277,72 +314,121 @@ export function ImportArticlesPage() {
<div className="mt-2"> <div className="mt-2">
<label className="block text-sm font-medium mb-1">Фотограф:</label> <label className="block text-sm font-medium mb-1">Фотограф:</label>
<input <div className="flex items-center gap-2">
type="text" <button
value={article.photographerName} onClick={() =>
onChange={(e) => handleEditField(article.id, 'photographerName', e.target.value)} handleEditField(article.id, 'photographerName', '')
className="w-full border border-gray-300 rounded px-3 py-1 text-sm" }
/> title="Очистить поле «Фотограф»"
className="p-1 bg-white border rounded hover:bg-red-50 hover:text-red-600"
>
<Trash2 size={18} />
</button>
<input
type="text"
value={article.photographerName}
onChange={(e) => handleEditField(article.id, 'photographerName', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
/>
</div>
</div> </div>
{/* Images and subs */}
{(article.images?.length || 0) > 0 && ( {(article.images?.length || 0) > 0 && (
<div className="mt-4"> <div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2"> <h4 className="text-sm font-medium text-gray-700 mb-2">Изображения с подписями:</h4>
Изображения с подписями:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{(article.images ?? []).map((imageUrl, index) => {
const subsLen = (article.imageSubs ?? []).length;
const isFirst = index === 0;
const isLast = index === subsLen - 1;
const limiterIndex = limitIndexMap[article.id] ?? null;
{(article.images ?? []).map((imageUrl, index) => ( return (
<div key={index} className="border rounded-md p-2 bg-gray-50"> <div key={`${article.id}-${index}`} className="border rounded-md p-2 bg-gray-50">
<img
{/* Image */} src={imageUrl}
<img alt={`image-${index + 1}`}
src={imageUrl} className="w-full h-48 object-cover rounded-md mb-2"
alt="" onError={(e) => {
className="w-full h-48 object-cover rounded-md mb-2" const tgt = e.currentTarget;
onError={(e) => { tgt.onerror = null;
const tgt = e.currentTarget; tgt.src = fallbackImg;
tgt.onerror = null; }}
tgt.src = fallbackImg;
}}
/>
{/* Row with buttons and input */}
<div className="flex items-center gap-2">
{/* Move left */}
<button
onClick={() => moveSubLeft(article.id, index)}
disabled={index === 0}
className="px-2 py-1 bg-gray-200 text-gray-700 hover:bg-gray-300 rounded disabled:opacity-40"
title="Переместить подпись влево"
>
</button>
{/* Input field */}
<input
type="text"
value={(article.imageSubs ?? [])[index] || ''}
onChange={(e) => handleImageSubEdit(article.id, index, e.target.value)}
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
/> />
{/* Move right */} <div className="flex items-center mb-2">
<button <input
onClick={() => moveSubRight(article.id, index)} id={`lim-${article.id}-${index}`}
disabled={index === (article.imageSubs ?? []).length - 1} type="checkbox"
className="px-2 py-1 bg-gray-200 text-gray-700 hover:bg-gray-300 rounded disabled:opacity-40" checked={limiterIndex === index}
title="Переместить подпись вправо" onChange={() => toggleLimiter(article.id, index)}
> className="mr-2"
/>
</button> <label htmlFor={`lim-${article.id}-${index}`} className="text-xs text-gray-600">
</div> Ограничитель сдвига (право)
</div> </label>
))} </div>
<div className="flex items-center gap-2">
<button
onClick={() => swapLeft(article.id, index)}
disabled={isFirst}
title="Поменять местами с левым"
className="p-1 bg-white border rounded text-sm disabled:opacity-40 hover:bg-gray-100"
>
<ArrowLeft size={16} />
</button>
<button
onClick={() => shiftLeft(article.id, index)}
disabled={subsLen === 0 || index < 0 || index >= subsLen}
title="Сдвинуть группу влево"
className="p-1 bg-white border rounded text-sm disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsLeft size={16} />
</button>
<input
type="text"
value={(article.imageSubs ?? [])[index] ?? ''}
onChange={(e) => handleImageSubEdit(article.id, index, e.target.value)}
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
placeholder={`Подпись ${index + 1}`}
title={(article.imageSubs ?? [])[index] ?? ''} // <<==== tooltip
/>
<button
onClick={() => shiftRight(article.id, index)}
disabled={subsLen === 0 || index < 0 || index >= subsLen}
title="Сдвинуть группу вправо"
className="p-1 bg-white border rounded text-sm disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsRight size={16} />
</button>
<button
onClick={() => swapRight(article.id, index)}
disabled={isLast}
title="Поменять местами с правым"
className="p-1 bg-white border rounded text-sm disabled:opacity-40 hover:bg-gray-100"
>
<ArrowRight size={16} />
</button>
{/* КНОПКА КОРЗИНЫ — НОВАЯ */}
<button
onClick={() => handleImageSubEdit(article.id, index, '...')}
title="Очистить подпись"
className="p-1 bg-white border rounded hover:bg-red-50 hover:text-red-600"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})}
</div> </div>
</div> </div>
)} )}
@ -352,7 +438,6 @@ export function ImportArticlesPage() {
))} ))}
</ul> </ul>
{/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
@ -404,9 +489,9 @@ export function ImportArticlesPage() {
<p className="text-sm text-gray-500">Начните с выбора JSON файла.</p> <p className="text-sm text-gray-500">Начните с выбора JSON файла.</p>
</div> </div>
)} )}
</div> </div>
{/* Success modal */}
{showSuccessModal && ( {showSuccessModal && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50"> <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
<div className="bg-white p-6 rounded shadow-lg max-w-sm w-full text-center"> <div className="bg-white p-6 rounded shadow-lg max-w-sm w-full text-center">
@ -421,7 +506,6 @@ export function ImportArticlesPage() {
</div> </div>
</div> </div>
)} )}
</main> </main>
</div> </div>
</AuthGuard> </AuthGuard>