Add complete CKE Project implementation with news management system
This commit is contained in:
203
app/admin/components/ImageUpload.tsx
Normal file
203
app/admin/components/ImageUpload.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image as ImageIcon, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
onRemove: () => void;
|
||||
maxSize?: number; // in MB
|
||||
acceptedTypes?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
maxSize = 5,
|
||||
acceptedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
className = ''
|
||||
}: ImageUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
// Проверка типа файла
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
return `Неподдерживаемый тип файла. Разрешены: ${acceptedTypes.join(', ')}`;
|
||||
}
|
||||
|
||||
// Проверка размера файла
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
return `Файл слишком большой. Максимальный размер: ${maxSize} MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// В реальном приложении здесь будет загрузка на сервер
|
||||
// Для демонстрации используем FileReader для создания data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
onChange(result);
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onRemove();
|
||||
setError('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{value ? (
|
||||
// Предварительный просмотр изображения
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={value}
|
||||
alt="Preview"
|
||||
className="w-full h-64 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
|
||||
{/* Overlay с действиями */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFileDialog}
|
||||
className="p-2 bg-white text-gray-800 rounded-full hover:bg-gray-100 transition-colors"
|
||||
title="Заменить изображение"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Удалить изображение"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Область загрузки
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={!isUploading ? openFileDialog : undefined}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="text-sm text-gray-600">Загрузка...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<ImageIcon className="h-12 w-12 text-gray-400" />
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium text-blue-600">Нажмите для выбора</span> или перетащите файл сюда
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
PNG, JPG, GIF, WEBP до {maxSize} MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Скрытый input для выбора файла */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedTypes.join(',')}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Сообщение об ошибке */}
|
||||
{error && (
|
||||
<div className="flex items-center space-x-2 text-red-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о файле */}
|
||||
{value && !error && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<p>Изображение загружено успешно</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
218
app/admin/components/TextEditor.tsx
Normal file
218
app/admin/components/TextEditor.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Bold, Italic, List, Link, Eye, EyeOff, Type, Quote } from 'lucide-react';
|
||||
|
||||
interface TextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Введите текст...',
|
||||
rows = 12,
|
||||
className = ''
|
||||
}: TextEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const insertText = (before: string, after: string = '') => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = value.substring(start, end);
|
||||
|
||||
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
|
||||
onChange(newText);
|
||||
|
||||
// Восстанавливаем фокус и позицию курсора
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selectedText.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const insertAtCursor = (text: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
const newText = value.substring(0, start) + text + value.substring(end);
|
||||
onChange(newText);
|
||||
|
||||
// Восстанавливаем фокус и позицию курсора
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatContent = (content: string) => {
|
||||
return content
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold text-gray-900 mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold text-gray-900 mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold text-gray-900 mt-8 mb-4">$1</h1>')
|
||||
.replace(/^> (.*$)/gm, '<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">$1</blockquote>')
|
||||
.replace(/^- (.*$)/gm, '<li class="mb-1">$1</li>')
|
||||
.replace(/(<li.*<\/li>)/g, '<ul class="list-disc list-inside mb-4 text-gray-700">$1</ul>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-600 hover:text-blue-800 underline" target="_blank">$1</a>');
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
{
|
||||
icon: Bold,
|
||||
label: 'Жирный',
|
||||
action: () => insertText('**', '**'),
|
||||
shortcut: 'Ctrl+B'
|
||||
},
|
||||
{
|
||||
icon: Italic,
|
||||
label: 'Курсив',
|
||||
action: () => insertText('*', '*'),
|
||||
shortcut: 'Ctrl+I'
|
||||
},
|
||||
{
|
||||
icon: Type,
|
||||
label: 'Заголовок',
|
||||
action: () => insertAtCursor('## '),
|
||||
shortcut: 'Ctrl+H'
|
||||
},
|
||||
{
|
||||
icon: Quote,
|
||||
label: 'Цитата',
|
||||
action: () => insertAtCursor('> '),
|
||||
shortcut: 'Ctrl+Q'
|
||||
},
|
||||
{
|
||||
icon: List,
|
||||
label: 'Список',
|
||||
action: () => insertAtCursor('- '),
|
||||
shortcut: 'Ctrl+L'
|
||||
},
|
||||
{
|
||||
icon: Link,
|
||||
label: 'Ссылка',
|
||||
action: () => insertText('[', '](url)'),
|
||||
shortcut: 'Ctrl+K'
|
||||
}
|
||||
];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
insertText('**', '**');
|
||||
break;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
insertText('*', '*');
|
||||
break;
|
||||
case 'h':
|
||||
e.preventDefault();
|
||||
insertAtCursor('## ');
|
||||
break;
|
||||
case 'q':
|
||||
e.preventDefault();
|
||||
insertAtCursor('> ');
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
insertAtCursor('- ');
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
insertText('[', '](url)');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border border-gray-300 rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-1">
|
||||
{toolbarButtons.map((button, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={button.action}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
title={`${button.label} (${button.shortcut})`}
|
||||
>
|
||||
<button.icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center space-x-2 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span>{showPreview ? 'Редактор' : 'Превью'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative">
|
||||
{showPreview ? (
|
||||
/* Preview Mode */
|
||||
<div className="p-4 min-h-[300px] bg-white">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: formatContent(value || 'Содержимое будет отображено здесь...') }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Editor Mode */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full p-4 border-none resize-none focus:outline-none focus:ring-0"
|
||||
style={{ minHeight: '300px' }}
|
||||
/>
|
||||
|
||||
{/* Character count */}
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
{value.length} символов
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="bg-gray-50 border-t border-gray-200 px-3 py-2 text-xs text-gray-500">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span><strong>**жирный**</strong></span>
|
||||
<span><em>*курсив*</em></span>
|
||||
<span>## заголовок</span>
|
||||
<span>> цитата</span>
|
||||
<span>- список</span>
|
||||
<span>[ссылка](url)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user