Версия 1.2.17 Добавлены кнопри обмена подписями под изображением при пред-просмотре данных статей для импорта.

This commit is contained in:
anibilag 2025-12-10 19:42:34 +03:00
parent d1f87e539f
commit 95ae42b4e9
2 changed files with 261 additions and 194 deletions

View File

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

View File

@ -11,10 +11,6 @@ export function ImportArticlesPage() {
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [currentPage]);
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,6 +22,11 @@ export function ImportArticlesPage() {
const endIndex = startIndex + ARTICLES_PER_PAGE; const endIndex = startIndex + ARTICLES_PER_PAGE;
const currentArticles = articles.slice(startIndex, endIndex); const currentArticles = articles.slice(startIndex, endIndex);
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [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;
@ -37,10 +38,11 @@ export function ImportArticlesPage() {
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) => ({
...article, ...article,
id: article.importId.toString(), // 👈 теперь будет использоваться корректный id id: String(article.importId),
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] : [],
})); }));
@ -58,23 +60,68 @@ export function ImportArticlesPage() {
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,
value: Article[K] value: Article[K]
) => { ) => {
setArticles(prev =>
prev.map(article =>
article.id === articleId
? {
...article,
[field]: Array.isArray(value) ? [...value] : value,
}
: article
)
);
};
// редактирование текста подписи
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;
return { const subs = [...(article.imageSubs ?? [])];
...article, subs[index] = newValue;
[field]: Array.isArray(value) ? [...value] : value, // гарантированно новая ссылка
}; return { ...article, imageSubs: subs };
}) })
); );
}; };
// перенос подписи ВЛЕВО
const moveSubLeft = (articleId: string, index: number) => {
if (index === 0) return;
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
const subs = [...(article.imageSubs ?? [])];
[subs[index - 1], subs[index]] = [subs[index], subs[index - 1]];
return { ...article, imageSubs: subs };
})
);
};
// перенос подписи ВПРАВО
const moveSubRight = (articleId: string, index: number) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
if (index >= (article.imageSubs ?? []).length - 1) return article;
const subs = [...(article.imageSubs ?? [])];
[subs[index], subs[index + 1]] = [subs[index + 1], subs[index]];
return { ...article, imageSubs: subs };
})
);
};
// отправка на backend
const handleSaveToBackend = useCallback(async () => { const handleSaveToBackend = useCallback(async () => {
if (articles.length === 0) return; if (articles.length === 0) return;
setIsSaving(true); setIsSaving(true);
@ -94,7 +141,6 @@ export function ImportArticlesPage() {
throw new Error(`Ошибка сервера: ${response.statusText}`); throw new Error(`Ошибка сервера: ${response.statusText}`);
} }
setError(null);
setShowSuccessModal(true); setShowSuccessModal(true);
} catch { } catch {
setError('Не удалось сохранить статьи. Попробуйте снова.'); setError('Не удалось сохранить статьи. Попробуйте снова.');
@ -103,38 +149,27 @@ export function ImportArticlesPage() {
} }
}, [articles]); }, [articles]);
const handleImageSubEdit = (articleId: string, index: number, newValue: string) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
const updatedSubs = article.imageSubs ? [...article.imageSubs] : [];
updatedSubs[index] = newValue;
return {
...article,
imageSubs: updatedSubs, // новая ссылка!
};
})
);
};
return ( return (
<AuthGuard> <AuthGuard>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header /> <Header />
<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>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" className="inline-flex items-center px-4 py-2 border border-gray-300 rounded bg-white hover:bg-gray-50 text-sm"
> >
<FileJson className="h-5 w-5 mr-2" /> <FileJson className="h-5 w-5 mr-2" />
Выбор JSON файла Выбор JSON файла
</button> </button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -142,11 +177,12 @@ export function ImportArticlesPage() {
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />
{articles.length > 0 && ( {articles.length > 0 && (
<button <button
onClick={handleSaveToBackend} onClick={handleSaveToBackend}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400"
disabled={isSaving} disabled={isSaving}
className="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm disabled:bg-gray-400"
> >
<Save className="h-5 w-5 mr-2" /> <Save className="h-5 w-5 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить на сервер'} {isSaving ? 'Сохранение...' : 'Сохранить на сервер'}
@ -155,196 +191,226 @@ 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 overflow-hidden sm:rounded-md"> <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">
{/* Title */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-0 space-y-3"> <input
<div className="flex items-center justify-between"> type="text"
<input value={article.title}
type="text" onChange={(e) => handleEditField(article.id, 'title', e.target.value)}
value={article.title} className="block w-full text-sm font-medium border-0 focus:ring-0 p-0"
onChange={(e) => handleEditField(article.id, 'title', e.target.value)} />
className="block w-full text-sm font-medium text-gray-900 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" >
> <Edit2 size={16} />
<Edit2 size={16} /> </button>
</button> </div>
</div>
<input {/* Excerpt */}
type="text" <input
value={article.excerpt} type="text"
onChange={(e) => handleEditField(article.id, 'excerpt', e.target.value)} value={article.excerpt}
className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0" onChange={(e) => handleEditField(article.id, 'excerpt', e.target.value)}
className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0"
/>
{/* Meta */}
<div className="text-sm text-gray-500">
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
</div>
{/* Cover image */}
<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">
<img
src={article.coverImage}
alt=""
className="w-full h-48 object-cover rounded-md mb-2"
onError={(e) => {
const tgt = e.currentTarget;
tgt.onerror = null;
tgt.src = fallbackImg;
}}
/> />
<div className="text-sm text-gray-500">
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
</div>
<div className="mt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div className="border rounded-md p-2 bg-gray-50">
<img
src={article.coverImage}
alt={""}
className="w-full h-48 object-cover rounded-md mb-2"
onError={(e) => {
const target = e.currentTarget;
target.onerror = null;
target.src = fallbackImg;
}}
/>
</div>
</div>
</div>
<div className="mt-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Автор:</label>
<input
type="text"
value={article.authorName}
onChange={(e) =>
handleEditField(article.id, 'authorName', e.target.value)
}
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
placeholder="Введите имя автора"
/>
</div>
<div className="mt-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Соавтор:</label>
<input
type="text"
value={article.coAuthorName}
onChange={(e) =>
handleEditField(article.id, 'coAuthorName', e.target.value)
}
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
placeholder="Введите имя соавтора"
/>
</div>
<div className="mt-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Фотограф:</label>
<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"
placeholder="Введите имя фотографа"
/>
</div>
{(article.images?.length || 0) > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Изображения с подписями:</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{article.images?.map((imageUrl, index) => (
<div key={index} className="border rounded-md p-2 bg-gray-50">
<img
src={imageUrl}
alt={`image-${index + 1}`}
className="w-full h-48 object-cover rounded-md mb-2"
onError={(e) => {
const target = e.currentTarget;
target.onerror = null;
target.src = fallbackImg;
}}
/>
<input
type="text"
value={article.imageSubs?.[index] || ''}
onChange={(e) => handleImageSubEdit(article.id, index, e.target.value)}
placeholder={`Подпись ${index + 1}`}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
/>
</div>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</li>
))}
</ul>
{/* Pagination */} {/* Author fields */}
{totalPages > 1 && ( <div className="mt-2">
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <label className="block text-sm font-medium mb-1">Автор:</label>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <input
<div> type="text"
<p className="text-sm text-gray-700"> value={article.authorName}
Showing <span className="font-medium">{startIndex + 1}</span> to{' '} onChange={(e) => handleEditField(article.id, 'authorName', e.target.value)}
<span className="font-medium">{Math.min(endIndex, articles.length)}</span> of{' '} className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
<span className="font-medium">{articles.length}</span> articles />
</p>
</div> </div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> <div className="mt-2">
<button <label className="block text-sm font-medium mb-1">Соавтор:</label>
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))} <input
disabled={currentPage === 1} type="text"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" value={article.coAuthorName}
> onChange={(e) => handleEditField(article.id, 'coAuthorName', e.target.value)}
<span className="sr-only">Previous</span> className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
<ChevronLeft className="h-5 w-5" /> />
</button>
{Array.from({ length: totalPages }).map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentPage(idx + 1)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === idx + 1
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{idx + 1}
</button>
))}
<button
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div> </div>
<div className="mt-2">
<label className="block text-sm font-medium mb-1">Фотограф:</label>
<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>
{/* Images and subs */}
{(article.images?.length || 0) > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Изображения с подписями:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{(article.images ?? []).map((imageUrl, index) => (
<div key={index} className="border rounded-md p-2 bg-gray-50">
{/* Image */}
<img
src={imageUrl}
alt=""
className="w-full h-48 object-cover rounded-md mb-2"
onError={(e) => {
const tgt = e.currentTarget;
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 */}
<button
onClick={() => moveSubRight(article.id, index)}
disabled={index === (article.imageSubs ?? []).length - 1}
className="px-2 py-1 bg-gray-200 text-gray-700 hover:bg-gray-300 rounded disabled:opacity-40"
title="Переместить подпись вправо"
>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
</li>
))}
</ul>
{/* Pagination */}
{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="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{startIndex + 1}</span> to{' '}
<span className="font-medium">{Math.min(endIndex, articles.length)}</span> of{' '}
<span className="font-medium">{articles.length}</span> articles
</p>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2 py-2 rounded-l-md border bg-white text-gray-500 hover:bg-gray-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
{Array.from({ length: totalPages }).map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentPage(idx + 1)}
className={`px-4 py-2 border text-sm ${
currentPage === idx + 1
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{idx + 1}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2 py-2 rounded-r-md border bg-white text-gray-500 hover:bg-gray-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div> </div>
)} </div>
</div> )}
</> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<FileJson className="mx-auto h-12 w-12 text-gray-400" /> <FileJson className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Еще нет статей для импорта</h3> <h3 className="mt-2 text-sm font-medium">Еще нет статей для импорта</h3>
<p className="mt-1 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">
<h2 className="text-lg font-semibold mb-2"> Успешно!</h2> <h2 className="text-lg font-semibold mb-2">Успешно!</h2>
<p className="text-gray-700 mb-4">Статьи успешно сохранены на сервере.</p> <p className="text-gray-700 mb-4">Статьи успешно сохранены на сервере.</p>
<button <button
onClick={() => setShowSuccessModal(false)} onClick={() => setShowSuccessModal(false)}
@ -355,6 +421,7 @@ export function ImportArticlesPage() {
</div> </div>
</div> </div>
)} )}
</main> </main>
</div> </div>
</AuthGuard> </AuthGuard>