diff --git a/backend/bot.js b/backend/bot.js index 4d16e58..87b92d7 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -2,14 +2,33 @@ const axios = require('axios'); const config = require('./config'); -const TELEGRAM_API = `https://api.telegram.org/bot${config.telegramBotToken}`; +if (!config.telegramBotToken) { + console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен! Функция отправки фото в Telegram недоступна.'); +} + +const TELEGRAM_API = config.telegramBotToken + ? `https://api.telegram.org/bot${config.telegramBotToken}` + : null; // Отправить одно фото пользователю async function sendPhotoToUser(userId, photoUrl, caption) { + if (!TELEGRAM_API) { + throw new Error('TELEGRAM_BOT_TOKEN не установлен'); + } + try { + // Если photoUrl относительный (начинается с /), преобразуем в полный URL + let finalPhotoUrl = photoUrl; + if (photoUrl.startsWith('/')) { + // Если это прокси URL, нужно получить полный URL + // Для production используем домен из переменной окружения или дефолтный + const baseUrl = process.env.FRONTEND_URL || process.env.API_URL || 'https://nakama.glpshchn.ru'; + finalPhotoUrl = `${baseUrl}${photoUrl}`; + } + const response = await axios.post(`${TELEGRAM_API}/sendPhoto`, { chat_id: userId, - photo: photoUrl, + photo: finalPhotoUrl, caption: caption || '', parse_mode: 'HTML' }); @@ -23,6 +42,10 @@ async function sendPhotoToUser(userId, photoUrl, caption) { // Отправить несколько фото группой (до 10 штук) async function sendPhotosToUser(userId, photos) { + if (!TELEGRAM_API) { + throw new Error('TELEGRAM_BOT_TOKEN не установлен'); + } + try { // Telegram поддерживает до 10 фото в одной группе const batches = []; @@ -31,14 +54,23 @@ async function sendPhotosToUser(userId, photos) { } const results = []; + const baseUrl = process.env.FRONTEND_URL || process.env.API_URL || 'https://nakama.glpshchn.ru'; for (const batch of batches) { - const media = batch.map((photo, index) => ({ - type: 'photo', - media: photo.url, - caption: index === 0 ? `Из NakamaSpace\n${batch.length} фото` : undefined, - parse_mode: 'HTML' - })); + const media = batch.map((photo, index) => { + // Преобразуем относительные URL в полные + let photoUrl = photo.url; + if (photoUrl.startsWith('/')) { + photoUrl = `${baseUrl}${photoUrl}`; + } + + return { + type: 'photo', + media: photoUrl, + caption: index === 0 ? `Из NakamaSpace\n${batch.length} фото` : undefined, + parse_mode: 'HTML' + }; + }); const response = await axios.post(`${TELEGRAM_API}/sendMediaGroup`, { chat_id: userId, diff --git a/backend/config/index.js b/backend/config/index.js index fa3f1ce..6a47a58 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -17,6 +17,10 @@ module.exports = { // Telegram telegramBotToken: process.env.TELEGRAM_BOT_TOKEN, + // Gelbooru API + gelbooruApiKey: process.env.GELBOORU_API_KEY || '638e2433d451fc02e848811acdafdce08317073c01ed78e38139115c19fe04afa367f736726514ef1337565d4c05b3cbe2c81125c424301e90d29d1f7f4cceff', + gelbooruUserId: process.env.GELBOORU_USER_ID || '1844464', + // Frontend URL frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', diff --git a/backend/routes/search.js b/backend/routes/search.js index bc39195..2c59b1f 100644 --- a/backend/routes/search.js +++ b/backend/routes/search.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const axios = require('axios'); const { authenticate } = require('../middleware/auth'); +const config = require('../config'); // Функция для создания прокси URL function createProxyUrl(originalUrl) { @@ -117,7 +118,9 @@ router.get('/anime', authenticate, async (req, res) => { json: 1, tags: query, limit, - pid: page + pid: page, + api_key: config.gelbooruApiKey, + user_id: config.gelbooruUserId }, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' @@ -204,7 +207,9 @@ router.get('/anime/tags', authenticate, async (req, res) => { json: 1, name_pattern: `${query}%`, orderby: 'count', - limit: 10 + limit: 10, + api_key: config.gelbooruApiKey, + user_id: config.gelbooruUserId }, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5b99957..72f90ee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,8 @@ import Search from './pages/Search' import Notifications from './pages/Notifications' import Profile from './pages/Profile' import UserProfile from './pages/UserProfile' +import CommentsPage from './pages/CommentsPage' +import PostMenuPage from './pages/PostMenuPage' import './styles/index.css' function App() { @@ -92,6 +94,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/PostCard.jsx b/frontend/src/components/PostCard.jsx index 48d8ce2..4961fd2 100644 --- a/frontend/src/components/PostCard.jsx +++ b/frontend/src/components/PostCard.jsx @@ -1,10 +1,8 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight } from 'lucide-react' -import { likePost, commentPost, deletePost } from '../utils/api' +import { likePost, deletePost } from '../utils/api' import { hapticFeedback, showConfirm } from '../utils/telegram' -import PostMenu from './PostMenu' -import CommentsModal from './CommentsModal' import './PostCard.css' const TAG_COLORS = { @@ -23,8 +21,6 @@ export default function PostCard({ post, currentUser, onUpdate }) { const navigate = useNavigate() const [liked, setLiked] = useState(post.likes.includes(currentUser.id)) const [likesCount, setLikesCount] = useState(post.likes.length) - const [showMenu, setShowMenu] = useState(false) - const [showComments, setShowComments] = useState(false) const [currentImageIndex, setCurrentImageIndex] = useState(0) // Поддержка и старого поля imageUrl и нового images @@ -87,7 +83,7 @@ export default function PostCard({ post, currentUser, onUpdate }) { - @@ -160,30 +156,11 @@ export default function PostCard({ post, currentUser, onUpdate }) { {likesCount} - - - {/* Меню поста */} - {showMenu && ( - setShowMenu(false)} - onDelete={handleDelete} - /> - )} - - {/* Комментарии */} - {showComments && ( - setShowComments(false)} - onUpdate={onUpdate} - /> - )} ) } diff --git a/frontend/src/pages/CommentsPage.css b/frontend/src/pages/CommentsPage.css new file mode 100644 index 0000000..993293f --- /dev/null +++ b/frontend/src/pages/CommentsPage.css @@ -0,0 +1,233 @@ +.comments-page { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + overflow: hidden; +} + +.page-header { + padding: 16px; + border-bottom: 1px solid var(--divider-color); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + background: var(--bg-secondary); + z-index: 100; +} + +.page-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.back-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; +} + +.back-btn svg { + stroke: currentColor; +} + +.loading-state, +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 16px; +} + +.loading-state p, +.empty-state p { + color: var(--text-secondary); + font-size: 16px; +} + +/* Превью поста */ +.post-preview { + padding: 16px; + border-bottom: 1px solid var(--divider-color); + background: var(--bg-secondary); +} + +.preview-author { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.preview-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.preview-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.preview-username { + font-size: 13px; + color: var(--text-secondary); +} + +.preview-content { + font-size: 15px; + line-height: 1.5; + color: var(--text-primary); + margin-bottom: 12px; + white-space: pre-wrap; +} + +.preview-images { + display: flex; + flex-direction: column; + gap: 8px; +} + +.preview-images img { + width: 100%; + border-radius: 12px; + max-height: 400px; + object-fit: contain; + background: var(--bg-primary); +} + +.comments-list { + flex: 1; + overflow-y: auto; + padding: 16px; + padding-bottom: 100px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty-comments { + padding: 60px 20px; + text-align: center; +} + +.empty-comments p { + color: var(--text-primary); + font-size: 16px; + margin-bottom: 8px; +} + +.empty-comments span { + color: var(--text-secondary); + font-size: 14px; +} + +.comment-item { + display: flex; + gap: 12px; +} + +.comment-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.comment-content { + flex: 1; +} + +.comment-header { + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +.comment-author { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.comment-time { + font-size: 12px; + color: var(--text-secondary); +} + +.comment-text { + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); +} + +.comment-form { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 12px 16px; + padding-bottom: calc(12px + 80px); /* Отступ для навигации */ + background: var(--bg-secondary); + border-top: 1px solid var(--divider-color); + display: flex; + gap: 8px; + z-index: 1000; +} + +.comment-form input { + flex: 1; + padding: 12px 16px; + border-radius: 24px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 15px; + border: none; +} + +.send-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background: #1C1C1E; + color: white; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + flex-shrink: 0; +} + +.send-btn svg { + stroke: white; +} + +.send-btn:disabled { + opacity: 0.5; +} + +[data-theme="dark"] .send-btn { + background: #FFFFFF; + color: #000000; +} + +[data-theme="dark"] .send-btn svg { + stroke: #000000; +} + diff --git a/frontend/src/pages/CommentsPage.jsx b/frontend/src/pages/CommentsPage.jsx new file mode 100644 index 0000000..699f4b1 --- /dev/null +++ b/frontend/src/pages/CommentsPage.jsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { ArrowLeft, Send } from 'lucide-react' +import { getPosts, commentPost } from '../utils/api' +import { hapticFeedback } from '../utils/telegram' +import './CommentsPage.css' + +export default function CommentsPage({ user }) { + const navigate = useNavigate() + const { postId } = useParams() + const [post, setPost] = useState(null) + const [comment, setComment] = useState('') + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [comments, setComments] = useState([]) + + useEffect(() => { + loadPost() + }, [postId]) + + const loadPost = async () => { + try { + setLoading(true) + const posts = await getPosts() + const foundPost = posts.posts.find(p => p._id === postId) + if (foundPost) { + setPost(foundPost) + setComments(foundPost.comments || []) + } + } catch (error) { + console.error('Ошибка загрузки поста:', error) + } finally { + setLoading(false) + } + } + + const handleSubmit = async () => { + if (!comment.trim()) return + + try { + setSubmitting(true) + hapticFeedback('light') + + const result = await commentPost(postId, comment) + setComments(result.comments) + setComment('') + hapticFeedback('success') + // Обновить пост + await loadPost() + } catch (error) { + console.error('Ошибка добавления комментария:', error) + hapticFeedback('error') + } finally { + setSubmitting(false) + } + } + + const formatDate = (date) => { + const d = new Date(date) + const now = new Date() + const diff = Math.floor((now - d) / 1000) // секунды + + if (diff < 60) return 'только что' + if (diff < 3600) return `${Math.floor(diff / 60)} мин` + if (diff < 86400) return `${Math.floor(diff / 3600)} ч` + + return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) + } + + if (loading) { + return ( +
+
+ +

Комментарии

+
+
+
+
+

Загрузка...

+
+
+ ) + } + + if (!post) { + return ( +
+
+ +

Комментарии

+
+
+
+

Пост не найден

+
+
+ ) + } + + // Поддержка и старого поля imageUrl и нового images + const images = post.images && post.images.length > 0 ? post.images : (post.imageUrl ? [post.imageUrl] : []) + + return ( +
+ {/* Хедер */} +
+ +

