Update files
This commit is contained in:
parent
d90f6088d2
commit
a678b129c8
|
|
@ -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) => ({
|
||||
const media = batch.map((photo, index) => {
|
||||
// Преобразуем относительные URL в полные
|
||||
let photoUrl = photo.url;
|
||||
if (photoUrl.startsWith('/')) {
|
||||
photoUrl = `${baseUrl}${photoUrl}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'photo',
|
||||
media: photo.url,
|
||||
media: photoUrl,
|
||||
caption: index === 0 ? `<b>Из NakamaSpace</b>\n${batch.length} фото` : undefined,
|
||||
parse_mode: 'HTML'
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const response = await axios.post(`${TELEGRAM_API}/sendMediaGroup`, {
|
||||
chat_id: userId,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="notifications" element={<Notifications user={user} />} />
|
||||
<Route path="profile" element={<Profile user={user} setUser={setUser} />} />
|
||||
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
|
||||
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
|
||||
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button className="menu-btn" onClick={() => setShowMenu(true)}>
|
||||
<button className="menu-btn" onClick={() => navigate(`/post/${post._id}/menu`)}>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -160,30 +156,11 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
|||
<span>{likesCount}</span>
|
||||
</button>
|
||||
|
||||
<button className="action-btn" onClick={() => setShowComments(true)}>
|
||||
<button className="action-btn" onClick={() => navigate(`/post/${post._id}/comments`)}>
|
||||
<MessageCircle size={20} stroke="currentColor" />
|
||||
<span>{post.comments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Меню поста */}
|
||||
{showMenu && (
|
||||
<PostMenu
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
onClose={() => setShowMenu(false)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Комментарии */}
|
||||
{showComments && (
|
||||
<CommentsModal
|
||||
post={post}
|
||||
onClose={() => setShowComments(false)}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="comments-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Комментарии</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="comments-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Комментарии</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
<div className="empty-state">
|
||||
<p>Пост не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Поддержка и старого поля imageUrl и нового images
|
||||
const images = post.images && post.images.length > 0 ? post.images : (post.imageUrl ? [post.imageUrl] : [])
|
||||
|
||||
return (
|
||||
<div className="comments-page">
|
||||
{/* Хедер */}
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Комментарии</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
|
||||
{/* Пост */}
|
||||
<div className="post-preview">
|
||||
<div className="preview-author">
|
||||
<img
|
||||
src={post.author.photoUrl || '/default-avatar.png'}
|
||||
alt={post.author.username}
|
||||
className="preview-avatar"
|
||||
/>
|
||||
<div>
|
||||
<div className="preview-name">
|
||||
{post.author.firstName} {post.author.lastName}
|
||||
</div>
|
||||
<div className="preview-username">@{post.author.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.content && (
|
||||
<div className="preview-content">{post.content}</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="preview-images">
|
||||
{images.map((img, index) => (
|
||||
<img key={index} src={img} alt={`Post ${index + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список комментариев */}
|
||||
<div className="comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<div className="empty-comments">
|
||||
<p>Пока нет комментариев</p>
|
||||
<span>Будьте первым!</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c, index) => (
|
||||
<div key={index} className="comment-item fade-in">
|
||||
<img
|
||||
src={c.author.photoUrl || '/default-avatar.png'}
|
||||
alt={c.author.username}
|
||||
className="comment-avatar"
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">
|
||||
{c.author.firstName} {c.author.lastName}
|
||||
</span>
|
||||
<span className="comment-time">{formatDate(c.createdAt)}</span>
|
||||
</div>
|
||||
<p className="comment-text">{c.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Форма добавления комментария */}
|
||||
<div className="comment-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Написать комментарий..."
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && handleSubmit()}
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !comment.trim()}
|
||||
className="send-btn"
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="post-menu-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Действия</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="post-menu-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Действия</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
<div className="empty-state">
|
||||
<p>Пост не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showReportModal) {
|
||||
return (
|
||||
<div className="post-menu-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => setShowReportModal(false)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Пожаловаться</h2>
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleReport}
|
||||
disabled={submitting || !reportReason.trim()}
|
||||
>
|
||||
{submitting ? 'Отправка...' : 'Отправить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="report-body">
|
||||
<textarea
|
||||
placeholder="Опишите причину жалобы..."
|
||||
value={reportReason}
|
||||
onChange={e => setReportReason(e.target.value)}
|
||||
maxLength={500}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-menu-page">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<h2>Действия</h2>
|
||||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
|
||||
{/* Пост */}
|
||||
<div className="post-preview">
|
||||
<div className="preview-author">
|
||||
<img
|
||||
src={post.author.photoUrl || '/default-avatar.png'}
|
||||
alt={post.author.username}
|
||||
className="preview-avatar"
|
||||
/>
|
||||
<div>
|
||||
<div className="preview-name">
|
||||
{post.author.firstName} {post.author.lastName}
|
||||
</div>
|
||||
<div className="preview-username">@{post.author.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.content && (
|
||||
<div className="preview-content">{post.content}</div>
|
||||
)}
|
||||
|
||||
{(post.images && post.images.length > 0) || post.imageUrl ? (
|
||||
<div className="preview-images">
|
||||
{post.images && post.images.length > 0 ? (
|
||||
post.images.map((img, index) => (
|
||||
<img key={index} src={img} alt={`Post ${index + 1}`} />
|
||||
))
|
||||
) : (
|
||||
<img src={post.imageUrl} alt="Post" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Меню */}
|
||||
<div className="menu-items">
|
||||
{(post.author._id === user.id) || (user.role === 'moderator' || user.role === 'admin') ? (
|
||||
<button className="menu-item danger" onClick={handleDelete}>
|
||||
<Trash2 size={20} />
|
||||
<span>Удалить пост</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="menu-item" onClick={() => setShowReportModal(true)}>
|
||||
<Flag size={20} />
|
||||
<span>Пожаловаться</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
╔═══════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🔧 GELBOORU API КЛЮЧ ДОБАВЛЕН! 🔧 ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
|
||||
ИСПРАВЛЕНО:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
✅ 1. Добавлен Gelbooru API ключ в конфиг
|
||||
• API Key: 638e2433d451fc02e848811acdafdce08317073c01ed78e38139115c19fe04afa367f736726514ef1337565d4c05b3cbe2c81125c424301e90d29d1f7f4cceff
|
||||
• User ID: 1844464
|
||||
|
||||
✅ 2. Обновлены запросы к Gelbooru API
|
||||
• Поиск постов теперь использует api_key и user_id
|
||||
• Автокомплит тегов теперь использует api_key и user_id
|
||||
• Согласно документации: https://gelbooru.com/index.php?page=wiki&s=view&id=18780
|
||||
|
||||
✅ 3. Запросы не будут ограничены
|
||||
• API ключ позволяет избежать throttling
|
||||
• Более стабильная работа API
|
||||
|
||||
|
||||
ИЗМЕНЕННЫЕ ФАЙЛЫ:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Backend:
|
||||
• backend/config/index.js
|
||||
• backend/routes/search.js
|
||||
|
||||
|
||||
ОБНОВЛЕНИЕ (2 файла):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
cd /Users/glpshchn/Desktop/nakama
|
||||
|
||||
# Backend
|
||||
scp backend/config/index.js backend/routes/search.js root@ваш_IP:/var/www/nakama/backend/
|
||||
scp backend/routes/search.js root@ваш_IP:/var/www/nakama/backend/routes/
|
||||
|
||||
# На сервере
|
||||
ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
|
||||
|
||||
|
||||
ЧТО ИСПРАВЛЕНО:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
1. ✅ Gelbooru API теперь использует аутентификацию
|
||||
2. ✅ Запросы не будут ограничены (throttling)
|
||||
3. ✅ Более стабильная работа поиска в Gelbooru
|
||||
|
||||
|
||||
ПРИМЕЧАНИЕ:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
API ключ можно также добавить в переменные окружения:
|
||||
• GELBOORU_API_KEY=638e2433d451fc02e848811acdafdce08317073c01ed78e38139115c19fe04afa367f736726514ef1337565d4c05b3cbe2c81125c424301e90d29d1f7f4cceff
|
||||
• GELBOORU_USER_ID=1844464
|
||||
|
||||
Если не указаны, будут использоваться значения по умолчанию из конфига.
|
||||
|
||||
|
||||
2 минуты
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
╔═══════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🔧 СТРАНИЦЫ ВМЕСТО МОДАЛОК + ИСПРАВЛЕН БОТ 🔧 ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
|
||||
ИСПРАВЛЕНО:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
✅ 1. Проблема с ботом (botundefined в URL)
|
||||
• Добавлена проверка на наличие TELEGRAM_BOT_TOKEN
|
||||
• Преобразование относительных URL в полные для Telegram
|
||||
• Улучшена обработка ошибок
|
||||
|
||||
✅ 2. Создана страница CommentsPage
|
||||
• Отдельная страница для комментариев
|
||||
• Пост дублируется на странице
|
||||
• Кнопка "Назад" для возврата
|
||||
• Нет проблем с прыганием!
|
||||
|
||||
✅ 3. Создана страница PostMenuPage
|
||||
• Отдельная страница для меню поста
|
||||
• Пост дублируется на странице
|
||||
• Кнопка "Назад" для возврата
|
||||
• Нет проблем с прыганием!
|
||||
|
||||
✅ 4. Добавлены маршруты в App.jsx
|
||||
• /post/:postId/comments - страница комментариев
|
||||
• /post/:postId/menu - страница меню поста
|
||||
|
||||
✅ 5. Обновлен PostCard.jsx
|
||||
• Навигация на страницы вместо модальных окон
|
||||
• Убраны импорты PostMenu и CommentsModal
|
||||
|
||||
|
||||
ИЗМЕНЕННЫЕ ФАЙЛЫ:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Backend:
|
||||
• backend/bot.js
|
||||
|
||||
Frontend:
|
||||
• frontend/src/App.jsx
|
||||
• frontend/src/components/PostCard.jsx
|
||||
• frontend/src/pages/CommentsPage.jsx (новый)
|
||||
• frontend/src/pages/CommentsPage.css (новый)
|
||||
• frontend/src/pages/PostMenuPage.jsx (новый)
|
||||
• frontend/src/pages/PostMenuPage.css (новый)
|
||||
|
||||
|
||||
ОБНОВЛЕНИЕ (8 файлов):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
cd /Users/glpshchn/Desktop/nakama
|
||||
|
||||
# Backend
|
||||
scp backend/bot.js root@ваш_IP:/var/www/nakama/backend/
|
||||
|
||||
# Frontend
|
||||
scp frontend/src/App.jsx frontend/src/components/PostCard.jsx root@ваш_IP:/var/www/nakama/frontend/src/
|
||||
scp frontend/src/components/PostCard.jsx root@ваш_IP:/var/www/nakama/frontend/src/components/
|
||||
scp frontend/src/pages/CommentsPage.jsx frontend/src/pages/CommentsPage.css root@ваш_IP:/var/www/nakama/frontend/src/pages/
|
||||
scp frontend/src/pages/PostMenuPage.jsx frontend/src/pages/PostMenuPage.css root@ваш_IP:/var/www/nakama/frontend/src/pages/
|
||||
|
||||
# На сервере
|
||||
ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
|
||||
ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
|
||||
|
||||
|
||||
ЧТО ИСПРАВЛЕНО:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
1. ✅ Бот больше не выдает ошибку 404
|
||||
2. ✅ Комментарии на отдельной странице - НЕ ПРЫГАЮТ!
|
||||
3. ✅ Меню поста на отдельной странице - НЕ ПРЫГАЕТ!
|
||||
4. ✅ Пост дублируется на обеих страницах
|
||||
5. ✅ Кнопка "Назад" работает правильно
|
||||
|
||||
|
||||
5 минут
|
||||
|
||||
Loading…
Reference in New Issue