Release v1.1.0: Add Telegram and Todoist integrations UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s

- Add telegram_integrations table to store bot token and chat_id
- Add Integrations tab with Todoist and Telegram integration screens
- Remove TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from env variables
- All Telegram configuration now done through UI
- Telegram webhook registration happens when user saves bot token
- Rename TELEGRAM_WEBHOOK_BASE_URL to WEBHOOK_BASE_URL
This commit is contained in:
poignatov
2025-12-31 19:11:28 +03:00
parent 63af6bf4ed
commit 7398918bc0
20 changed files with 721 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -7,6 +7,7 @@ import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords'
import Integrations from './components/Integrations'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed'
@@ -24,6 +25,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
})
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -36,6 +38,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
})
// Параметры для навигации между вкладками
@@ -74,7 +77,7 @@ function App() {
try {
const savedTab = window.localStorage?.getItem('activeTab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test']
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
@@ -184,6 +187,7 @@ function App() {
'test-config': false,
'add-config': false,
test: false,
integrations: false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
@@ -471,6 +475,12 @@ function App() {
/>
</div>
)}
{loadedTabs.integrations && (
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
<Integrations onNavigate={handleNavigate} />
</div>
)}
</div>
</div>
@@ -519,6 +529,26 @@ function App() {
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('integrations')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'integrations'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Интеграции"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
</span>
{activeTab === 'integrations' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
</div>
</div>
)}

View File

@@ -0,0 +1,25 @@
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react'
import TodoistIntegration from './TodoistIntegration'
import TelegramIntegration from './TelegramIntegration'
function Integrations({ onNavigate }) {
const [selectedIntegration, setSelectedIntegration] = useState(null)
const integrations = [
{ id: 'todoist', name: 'TODOist' },
{ id: 'telegram', name: 'Telegram' },
]
if (selectedIntegration) {
if (selectedIntegration === 'todoist') {
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6">
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
<div className="space-y-4">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-800">
{integration.name}
</span>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
)
}
export default Integrations

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from 'react'
import './Integrations.css'
function TelegramIntegration({ onBack }) {
const [botToken, setBotToken] = useState('')
const [chatId, setChatId] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchIntegration()
}, [])
const fetchIntegration = async () => {
try {
setLoading(true)
const response = await fetch('/api/integrations/telegram')
if (!response.ok) {
throw new Error('Ошибка при загрузке интеграции')
}
const data = await response.json()
setBotToken(data.bot_token || '')
setChatId(data.chat_id || '')
} catch (error) {
console.error('Error fetching integration:', error)
setError('Не удалось загрузить данные интеграции')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!botToken.trim()) {
setError('Bot Token обязателен для заполнения')
return
}
try {
setSaving(true)
setError('')
setSuccess('')
const response = await fetch('/api/integrations/telegram', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ bot_token: botToken }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Ошибка при сохранении')
}
const data = await response.json()
setBotToken(data.bot_token || '')
setChatId(data.chat_id || '')
setSuccess('Bot Token успешно сохранен!')
} catch (error) {
console.error('Error saving integration:', error)
setError(error.message || 'Не удалось сохранить Bot Token')
} finally {
setSaving(false)
}
}
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
{loading ? (
<div className="text-gray-500">Загрузка...</div>
) : (
<>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Настройки</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Telegram Bot Token
</label>
<input
type="text"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder="Введите Bot Token"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{chatId && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Chat ID (устанавливается автоматически)
</label>
<input
type="text"
value={chatId}
readOnly
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
</div>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
{success}
</div>
)}
<button
onClick={handleSave}
disabled={saving || !botToken.trim()}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? 'Сохранение...' : 'Сохранить Bot Token'}
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Откуда взять Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте Telegram и найдите бота @BotFather</li>
<li>Отправьте команду /newbot</li>
<li>Следуйте инструкциям для создания нового бота</li>
<li>
После создания бота BotFather предоставит вам Bot Token
</li>
<li>Скопируйте токен и вставьте его в поле выше</li>
</ol>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-yellow-900">
Что нужно сделать после сохранения Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>После сохранения Bot Token отправьте первое сообщение вашему боту в Telegram</li>
<li>
Chat ID будет автоматически сохранен после обработки первого
сообщения
</li>
<li>
После этого бот сможет отправлять вам ответные сообщения
</li>
</ol>
</div>
</>
)}
</div>
)
}
export default TelegramIntegration

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react'
import './Integrations.css'
function TodoistIntegration({ onBack }) {
const [webhookURL, setWebhookURL] = useState('')
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
fetchWebhookURL()
}, [])
const fetchWebhookURL = async () => {
try {
setLoading(true)
const response = await fetch('/api/integrations/todoist/webhook-url')
if (!response.ok) {
throw new Error('Ошибка при загрузке URL webhook')
}
const data = await response.json()
setWebhookURL(data.webhook_url)
} catch (error) {
console.error('Error fetching webhook URL:', error)
} finally {
setLoading(false)
}
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(webhookURL)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">TODOist интеграция</h1>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
{loading ? (
<div className="text-gray-500">Загрузка...</div>
) : (
<div className="flex items-center gap-2">
<input
type="text"
value={webhookURL}
readOnly
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors whitespace-nowrap"
>
{copied ? 'Скопировано!' : 'Копировать'}
</button>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Как использовать в приложении TODOist
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте приложение TODOist на вашем устройстве</li>
<li>Перейдите в настройки проекта или задачи</li>
<li>Найдите раздел "Интеграции" или "Webhooks"</li>
<li>Вставьте скопированный URL webhook в соответствующее поле</li>
<li>Сохраните настройки</li>
<li>
Теперь при закрытии задач в TODOist они будут автоматически
обрабатываться системой
</li>
</ol>
</div>
</div>
)
}
export default TodoistIntegration