Комментарии

+
+
+ + {/* Пост */} +
+
+ {post.author.username} +
+
+ {post.author.firstName} {post.author.lastName} +
+
@{post.author.username}
+
+
+ + {post.content && ( +
{post.content}
+ )} + + {images.length > 0 && ( +
+ {images.map((img, index) => ( + {`Post + ))} +
+ )} +
+ + {/* Список комментариев */} +
+ {comments.length === 0 ? ( +
+

Пока нет комментариев

+ Будьте первым! +
+ ) : ( + comments.map((c, index) => ( +
+ {c.author.username} +
+
+ + {c.author.firstName} {c.author.lastName} + + {formatDate(c.createdAt)} +
+

{c.content}

+
+
+ )) + )} +
+ + {/* Форма добавления комментария */} +
+ setComment(e.target.value)} + onKeyPress={e => e.key === 'Enter' && handleSubmit()} + maxLength={500} + /> + +
+
+ ) +} + diff --git a/frontend/src/pages/PostMenuPage.css b/frontend/src/pages/PostMenuPage.css new file mode 100644 index 0000000..602e9e2 --- /dev/null +++ b/frontend/src/pages/PostMenuPage.css @@ -0,0 +1,190 @@ +.post-menu-page { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + overflow: hidden; +} + +.page-header { + padding: 16px; + border-bottom: 1px solid var(--divider-color); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + background: var(--bg-secondary); + z-index: 100; +} + +.page-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.back-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; +} + +.back-btn svg { + stroke: currentColor; +} + +.loading-state, +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 16px; +} + +.loading-state p, +.empty-state p { + color: var(--text-secondary); + font-size: 16px; +} + +/* Превью поста */ +.post-preview { + padding: 16px; + border-bottom: 1px solid var(--divider-color); + background: var(--bg-secondary); +} + +.preview-author { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.preview-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.preview-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.preview-username { + font-size: 13px; + color: var(--text-secondary); +} + +.preview-content { + font-size: 15px; + line-height: 1.5; + color: var(--text-primary); + margin-bottom: 12px; + white-space: pre-wrap; +} + +.preview-images { + display: flex; + flex-direction: column; + gap: 8px; +} + +.preview-images img { + width: 100%; + border-radius: 12px; + max-height: 400px; + object-fit: contain; + background: var(--bg-primary); +} + +.menu-items { + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.menu-item { + width: 100%; + padding: 16px; + background: var(--bg-primary); + border: none; + display: flex; + align-items: center; + gap: 12px; + color: var(--text-primary); + font-size: 16px; + font-weight: 500; + border-radius: 12px; + cursor: pointer; +} + +.menu-item svg { + stroke: currentColor; +} + +.menu-item:active { + opacity: 0.7; + transform: scale(0.98); +} + +.menu-item.danger { + color: #FF3B30; +} + +.submit-btn { + padding: 8px 16px; + border-radius: 20px; + background: #1C1C1E; + color: white; + font-size: 14px; + font-weight: 600; + border: none; + cursor: pointer; +} + +.submit-btn:disabled { + opacity: 0.5; +} + +[data-theme="dark"] .submit-btn { + background: #FFFFFF; + color: #000000; +} + +[data-theme="dark"] .submit-btn:disabled { + opacity: 0.5; +} + +.report-body { + flex: 1; + padding: 16px; +} + +.report-body textarea { + width: 100%; + height: 200px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 15px; + line-height: 1.5; + resize: none; +} + diff --git a/frontend/src/pages/PostMenuPage.jsx b/frontend/src/pages/PostMenuPage.jsx new file mode 100644 index 0000000..13333dd --- /dev/null +++ b/frontend/src/pages/PostMenuPage.jsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { ArrowLeft, Trash2, Flag } from 'lucide-react' +import { getPosts, reportPost, deletePost } from '../utils/api' +import { hapticFeedback, showConfirm } from '../utils/telegram' +import './PostMenuPage.css' + +export default function PostMenuPage({ user }) { + const navigate = useNavigate() + const { postId } = useParams() + const [post, setPost] = useState(null) + const [loading, setLoading] = useState(true) + const [showReportModal, setShowReportModal] = useState(false) + const [reportReason, setReportReason] = useState('') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + loadPost() + }, [postId]) + + const loadPost = async () => { + try { + setLoading(true) + const posts = await getPosts() + const foundPost = posts.posts.find(p => p._id === postId) + if (foundPost) { + setPost(foundPost) + } + } catch (error) { + console.error('Ошибка загрузки поста:', error) + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + const confirmed = await showConfirm('Удалить этот пост?') + if (confirmed) { + try { + hapticFeedback('light') + await deletePost(postId) + hapticFeedback('success') + navigate(-1) + } catch (error) { + console.error('Ошибка удаления:', error) + hapticFeedback('error') + } + } + } + + const handleReport = async () => { + if (!reportReason.trim()) { + alert('Укажите причину жалобы') + return + } + + try { + setSubmitting(true) + hapticFeedback('light') + await reportPost(postId, reportReason) + hapticFeedback('success') + alert('Жалоба отправлена') + navigate(-1) + } catch (error) { + console.error('Ошибка отправки жалобы:', error) + hapticFeedback('error') + alert('Ошибка отправки жалобы') + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+
+ +

Действия

+
+
+
+
+

Загрузка...

+
+
+ ) + } + + if (!post) { + return ( +
+
+ +

Действия

+
+
+
+

Пост не найден

+
+
+ ) + } + + if (showReportModal) { + return ( +
+
+ +

Пожаловаться

+ +
+ +
+