2026-02-08 17:01:36 +03:00
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
|
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import './WordList.css'
|
|
|
|
|
|
|
|
|
|
|
|
const API_URL = '/api'
|
|
|
|
|
|
|
|
|
|
|
|
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [words, setWords] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const [dictionary, setDictionary] = useState(null)
|
|
|
|
|
|
const [dictionaryName, setDictionaryName] = useState('')
|
|
|
|
|
|
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
|
|
|
|
|
|
const [isSavingName, setIsSavingName] = useState(false)
|
|
|
|
|
|
const [selectedWord, setSelectedWord] = useState(null)
|
|
|
|
|
|
// Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined
|
|
|
|
|
|
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null)
|
|
|
|
|
|
|
|
|
|
|
|
// isNewDict is computed from currentDictionaryId: new dictionary if currentDictionaryId == null
|
|
|
|
|
|
const isNewDict = currentDictionaryId == null
|
|
|
|
|
|
|
|
|
|
|
|
// Helper function to check if dictionary exists and is not new
|
|
|
|
|
|
const hasValidDictionary = (dictId) => {
|
|
|
|
|
|
return dictId !== undefined && dictId !== null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// Normalize undefined to null: if dictionaryId is undefined, treat it as null (new dictionary)
|
|
|
|
|
|
const normalizedDictionaryId = dictionaryId ?? null
|
|
|
|
|
|
setCurrentDictionaryId(normalizedDictionaryId)
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedDictionaryId == null) {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
setDictionary(null)
|
|
|
|
|
|
setDictionaryName('')
|
|
|
|
|
|
setOriginalDictionaryName('')
|
|
|
|
|
|
setWords([])
|
|
|
|
|
|
} else if (hasValidDictionary(normalizedDictionaryId)) {
|
|
|
|
|
|
fetchDictionary(normalizedDictionaryId)
|
|
|
|
|
|
fetchWords(normalizedDictionaryId)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
setWords([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [dictionaryId, refreshTrigger])
|
|
|
|
|
|
|
|
|
|
|
|
const fetchDictionary = async (dictId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/dictionaries`)
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке словарей')
|
|
|
|
|
|
}
|
|
|
|
|
|
const dictionaries = await response.json()
|
|
|
|
|
|
const dict = dictionaries.find(d => d.id === dictId)
|
|
|
|
|
|
if (dict) {
|
|
|
|
|
|
setDictionary(dict)
|
|
|
|
|
|
setDictionaryName(dict.name)
|
|
|
|
|
|
setOriginalDictionaryName(dict.name)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching dictionary:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fetchWords = async (dictId) => {
|
|
|
|
|
|
if (!hasValidDictionary(dictId)) {
|
|
|
|
|
|
setWords([])
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await fetchWordsForDictionary(dictId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fetchWordsForDictionary = async (dictId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
|
|
|
|
|
const response = await authFetch(url)
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при загрузке слов')
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
setWords(Array.isArray(data) ? data : [])
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
setWords([])
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleWordClick = (word) => {
|
|
|
|
|
|
setSelectedWord(word)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteWord = async () => {
|
|
|
|
|
|
if (!selectedWord) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.confirm('Вы уверены, что хотите удалить это слово?')) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/words/${selectedWord.id}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при удалении слова')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove word from local state
|
|
|
|
|
|
setWords(words.filter(word => word.id !== selectedWord.id))
|
|
|
|
|
|
setSelectedWord(null)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleResetProgress = async () => {
|
|
|
|
|
|
if (!selectedWord) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.confirm('Вы уверены, что хотите сбросить прогресс этого слова?')) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/words/${selectedWord.id}/reset-progress`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при сбросе прогресса')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update word in local state - reset progress fields
|
|
|
|
|
|
setWords(words.map(word =>
|
|
|
|
|
|
word.id === selectedWord.id
|
|
|
|
|
|
? { ...word, success: 0, failure: 0, last_success_at: null, last_failure_at: null }
|
|
|
|
|
|
: word
|
|
|
|
|
|
))
|
|
|
|
|
|
setSelectedWord(null)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleNameChange = (e) => {
|
|
|
|
|
|
setDictionaryName(e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleNameSave = async () => {
|
|
|
|
|
|
if (!dictionaryName.trim()) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsSavingName(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!hasValidDictionary(currentDictionaryId)) {
|
|
|
|
|
|
// Create new dictionary
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/dictionaries`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ name: dictionaryName.trim() }),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при создании словаря')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newDict = await response.json()
|
|
|
|
|
|
const newDictionaryId = newDict.id
|
|
|
|
|
|
|
|
|
|
|
|
// Update local state
|
|
|
|
|
|
setOriginalDictionaryName(newDict.name)
|
|
|
|
|
|
setDictionaryName(newDict.name)
|
|
|
|
|
|
setDictionary(newDict)
|
|
|
|
|
|
setCurrentDictionaryId(newDictionaryId)
|
|
|
|
|
|
|
|
|
|
|
|
// Reinitialize screen: fetch dictionary info and words for the new dictionary
|
|
|
|
|
|
await fetchDictionary(newDictionaryId)
|
|
|
|
|
|
await fetchWordsForDictionary(newDictionaryId)
|
|
|
|
|
|
|
2026-03-08 19:56:25 +03:00
|
|
|
|
// Update navigation to use the new dictionary ID and name (replace history entry so back goes to dictionaries)
|
|
|
|
|
|
onNavigate?.('words', { dictionaryId: newDictionaryId, dictionaryName: newDict.name }, { replace: true })
|
2026-02-08 17:01:36 +03:00
|
|
|
|
} else if (hasValidDictionary(currentDictionaryId)) {
|
|
|
|
|
|
// Update existing dictionary (rename)
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ name: dictionaryName.trim() }),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при обновлении словаря')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setOriginalDictionaryName(dictionaryName.trim())
|
|
|
|
|
|
if (dictionary) {
|
|
|
|
|
|
setDictionary({ ...dictionary, name: dictionaryName.trim() })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSavingName(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Show save button only if name is not empty and has changed
|
|
|
|
|
|
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
|
|
|
|
|
|
|
|
|
|
|
|
if (error && !loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="word-list">
|
|
|
|
|
|
<LoadingError onRetry={() => {
|
|
|
|
|
|
if (hasValidDictionary(currentDictionaryId)) {
|
|
|
|
|
|
fetchWordsForDictionary(currentDictionaryId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="word-list">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => window.history.back()}
|
|
|
|
|
|
className="close-x-button"
|
|
|
|
|
|
title="Закрыть"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="fixed inset-0 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Dictionary name input */}
|
|
|
|
|
|
<div className="dictionary-name-input-container">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
className="dictionary-name-input"
|
|
|
|
|
|
value={dictionaryName}
|
|
|
|
|
|
onChange={handleNameChange}
|
|
|
|
|
|
placeholder="Введите название словаря"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{showSaveButton && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="dictionary-name-save-button"
|
|
|
|
|
|
onClick={handleNameSave}
|
|
|
|
|
|
disabled={isSavingName}
|
|
|
|
|
|
title="Сохранить название"
|
|
|
|
|
|
>
|
|
|
|
|
|
✓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Show add button and words list only if dictionaryId exists and is not new */}
|
|
|
|
|
|
{hasValidDictionary(currentDictionaryId) && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{(!words || words.length === 0) ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="empty-state">
|
|
|
|
|
|
<p>Слов пока нет. Добавьте слова через экран "Добавить слова".</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="words-grid">
|
|
|
|
|
|
{words.map((word) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={word.id}
|
|
|
|
|
|
className="word-card"
|
|
|
|
|
|
onClick={() => handleWordClick(word)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="word-content">
|
|
|
|
|
|
<div className="word-header">
|
|
|
|
|
|
<h3 className="word-name">{word.name}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="word-translation">{word.translation}</div>
|
|
|
|
|
|
{word.description && (
|
|
|
|
|
|
<div className="word-description">{word.description}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="word-stats">
|
|
|
|
|
|
<span className="stat-success">{word.success || 0}</span>
|
|
|
|
|
|
<span className="stat-separator"> | </span>
|
|
|
|
|
|
<span className="stat-failure">{word.failure || 0}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Модальное окно для действий со словом */}
|
|
|
|
|
|
{selectedWord && (
|
|
|
|
|
|
<div className="word-modal-overlay" onClick={() => setSelectedWord(null)}>
|
|
|
|
|
|
<div className="word-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="word-modal-header">
|
|
|
|
|
|
<h3>{selectedWord.name}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="word-modal-actions">
|
|
|
|
|
|
<button className="word-modal-reset" onClick={handleResetProgress}>
|
|
|
|
|
|
Сбросить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="word-modal-delete" onClick={handleDeleteWord}>
|
|
|
|
|
|
Удалить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default WordList
|
|
|
|
|
|
|