Some checks failed
continuous-integration/drone/push Build is failing
✨ Новые возможности: - Добавлена вкладка 'Данные' в панель настроек - Интерактивный модал экспорта с деревом выбора элементов - Модал импорта с превью архива и селективным восстановлением - Автоматическая обработка ZIP архивов и медиафайлов 🎯 Функционал экспорта: - Древовидный выбор: профиль, группы, конкретные ссылки - Чекбоксы для типов данных: стили, медиа - Прогресс-индикаторы и автозагрузка файлов - Подсчет выбранных элементов в реальном времени 📥 Функционал импорта: - Drag&Drop загрузка ZIP архивов - Детальное превью содержимого файла - Селективный выбор данных для восстановления - Защита от перезаписи с опциональным режимом 🔗 Интеграция: - Полная интеграция с существующими API endpoints - Автообновление данных после импорта - Обработка ошибок и пользовательские уведомления - Responsive дизайн для всех устройств
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState } from 'react'
|
||
|
||
interface ImportDataModalProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
onImportComplete?: () => void
|
||
}
|
||
|
||
interface ImportPreview {
|
||
export_info?: {
|
||
username: string
|
||
export_date: string
|
||
}
|
||
user_data?: {
|
||
username: string
|
||
email: string
|
||
full_name: string
|
||
bio?: string
|
||
}
|
||
groups_count: number
|
||
links_count: number
|
||
has_design_settings: boolean
|
||
media_files: {
|
||
avatars: number
|
||
customization: number
|
||
link_groups: number
|
||
links: number
|
||
}
|
||
groups_preview: Array<{
|
||
id: number
|
||
title: string
|
||
description?: string
|
||
}>
|
||
links_preview: Array<{
|
||
id: number
|
||
title: string
|
||
url: string
|
||
group_id: number
|
||
}>
|
||
}
|
||
|
||
interface ImportSelection {
|
||
groups: boolean
|
||
links: boolean
|
||
styles: boolean
|
||
media: boolean
|
||
overwrite_existing: boolean
|
||
}
|
||
|
||
export function ImportDataModal({ isOpen, onClose, onImportComplete }: ImportDataModalProps) {
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||
const [selection, setSelection] = useState<ImportSelection>({
|
||
groups: true,
|
||
links: true,
|
||
styles: true,
|
||
media: true,
|
||
overwrite_existing: false
|
||
})
|
||
const [loading, setLoading] = useState(false)
|
||
const [previewLoading, setPreviewLoading] = useState(false)
|
||
|
||
const handleFileSelect = async (file: File) => {
|
||
setSelectedFile(file)
|
||
setPreview(null)
|
||
|
||
if (!file.name.endsWith('.zip')) {
|
||
alert('Пожалуйста, выберите ZIP архив')
|
||
return
|
||
}
|
||
|
||
setPreviewLoading(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('import_file', file)
|
||
|
||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
|
||
|
||
const response = await fetch(`${API}/api/import/preview/`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
const previewData = await response.json()
|
||
setPreview(previewData)
|
||
} else {
|
||
const errorData = await response.json()
|
||
throw new Error(errorData.error || 'Ошибка при анализе архива')
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при анализе файла:', error)
|
||
alert('Ошибка при анализе архива: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
|
||
setSelectedFile(null)
|
||
} finally {
|
||
setPreviewLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleImport = async () => {
|
||
if (!selectedFile) {
|
||
alert('Пожалуйста, выберите файл для импорта')
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('import_file', selectedFile)
|
||
formData.append('import_groups', selection.groups.toString())
|
||
formData.append('import_links', selection.links.toString())
|
||
formData.append('import_styles', selection.styles.toString())
|
||
formData.append('import_media', selection.media.toString())
|
||
formData.append('overwrite_existing', selection.overwrite_existing.toString())
|
||
|
||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
|
||
|
||
const response = await fetch(`${API}/api/import/`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
const result = await response.json()
|
||
alert(`Импорт завершен успешно!\nИмпортировано групп: ${result.imported_groups_count}\nИмпортировано ссылок: ${result.imported_links_count}\nИмпортировано медиафайлов: ${result.imported_media_count}`)
|
||
|
||
// Очищаем состояние
|
||
setSelectedFile(null)
|
||
setPreview(null)
|
||
|
||
// Вызываем коллбэк для обновления данных
|
||
if (onImportComplete) {
|
||
onImportComplete()
|
||
}
|
||
|
||
onClose()
|
||
} else {
|
||
const errorData = await response.json()
|
||
throw new Error(errorData.error || 'Ошибка при импорте')
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка импорта:', error)
|
||
alert('Ошибка при импорте: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
return (
|
||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||
<div className="modal-dialog modal-lg">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">
|
||
<i className="bi bi-upload me-2"></i>
|
||
Импорт данных профиля
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={onClose}
|
||
disabled={loading}
|
||
></button>
|
||
</div>
|
||
|
||
<div className="modal-body">
|
||
{/* Выбор файла */}
|
||
<div className="mb-4">
|
||
<label className="form-label">Выберите архив для импорта</label>
|
||
<input
|
||
type="file"
|
||
className="form-control"
|
||
accept=".zip"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
handleFileSelect(file)
|
||
}
|
||
}}
|
||
disabled={previewLoading || loading}
|
||
/>
|
||
|
||
{selectedFile && (
|
||
<div className="mt-2 small text-muted">
|
||
<i className="bi bi-file-zip me-1"></i>
|
||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Индикатор загрузки превью */}
|
||
{previewLoading && (
|
||
<div className="text-center py-4">
|
||
<div className="spinner-border text-primary" role="status">
|
||
<span className="visually-hidden">Анализ архива...</span>
|
||
</div>
|
||
<p className="text-muted mt-2">Анализ архива...</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Превью содержимого */}
|
||
{preview && (
|
||
<div className="mb-4">
|
||
<h6 className="mb-3">Содержимое архива</h6>
|
||
|
||
<div className="card">
|
||
<div className="card-body">
|
||
<div className="row">
|
||
<div className="col-md-6">
|
||
<h6 className="card-subtitle mb-2 text-muted">Информация об экспорте</h6>
|
||
{preview.export_info && (
|
||
<ul className="list-unstyled small">
|
||
<li><strong>Источник:</strong> {preview.export_info.username}</li>
|
||
<li><strong>Дата экспорта:</strong> {new Date(preview.export_info.export_date).toLocaleString()}</li>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div className="col-md-6">
|
||
<h6 className="card-subtitle mb-2 text-muted">Статистика данных</h6>
|
||
<ul className="list-unstyled small">
|
||
<li><i className="bi bi-collection me-1"></i> Групп: {preview.groups_count}</li>
|
||
<li><i className="bi bi-link-45deg me-1"></i> Ссылок: {preview.links_count}</li>
|
||
<li><i className="bi bi-palette me-1"></i> Настройки дизайна: {preview.has_design_settings ? 'Есть' : 'Нет'}</li>
|
||
<li>
|
||
<i className="bi bi-image me-1"></i>
|
||
Медиафайлов: {Object.values(preview.media_files).reduce((a, b) => a + b, 0)}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Превью групп */}
|
||
{preview.groups_preview.length > 0 && (
|
||
<div className="mt-3">
|
||
<h6 className="card-subtitle mb-2 text-muted">Группы (первые 5)</h6>
|
||
<div className="list-group list-group-flush small">
|
||
{preview.groups_preview.map((group, index) => (
|
||
<div key={index} className="list-group-item p-2">
|
||
<strong>{group.title}</strong>
|
||
{group.description && (
|
||
<div className="text-muted">{group.description}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Превью ссылок */}
|
||
{preview.links_preview.length > 0 && (
|
||
<div className="mt-3">
|
||
<h6 className="card-subtitle mb-2 text-muted">Ссылки (первые 10)</h6>
|
||
<div className="list-group list-group-flush small">
|
||
{preview.links_preview.map((link, index) => (
|
||
<div key={index} className="list-group-item p-2">
|
||
<strong>{link.title}</strong>
|
||
<div className="text-muted">{link.url}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Настройки импорта */}
|
||
{preview && (
|
||
<div className="mb-4">
|
||
<h6 className="mb-3">Настройки импорта</h6>
|
||
|
||
<div className="form-check mb-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="import-groups"
|
||
checked={selection.groups}
|
||
onChange={(e) => setSelection(prev => ({ ...prev, groups: e.target.checked }))}
|
||
/>
|
||
<label className="form-check-label" htmlFor="import-groups">
|
||
<i className="bi bi-collection me-2"></i>
|
||
Импортировать группы ({preview.groups_count})
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-check mb-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="import-links"
|
||
checked={selection.links}
|
||
onChange={(e) => setSelection(prev => ({ ...prev, links: e.target.checked }))}
|
||
/>
|
||
<label className="form-check-label" htmlFor="import-links">
|
||
<i className="bi bi-link-45deg me-2"></i>
|
||
Импортировать ссылки ({preview.links_count})
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-check mb-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="import-styles"
|
||
checked={selection.styles}
|
||
onChange={(e) => setSelection(prev => ({ ...prev, styles: e.target.checked }))}
|
||
disabled={!preview.has_design_settings}
|
||
/>
|
||
<label className="form-check-label" htmlFor="import-styles">
|
||
<i className="bi bi-palette me-2"></i>
|
||
Импортировать настройки дизайна
|
||
{!preview.has_design_settings && <span className="text-muted"> (недоступно)</span>}
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-check mb-3">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="import-media"
|
||
checked={selection.media}
|
||
onChange={(e) => setSelection(prev => ({ ...prev, media: e.target.checked }))}
|
||
/>
|
||
<label className="form-check-label" htmlFor="import-media">
|
||
<i className="bi bi-image me-2"></i>
|
||
Импортировать медиафайлы ({Object.values(preview.media_files).reduce((a, b) => a + b, 0)})
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-check">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="overwrite-existing"
|
||
checked={selection.overwrite_existing}
|
||
onChange={(e) => setSelection(prev => ({ ...prev, overwrite_existing: e.target.checked }))}
|
||
/>
|
||
<label className="form-check-label" htmlFor="overwrite-existing">
|
||
<i className="bi bi-exclamation-triangle me-2 text-warning"></i>
|
||
Перезаписать существующие данные
|
||
</label>
|
||
<div className="form-text">
|
||
Если отключено, существующие группы и ссылки с такими же названиями будут пропущены
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={onClose}
|
||
disabled={loading || previewLoading}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-success"
|
||
onClick={handleImport}
|
||
disabled={loading || previewLoading || !preview}
|
||
>
|
||
{loading ? (
|
||
<>
|
||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||
Импорт...
|
||
</>
|
||
) : (
|
||
<>
|
||
<i className="bi bi-upload me-2"></i>
|
||
Импортировать
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |