287 lines
9.4 KiB
React
287 lines
9.4 KiB
React
|
|
import React, { useState, useEffect } from 'react'
|
|||
|
|
import { useAuth } from './auth/AuthContext'
|
|||
|
|
import BoardMembers from './BoardMembers'
|
|||
|
|
import Toast from './Toast'
|
|||
|
|
import SubmitButton from './SubmitButton'
|
|||
|
|
import DeleteButton from './DeleteButton'
|
|||
|
|
import './Buttons.css'
|
|||
|
|
import './BoardForm.css'
|
|||
|
|
|
|||
|
|
function BoardForm({ boardId, onNavigate, onSaved }) {
|
|||
|
|
const { authFetch } = useAuth()
|
|||
|
|
const [name, setName] = useState('')
|
|||
|
|
const [inviteEnabled, setInviteEnabled] = useState(false)
|
|||
|
|
const [inviteURL, setInviteURL] = useState('')
|
|||
|
|
const [loading, setLoading] = useState(false)
|
|||
|
|
const [loadingBoard, setLoadingBoard] = useState(false)
|
|||
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|||
|
|
const [copied, setCopied] = useState(false)
|
|||
|
|
const [toastMessage, setToastMessage] = useState(null)
|
|||
|
|
|
|||
|
|
const isEdit = !!boardId
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (boardId) {
|
|||
|
|
fetchBoard()
|
|||
|
|
}
|
|||
|
|
}, [boardId])
|
|||
|
|
|
|||
|
|
const fetchBoard = async () => {
|
|||
|
|
setLoadingBoard(true)
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/wishlist/boards/${boardId}`)
|
|||
|
|
if (res.ok) {
|
|||
|
|
const data = await res.json()
|
|||
|
|
setName(data.name)
|
|||
|
|
setInviteEnabled(data.invite_enabled)
|
|||
|
|
setInviteURL(data.invite_url || '')
|
|||
|
|
} else {
|
|||
|
|
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
|||
|
|
} finally {
|
|||
|
|
setLoadingBoard(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
if (!name.trim()) {
|
|||
|
|
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLoading(true)
|
|||
|
|
try {
|
|||
|
|
const url = boardId
|
|||
|
|
? `/api/wishlist/boards/${boardId}`
|
|||
|
|
: '/api/wishlist/boards'
|
|||
|
|
|
|||
|
|
const res = await authFetch(url, {
|
|||
|
|
method: boardId ? 'PUT' : 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
name: name.trim(),
|
|||
|
|
invite_enabled: inviteEnabled
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
const data = await res.json()
|
|||
|
|
if (data.invite_url) {
|
|||
|
|
setInviteURL(data.invite_url)
|
|||
|
|
}
|
|||
|
|
onSaved?.()
|
|||
|
|
if (!boardId) {
|
|||
|
|
// При создании возвращаемся назад
|
|||
|
|
onNavigate('wishlist', { boardId: data.id })
|
|||
|
|
} else {
|
|||
|
|
// При редактировании возвращаемся на доску
|
|||
|
|
onNavigate('wishlist', { boardId: boardId })
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const err = await res.json()
|
|||
|
|
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Функция для автоматической генерации ссылки при включении доступа
|
|||
|
|
const generateInviteLink = async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, {
|
|||
|
|
method: 'POST'
|
|||
|
|
})
|
|||
|
|
if (res.ok) {
|
|||
|
|
const data = await res.json()
|
|||
|
|
setInviteURL(data.invite_url)
|
|||
|
|
setInviteEnabled(true)
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error generating invite link:', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleCopyLink = () => {
|
|||
|
|
navigator.clipboard.writeText(inviteURL)
|
|||
|
|
setCopied(true)
|
|||
|
|
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
|
|||
|
|
setTimeout(() => setCopied(false), 2000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleToggleInvite = async (enabled) => {
|
|||
|
|
setInviteEnabled(enabled)
|
|||
|
|
|
|||
|
|
if (boardId && enabled && !inviteURL) {
|
|||
|
|
// Автоматически генерируем ссылку при включении
|
|||
|
|
await generateInviteLink()
|
|||
|
|
} else if (boardId) {
|
|||
|
|
// Просто обновляем статус
|
|||
|
|
try {
|
|||
|
|
await authFetch(`/api/wishlist/boards/${boardId}`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ invite_enabled: enabled })
|
|||
|
|
})
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error updating invite status:', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
|
|||
|
|
|
|||
|
|
setIsDeleting(true)
|
|||
|
|
try {
|
|||
|
|
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
|
|||
|
|
method: 'DELETE'
|
|||
|
|
})
|
|||
|
|
if (res.ok) {
|
|||
|
|
onSaved?.()
|
|||
|
|
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
|
|||
|
|
onNavigate('wishlist', { boardDeleted: true })
|
|||
|
|
} else {
|
|||
|
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
|||
|
|
setIsDeleting(false)
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
|||
|
|
setIsDeleting(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleClose = () => {
|
|||
|
|
window.history.back()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loadingBoard) {
|
|||
|
|
return (
|
|||
|
|
<div className="board-form">
|
|||
|
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
|||
|
|
<div className="flex flex-col items-center">
|
|||
|
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
|||
|
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="board-form">
|
|||
|
|
<button className="close-x-button" onClick={handleClose}>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
|||
|
|
|
|||
|
|
<div className="form-card">
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label htmlFor="board-name">Название</label>
|
|||
|
|
<input
|
|||
|
|
id="board-name"
|
|||
|
|
type="text"
|
|||
|
|
className="form-input"
|
|||
|
|
value={name}
|
|||
|
|
onChange={e => setName(e.target.value)}
|
|||
|
|
placeholder="Название доски"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isEdit && (
|
|||
|
|
<>
|
|||
|
|
{/* Настройки доступа */}
|
|||
|
|
<div className="form-section">
|
|||
|
|
<h3>Доступ по ссылке</h3>
|
|||
|
|
|
|||
|
|
<label className="toggle-field">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={inviteEnabled}
|
|||
|
|
onChange={e => handleToggleInvite(e.target.checked)}
|
|||
|
|
/>
|
|||
|
|
<span className="toggle-slider"></span>
|
|||
|
|
<span className="toggle-label">Разрешить присоединение по ссылке</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
{inviteEnabled && inviteURL && (
|
|||
|
|
<div className="invite-link-section">
|
|||
|
|
<div className="invite-url-row">
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
className="invite-url-input"
|
|||
|
|
value={inviteURL}
|
|||
|
|
readOnly
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
className="copy-btn"
|
|||
|
|
onClick={handleCopyLink}
|
|||
|
|
title="Копировать ссылку"
|
|||
|
|
>
|
|||
|
|
{copied ? (
|
|||
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|||
|
|
<path d="M20 6L9 17l-5-5"></path>
|
|||
|
|
</svg>
|
|||
|
|
) : (
|
|||
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|||
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|||
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|||
|
|
</svg>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<p className="invite-hint">
|
|||
|
|
Пользователь, открывший ссылку, сможет присоединиться к доске
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Список участников */}
|
|||
|
|
<BoardMembers
|
|||
|
|
boardId={boardId}
|
|||
|
|
onMemberRemoved={() => {
|
|||
|
|
setToastMessage({ text: 'Участник удалён', type: 'success' })
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="form-actions">
|
|||
|
|
<SubmitButton
|
|||
|
|
onClick={handleSave}
|
|||
|
|
loading={loading}
|
|||
|
|
disabled={!name.trim()}
|
|||
|
|
>
|
|||
|
|
Сохранить
|
|||
|
|
</SubmitButton>
|
|||
|
|
{isEdit && (
|
|||
|
|
<DeleteButton
|
|||
|
|
onClick={handleDelete}
|
|||
|
|
loading={isDeleting}
|
|||
|
|
disabled={loading}
|
|||
|
|
title="Удалить доску"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{toastMessage && (
|
|||
|
|
<Toast
|
|||
|
|
message={toastMessage.text}
|
|||
|
|
type={toastMessage.type}
|
|||
|
|
onClose={() => setToastMessage(null)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default BoardForm
|
|||
|
|
|