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
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
25
play-life-web/src/components/Integrations.css
Normal file
25
play-life-web/src/components/Integrations.css
Normal 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;
|
||||
}
|
||||
|
||||
57
play-life-web/src/components/Integrations.jsx
Normal file
57
play-life-web/src/components/Integrations.jsx
Normal 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
|
||||
|
||||
170
play-life-web/src/components/TelegramIntegration.jsx
Normal file
170
play-life-web/src/components/TelegramIntegration.jsx
Normal 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
|
||||
|
||||
90
play-life-web/src/components/TodoistIntegration.jsx
Normal file
90
play-life-web/src/components/TodoistIntegration.jsx
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user