diff --git a/frontend/linktree-frontend/src/app/(protected)/dashboard/DashboardClient.tsx b/frontend/linktree-frontend/src/app/(protected)/dashboard/DashboardClient.tsx index fe9cffa..996b1ab 100644 --- a/frontend/linktree-frontend/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/frontend/linktree-frontend/src/app/(protected)/dashboard/DashboardClient.tsx @@ -1662,6 +1662,12 @@ export default function DashboardClient() { setDesignSettings(newSettings) setShowCustomizationPanel(false) }} + user={user} + groups={groups} + onDataUpdate={() => { + // Перезагрузить данные после импорта + reloadData() + }} /> )} diff --git a/frontend/linktree-frontend/src/app/components/CustomizationPanel.tsx b/frontend/linktree-frontend/src/app/components/CustomizationPanel.tsx index 2a6cad9..4c882aa 100644 --- a/frontend/linktree-frontend/src/app/components/CustomizationPanel.tsx +++ b/frontend/linktree-frontend/src/app/components/CustomizationPanel.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react' import { TemplatesSelector } from './TemplatesSelector' +import { ExportDataModal } from './ExportDataModal' +import { ImportDataModal } from './ImportDataModal' import { designTemplates, DesignTemplate } from '../constants/designTemplates' interface DesignSettings { @@ -37,13 +39,44 @@ interface DesignSettings { link_overlay_opacity?: number } +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar_url?: string +} + +interface LinkItem { + id: number + title: string + url: string + icon_url?: string + group: number +} + +interface Group { + id: number + name: string + description?: string + icon_url?: string + background_image_url?: string + is_public?: boolean + is_favorite?: boolean + links: LinkItem[] +} + interface CustomizationPanelProps { isOpen: boolean onClose: () => void onSettingsUpdate: (settings: DesignSettings) => void + user?: UserProfile | null + groups?: Group[] + onDataUpdate?: () => void } -export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: CustomizationPanelProps) { +export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate, user, groups = [], onDataUpdate }: CustomizationPanelProps) { const [settings, setSettings] = useState({ theme_color: '#ffffff', dashboard_layout: 'list', @@ -58,8 +91,12 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom header_text_color: '#000000' }) const [loading, setLoading] = useState(false) - const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced'>('templates') + const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced' | 'data'>('templates') const [backgroundImageFile, setBackgroundImageFile] = useState(null) + + // Состояния для модалов экспорта/импорта + const [showExportModal, setShowExportModal] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) useEffect(() => { if (isOpen) { @@ -298,6 +335,15 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom Дополнительно +
  • + +
  • {/* Содержимое вкладок */} @@ -1018,6 +1064,108 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom )} + + {/* Вкладка: Данные */} + {activeTab === 'data' && ( +
    +
    +
    +
    + + Экспорт и импорт данных профиля +
    +

    + Создавайте резервные копии данных профиля или восстанавливайте их из архива +

    +
    + + {/* Экспорт данных */} +
    +
    +
    +
    + + Экспорт данных +
    +
    +
    +

    + Создать архив с данными профиля для резервного копирования или переноса +

    + + +
    +
    +
    + + {/* Импорт данных */} +
    +
    +
    +
    + + Импорт данных +
    +
    +
    +

    + Загрузить и восстановить данные из архива экспорта +

    + +
    + + { + // TODO: Обработать загрузку файла и показать превью + const file = e.target.files?.[0] + if (file) { + console.log('Файл выбран:', file.name) + } + }} + /> +
    + + +
    +
    +
    + + {/* История операций */} +
    +
    +
    +
    + + История операций +
    +
    +
    +

    + Здесь будет отображаться история экспортов и импортов +

    +
    +
    +
    +
    +
    + )} @@ -1093,6 +1241,24 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom + + {/* Модалы экспорта и импорта */} + setShowExportModal(false)} + user={user || null} + groups={groups} + /> + + setShowImportModal(false)} + onImportComplete={() => { + if (onDataUpdate) { + onDataUpdate() + } + }} + /> ) } \ No newline at end of file diff --git a/frontend/linktree-frontend/src/app/components/ExportDataModal.tsx b/frontend/linktree-frontend/src/app/components/ExportDataModal.tsx new file mode 100644 index 0000000..0e5dc04 --- /dev/null +++ b/frontend/linktree-frontend/src/app/components/ExportDataModal.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useState, useEffect } from 'react' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar_url?: string +} + +interface LinkItem { + id: number + title: string + url: string + icon_url?: string + group: number +} + +interface Group { + id: number + name: string + description?: string + icon_url?: string + background_image_url?: string + is_public?: boolean + is_favorite?: boolean + links: LinkItem[] +} + +interface ExportDataModalProps { + isOpen: boolean + onClose: () => void + user: UserProfile | null + groups: Group[] +} + +interface ExportSelection { + profile: boolean + groups: { [key: number]: boolean } + links: { [key: number]: boolean } + styles: boolean + media: boolean +} + +export function ExportDataModal({ isOpen, onClose, user, groups }: ExportDataModalProps) { + const [selection, setSelection] = useState({ + profile: true, + groups: {}, + links: {}, + styles: true, + media: true + }) + const [expandedGroups, setExpandedGroups] = useState<{ [key: number]: boolean }>({}) + const [loading, setLoading] = useState(false) + + // Инициализация выбора при открытии модала + useEffect(() => { + if (isOpen) { + const newSelection: ExportSelection = { + profile: true, + groups: {}, + links: {}, + styles: true, + media: true + } + + // По умолчанию выбираем все группы + groups.forEach(group => { + newSelection.groups[group.id] = true + + // По умолчанию выбираем все ссылки в группе + group.links.forEach(link => { + newSelection.links[link.id] = true + }) + }) + + setSelection(newSelection) + } + }, [isOpen, groups]) + + const handleGroupToggle = (groupId: number) => { + const group = groups.find(g => g.id === groupId) + if (!group) return + + const newGroupState = !selection.groups[groupId] + + setSelection(prev => { + const newSelection = { ...prev } + newSelection.groups[groupId] = newGroupState + + // Переключаем все ссылки в группе + group.links.forEach(link => { + newSelection.links[link.id] = newGroupState + }) + + return newSelection + }) + } + + const handleLinkToggle = (linkId: number) => { + setSelection(prev => ({ + ...prev, + links: { + ...prev.links, + [linkId]: !prev.links[linkId] + } + })) + } + + const toggleGroupExpansion = (groupId: number) => { + setExpandedGroups(prev => ({ + ...prev, + [groupId]: !prev[groupId] + })) + } + + const handleExport = async () => { + setLoading(true) + + try { + const selectedGroupIds = Object.keys(selection.groups) + .filter(id => selection.groups[parseInt(id)]) + .map(id => parseInt(id)) + + const selectedLinkIds = Object.keys(selection.links) + .filter(id => selection.links[parseInt(id)]) + .map(id => parseInt(id)) + + const exportData = { + include_profile: selection.profile, + include_groups: selectedGroupIds.length > 0, + include_links: selectedLinkIds.length > 0, + include_styles: selection.styles, + include_media: selection.media, + selected_groups: selectedGroupIds, + selected_links: selectedLinkIds + } + + 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/export/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(exportData) + }) + + if (response.ok) { + const result = await response.json() + + if (result.download_url) { + // Скачиваем файл + const downloadResponse = await fetch(`${API}${result.download_url}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (downloadResponse.ok) { + const blob = await downloadResponse.blob() + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `profile_export_${user?.username || 'user'}_${new Date().toISOString().split('T')[0]}.zip` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + alert('Экспорт создан и загружен успешно!') + onClose() + } else { + throw new Error('Ошибка при скачивании файла') + } + } else { + throw new Error('Файл экспорта не создан') + } + } 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 getSelectedCount = () => { + const groupsCount = Object.values(selection.groups).filter(Boolean).length + const linksCount = Object.values(selection.links).filter(Boolean).length + return { groups: groupsCount, links: linksCount } + } + + if (!isOpen) return null + + const { groups: selectedGroupsCount, links: selectedLinksCount } = getSelectedCount() + + return ( +
    +
    +
    +
    +
    + + Экспорт данных профиля +
    + +
    + +
    +

    + Выберите данные для включения в архив экспорта +

    + + {/* Общие настройки */} +
    +
    Общие данные
    +
    + setSelection(prev => ({ ...prev, profile: e.target.checked }))} + /> + +
    +
    + setSelection(prev => ({ ...prev, styles: e.target.checked }))} + /> + +
    +
    + setSelection(prev => ({ ...prev, media: e.target.checked }))} + /> + +
    +
    + + {/* Выбор групп и ссылок */} +
    +
    + Группы и ссылки + + {selectedGroupsCount} групп, {selectedLinksCount} ссылок + +
    + +
    + {groups.map(group => ( +
    +
    +
    + handleGroupToggle(group.id)} + /> + +
    + + {group.links.length > 0 && ( + + )} +
    + + {/* Список ссылок в группе */} + {expandedGroups[group.id] && group.links.length > 0 && ( +
    + {group.links.map(link => ( +
    + handleLinkToggle(link.id)} + /> + +
    + ))} +
    + )} +
    + ))} + + {groups.length === 0 && ( +

    + Нет групп для экспорта +

    + )} +
    +
    +
    + +
    + + +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/frontend/linktree-frontend/src/app/components/ImportDataModal.tsx b/frontend/linktree-frontend/src/app/components/ImportDataModal.tsx new file mode 100644 index 0000000..c19dff6 --- /dev/null +++ b/frontend/linktree-frontend/src/app/components/ImportDataModal.tsx @@ -0,0 +1,403 @@ +'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(null) + const [preview, setPreview] = useState(null) + const [selection, setSelection] = useState({ + 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 ( +
    +
    +
    +
    +
    + + Импорт данных профиля +
    + +
    + +
    + {/* Выбор файла */} +
    + + { + const file = e.target.files?.[0] + if (file) { + handleFileSelect(file) + } + }} + disabled={previewLoading || loading} + /> + + {selectedFile && ( +
    + + {selectedFile.name} ({formatFileSize(selectedFile.size)}) +
    + )} +
    + + {/* Индикатор загрузки превью */} + {previewLoading && ( +
    +
    + Анализ архива... +
    +

    Анализ архива...

    +
    + )} + + {/* Превью содержимого */} + {preview && ( +
    +
    Содержимое архива
    + +
    +
    +
    +
    +
    Информация об экспорте
    + {preview.export_info && ( +
      +
    • Источник: {preview.export_info.username}
    • +
    • Дата экспорта: {new Date(preview.export_info.export_date).toLocaleString()}
    • +
    + )} +
    +
    +
    Статистика данных
    +
      +
    • Групп: {preview.groups_count}
    • +
    • Ссылок: {preview.links_count}
    • +
    • Настройки дизайна: {preview.has_design_settings ? 'Есть' : 'Нет'}
    • +
    • + + Медиафайлов: {Object.values(preview.media_files).reduce((a, b) => a + b, 0)} +
    • +
    +
    +
    + + {/* Превью групп */} + {preview.groups_preview.length > 0 && ( +
    +
    Группы (первые 5)
    +
    + {preview.groups_preview.map((group, index) => ( +
    + {group.title} + {group.description && ( +
    {group.description}
    + )} +
    + ))} +
    +
    + )} + + {/* Превью ссылок */} + {preview.links_preview.length > 0 && ( +
    +
    Ссылки (первые 10)
    +
    + {preview.links_preview.map((link, index) => ( +
    + {link.title} +
    {link.url}
    +
    + ))} +
    +
    + )} +
    +
    +
    + )} + + {/* Настройки импорта */} + {preview && ( +
    +
    Настройки импорта
    + +
    + setSelection(prev => ({ ...prev, groups: e.target.checked }))} + /> + +
    + +
    + setSelection(prev => ({ ...prev, links: e.target.checked }))} + /> + +
    + +
    + setSelection(prev => ({ ...prev, styles: e.target.checked }))} + disabled={!preview.has_design_settings} + /> + +
    + +
    + setSelection(prev => ({ ...prev, media: e.target.checked }))} + /> + +
    + +
    + setSelection(prev => ({ ...prev, overwrite_existing: e.target.checked }))} + /> + +
    + Если отключено, существующие группы и ссылки с такими же названиями будут пропущены +
    +
    +
    + )} +
    + +
    + + +
    +
    +
    +
    + ) +} \ No newline at end of file