diff --git a/client/src/components/CatCard.tsx b/client/src/components/CatCard.tsx index a6446ac..139522c 100644 --- a/client/src/components/CatCard.tsx +++ b/client/src/components/CatCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Cat } from '@/api/endpoints'; import { useAuth } from '@/hooks/useAuth'; @@ -17,6 +17,10 @@ export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) { const { user } = useAuth(); const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false); const [likeCount, setLikeCount] = useState(cat.likes_count); + + useEffect(() => { + if (likedIds !== undefined) setLiked(likedIds.has(cat.id)); + }, [likedIds, cat.id]); const { toast } = useToast(); const likeMutation = useLikeCat(cat.id); const unlikeMutation = useUnlikeCat(cat.id); diff --git a/client/src/components/FeedSidebar.tsx b/client/src/components/FeedSidebar.tsx index e319645..d8220b8 100644 --- a/client/src/components/FeedSidebar.tsx +++ b/client/src/components/FeedSidebar.tsx @@ -13,14 +13,13 @@ function RankBadge({ rank }: { rank: number }) { export default function FeedSidebar() { const { user } = useAuth(); - const { data } = useCats(1); + const { data } = useCats(); const usersMap = new Map(); - if (data?.cats) { - for (const cat of data.cats) { - if (!usersMap.has(cat.user_id)) - usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url }); - } + const firstPageCats = data?.pages[0]?.cats ?? []; + for (const cat of firstPageCats) { + if (!usersMap.has(cat.user_id)) + usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url }); } const topUsers = Array.from(usersMap.entries()) diff --git a/client/src/hooks/useCats.ts b/client/src/hooks/useCats.ts index 9e46a60..e6d4961 100644 --- a/client/src/hooks/useCats.ts +++ b/client/src/hooks/useCats.ts @@ -1,10 +1,13 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getCats, getUserCats, getCat, uploadCat, deleteCat } from '@/api/endpoints'; -export function useCats(page: number) { - return useQuery({ - queryKey: ['cats', page], - queryFn: () => getCats(page).then((r) => r.data), +export function useCats() { + return useInfiniteQuery({ + queryKey: ['cats'], + queryFn: ({ pageParam }) => getCats(pageParam as number).then((r) => r.data), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, }); } diff --git a/client/src/pages/FeedPage.tsx b/client/src/pages/FeedPage.tsx index c380c20..c60b2f5 100644 --- a/client/src/pages/FeedPage.tsx +++ b/client/src/pages/FeedPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useCats } from '@/hooks/useCats'; import CatCard from '@/components/CatCard'; import FeedSidebar from '@/components/FeedSidebar'; @@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal'; import { Button } from '@/components/ui/button'; import { getBatchLikeStatus } from '@/api/endpoints'; import { useAuth } from '@/hooks/useAuth'; -import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react'; +import { RefreshCw, Cat, Loader2 } from 'lucide-react'; function FeedSkeleton() { return ( @@ -32,26 +32,40 @@ function FeedSkeleton() { } export default function FeedPage() { - const [page, setPage] = useState(1); const [modalCatId, setModalCatId] = useState(null); const [likedIds, setLikedIds] = useState>(new Set()); - const { data, isLoading, isError, refetch } = useCats(page); + const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useCats(); const { user } = useAuth(); + const sentinelRef = useRef(null); + + const allCats = data?.pages.flatMap((p) => p.cats) ?? []; + + // Load like statuses for newly fetched cats + useEffect(() => { + if (!allCats.length || !user) return; + const ids = allCats.map((c) => c.id); + getBatchLikeStatus(ids) + .then((r) => setLikedIds(new Set(r.data.likedIds))) + .catch(() => {}); + }, [data?.pages.length, user]); + + // Infinite scroll via IntersectionObserver + const onSentinelVisible = useCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ); useEffect(() => { - if (!data?.cats?.length || !user) return; - const ids = data.cats.map(c => c.id); - getBatchLikeStatus(ids) - .then(r => setLikedIds(new Set(r.data.likedIds))) - .catch(() => {}); - }, [data?.cats, user]); - - const handleUnlike = (catId: number) => { - setLikedIds(prev => { const n = new Set(prev); n.delete(catId); return n; }); - }; - const handleLike = (catId: number) => { - setLikedIds(prev => { const n = new Set(prev); n.add(catId); return n; }); - }; + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver(onSentinelVisible, { rootMargin: '200px' }); + observer.observe(el); + return () => observer.disconnect(); + }, [onSentinelVisible]); return (
@@ -72,7 +86,7 @@ export default function FeedPage() {
)} - {data && data.cats.length === 0 && ( + {!isLoading && allCats.length === 0 && (
@@ -86,37 +100,25 @@ export default function FeedPage() {
)} - {data && data.cats.length > 0 && ( + {allCats.length > 0 && ( <>
- {data.cats.map(cat => ( + {allCats.map((cat) => ( ))}
- {data.totalPages > 1 && ( - + {!hasNextPage && allCats.length > 0 && ( +

Всё загружено ✓

)} )} @@ -130,4 +132,4 @@ export default function FeedPage() { {modalCatId && setModalCatId(null)} />}
); -} \ No newline at end of file +} diff --git a/deploy.sh b/deploy.sh index fa061cb..e765ff2 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,36 @@ #!/bin/bash set -e +BACKUP_DIR="$HOME/cats-backups/$(date +%Y-%m-%d_%H-%M-%S)" + +echo "==> Creating backup at $BACKUP_DIR ..." +mkdir -p "$BACKUP_DIR" + +# Backup database +if [ -f server/data.db ]; then + cp server/data.db "$BACKUP_DIR/data.db" + echo " ✓ data.db backed up" +else + echo " ⚠ data.db not found, skipping" +fi + +# Backup uploads +if [ -d server/uploads ] && [ "$(ls -A server/uploads 2>/dev/null)" ]; then + cp -r server/uploads "$BACKUP_DIR/uploads" + echo " ✓ uploads/ backed up ($(ls server/uploads | wc -l) files)" +else + echo " ⚠ uploads/ empty or not found, skipping" +fi + +# Keep only last 10 backups +BACKUP_ROOT="$HOME/cats-backups" +BACKUP_COUNT=$(ls -1 "$BACKUP_ROOT" | wc -l) +if [ "$BACKUP_COUNT" -gt 10 ]; then + ls -1t "$BACKUP_ROOT" | tail -n +11 | xargs -I{} rm -rf "$BACKUP_ROOT/{}" + echo " ✓ Old backups pruned (kept last 10)" +fi + +echo "" echo "==> Pulling latest changes..." git pull @@ -11,10 +41,12 @@ echo "==> Installing client dependencies..." npm install --prefix client echo "==> Building client..." -npm run build +npm run build --prefix client echo "==> Restarting PM2 process..." sudo pm2 restart cats sudo pm2 save +echo "" echo "==> Done! Site updated at http://cats.heagbokat-server.ru" +echo " Backup saved to: $BACKUP_DIR" diff --git a/plan_modernization.md b/plan_modernization.md new file mode 100644 index 0000000..7193a65 --- /dev/null +++ b/plan_modernization.md @@ -0,0 +1,100 @@ +# Котограм — план модернизации + +Идеи для последующих доработок, сгруппированные по направлениям. + +--- + +## Функционал + +### Социальное взаимодействие +- **Подписки на пользователей** — лента только из постов тех, на кого подписан; отдельная вкладка «Все». +- **Реакции вместо лайков** — несколько эмодзи (❤️ 😂 😮 😢 🔥), каждый даёт разное кол-во очков. +- **Репосты / поделиться** — пользователь может поделиться ссылкой на пост или репостнуть к себе в профиль. +- **Упоминания @username** в комментариях с уведомлением (push или in-app badge). +- **Теги #хэштеги** на фотографиях и фильтрация ленты по тегу. + +### Контент +- **Сторис (истории)** — публикация на 24 часа, круговые аватарки с индикатором непросмотренной истории. +- **Карусель фотографий** — несколько фото в одном посте (swipe). +- **Видео поддержка** — короткие видео до 30 сек (mp4, mov), хранение в `uploads/`. +- **Черновики** — сохранить пост не публикуя. +- **Редактирование подписи** к своему посту. + +### Профиль +- **Bio / описание** — поле до 150 символов в профиле. +- **Верификация** — значок ✓ для доверенных аккаунтов, назначается администратором. +- **Статистика профиля** — охват, просмотры за 7/30 дней. +- **Закреплённые посты** — возможность закрепить 1 пост вверху профиля. + +### Поиск и открытие +- **Страница «Обзор»** — сетка популярных постов (по лайкам за 7 дней) как на вкладке «Explore» в Instagram. +- **Поиск пользователей** по username. +- **Лента трендов** — ТОП-10 котов недели в sidebar. + +### Уведомления +- **In-app уведомления** — новый лайк, комментарий, подписчик (real-time через WebSocket или polling). +- **Push-уведомления** (PWA) — с разрешения пользователя. + +--- + +## Интерфейс и UX + +### Навигация +- **Мобильный bottom navigation bar** — иконки Лента / Поиск / Загрузить / Уведомления / Профиль. +- **Жест swipe** между постами на мобильном (как в Instagram Stories). +- **Keyboard shortcuts** для десктопа: `L` — лайк, `Esc` — закрыть модал, стрелки — следующий/предыдущий пост. + +### Карточка поста +- **Двойной тап — лайк** (как в Instagram), с анимацией большого сердца поверх фото. +- **Lazy loading** с blur placeholder пока грузится изображение. +- **Zoom** по клику/пинчу на фото. + +### Профиль +- **Grid-режим** фотографий (3 колонки) как в Instagram по умолчанию, переключатель на список. +- **Анимированный аватар** — возможность загрузить gif. + +### Тёмная / светлая тема +- Тема уже есть. Добавить **«Авто» по системной теме** (уже `prefers-color-scheme`, но сделать переключатель 3-позиционный). + +### Доступность +- Описания (`alt` тексты) генерировать через API Claude Vision как подсказку при загрузке. +- Навигация с клавиатуры для всех интерактивных элементов. + +--- + +## Производительность + +- **CDN для изображений** — вынести `uploads/` на Cloudflare R2 / S3, раздавать через CDN. +- **WebP конвертация** — уже есть sharp; конвертировать в WebP вместо JPEG (меньше размер при том же качестве), отдавать JPEG как fallback. +- **Мигрировать с sql.js на better-sqlite3** — нативный SQLite, не держит весь файл БД в RAM, в 10-20x быстрее на записи. +- **Redis кэш** для топа пользователей и трендовых постов (sidebar), TTL 5 минут. +- **Виртуальный список** (react-virtual / @tanstack/virtual) в ленте при 100+ постов — не рендерить вне viewport. + +--- + +## Безопасность (дополнительно) + +- **2FA / TOTP** — опционально для аккаунтов. +- **Чёрный список слов** в комментариях (модерация). +- **Жалоба на пост** — кнопка «Пожаловаться», уведомление администратору. +- **Автоматическое удаление сессий** — логаут всех активных сессий при смене пароля. +- **Сканирование загружаемых файлов** на вирусы через ClamAV (опционально). + +--- + +## Инфраструктура + +- **Docker** — `docker-compose.yml` для локальной разработки и VPS деплоя. +- **GitHub Actions CI** — автоматический билд и тест при push. +- **Автоматические бэкапы БД** — cron на сервере каждые 6 часов копирует `data.db` в `/backups/`. +- **Health-check endpoint** уже есть (`/api/health`). Подключить к uptime-мониторингу (UptimeRobot / Betterstack). +- **Логирование** — структурированные JSON-логи через `pino` или `winston` вместо `console.log`. + +--- + +## Геймификация и монетизация + +- **Рейтинговые лиги** — Bronze / Silver / Gold / Diamond по кол-ву очков. +- **Достижения** — «Первый кот», «100 лайков», «Месяц на сайте» и т.д. с иконками-бейджами в профиле. +- **Ежедневные задания** — «Поставь 5 лайков сегодня» за бонусные очки. +- **Донаты / супер-лайки** — платный лайк с особым отображением (опционально через Stripe/YooKassa). diff --git a/server/package.json b/server/package.json index 14f10b8..9634393 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "helmet": "^8.2.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "sharp": "^0.33.0", "sql.js": "^1.14.1" }, "devDependencies": { diff --git a/server/src/db.ts b/server/src/db.ts index 6c96536..e5ffa89 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -20,6 +20,7 @@ export async function initDb(): Promise { } db.run('PRAGMA foreign_keys = ON'); + db.run('PRAGMA journal_mode = WAL'); db.run(` CREATE TABLE IF NOT EXISTS users ( @@ -107,6 +108,15 @@ export function queryAll(sql: string, params: any[] = []): any[] { return results; } +export function setupGracefulShutdown(): void { + const handler = () => { + saveDb(); + process.exit(0); + }; + process.on('SIGTERM', handler); + process.on('SIGINT', handler); +} + export function execute(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } { const stmt = db.prepare(sql); stmt.bind(params); diff --git a/server/src/index.ts b/server/src/index.ts index 0c68897..dc09d9e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,7 @@ import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import path from 'path'; import { fileURLToPath } from 'url'; -import { initDb } from './db.js'; +import { initDb, setupGracefulShutdown } from './db.js'; import authRoutes from './routes/auth.js'; import catRoutes from './routes/cats.js'; import likeRoutes from './routes/likes.js'; @@ -22,6 +22,7 @@ if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEV_JWT_SECRET) { async function main() { await initDb(); + setupGracefulShutdown(); const app = express(); const PORT = parseInt(process.env.PORT || '3040'); @@ -46,6 +47,15 @@ async function main() { app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); + const uploadLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { error: 'Слишком много загрузок, подождите 15 минут' }, + standardHeaders: true, + legacyHeaders: false, + }); + app.use('/api/cats', uploadLimiter); + app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads'))); app.use('/api/auth', authRoutes); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 756b16f..093f6cc 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,7 +1,13 @@ import { Router, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { queryAll, queryOne, execute } from '../db'; import { authMiddleware, adminOnly, AuthRequest } from '../middleware/auth.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const uploadsDir = path.join(__dirname, '..', '..', 'uploads'); + const router = Router(); router.use(authMiddleware, adminOnly); @@ -34,9 +40,16 @@ router.delete('/users/:id', (req: AuthRequest, res: Response) => { const user = queryOne('SELECT id, username, is_admin FROM users WHERE id = ?', [userId]); if (!user) { res.status(404).json({ error: 'User not found' }); return; } if (user.is_admin) { res.status(400).json({ error: 'Cannot delete admin' }); return; } + const userCats = queryAll('SELECT image_url FROM cats WHERE user_id = ?', [userId]); + execute('DELETE FROM comments WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]); execute('DELETE FROM likes WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]); execute('DELETE FROM cats WHERE user_id = ?', [userId]); execute('DELETE FROM users WHERE id = ?', [userId]); + for (const cat of userCats) { + if (cat.image_url) { + try { fs.unlinkSync(path.join(uploadsDir, path.basename(cat.image_url))); } catch {} + } + } res.json({ ok: true }); }); @@ -63,10 +76,14 @@ router.put('/cats/:id/caption', (req: AuthRequest, res: Response) => { }); router.delete('/cats/:id', (req: AuthRequest, res: Response) => { - const cat = queryOne('SELECT id FROM cats WHERE id = ?', [req.params.id]); + const cat = queryOne('SELECT id, image_url FROM cats WHERE id = ?', [req.params.id]); if (!cat) { res.status(404).json({ error: 'Cat not found' }); return; } + execute('DELETE FROM comments WHERE cat_id = ?', [req.params.id]); execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]); execute('DELETE FROM cats WHERE id = ?', [req.params.id]); + if (cat.image_url) { + try { fs.unlinkSync(path.join(uploadsDir, path.basename(cat.image_url))); } catch {} + } res.json({ ok: true }); }); diff --git a/server/src/routes/cats.ts b/server/src/routes/cats.ts index 9b0001c..b6cd6c9 100644 --- a/server/src/routes/cats.ts +++ b/server/src/routes/cats.ts @@ -1,28 +1,43 @@ import { Router, Response } from 'express'; import multer from 'multer'; import path from 'path'; +import fs from 'fs'; import { fileURLToPath } from 'url'; +import sharp from 'sharp'; import { queryOne, queryAll, execute } from '../db'; import { authMiddleware, AuthRequest } from '../middleware/auth.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const uploadsDir = path.join(__dirname, '..', '..', 'uploads'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.avif']); +const ALLOWED_MIMES = new Set([ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'image/heic', 'image/heif', 'image/avif', +]); + const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadsDir), filename: (_req, file, cb) => { - const ext = path.extname(file.originalname); + const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); }, }); const upload = multer({ storage, - limits: { fileSize: 10 * 1024 * 1024 }, + limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { - const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const ext = path.extname(file.originalname).toLowerCase(); - cb(null, allowed.includes(ext)); + if (!ALLOWED_EXTENSIONS.has(ext) || !ALLOWED_MIMES.has(file.mimetype)) { + cb(new Error('Неподдерживаемый формат файла')); + return; + } + cb(null, true); }, }); @@ -88,14 +103,40 @@ router.get('/:id', (req: AuthRequest, res: Response) => { res.json({ cat }); }); -router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res: Response) => { +router.post('/', authMiddleware, upload.single('image'), async (req: AuthRequest, res: Response) => { if (!req.file) { - res.status(400).json({ error: 'Нужно выбрать изображение (jpg, png, gif, webp, до 10MB)' }); + res.status(400).json({ error: 'Нужно выбрать изображение (jpg, png, gif, webp, heic, avif, до 20MB)' }); return; } const caption = (req.body.caption || '').trim(); - const image_url = `/uploads/${req.file.filename}`; + if (caption.length > 500) { + fs.unlinkSync(req.file.path); + res.status(400).json({ error: 'Подпись не может быть длиннее 500 символов' }); + return; + } + + const origPath = req.file.path; + const origExt = path.extname(req.file.filename).toLowerCase(); + const isGif = origExt === '.gif'; + + let finalFilename = req.file.filename; + let finalPath = origPath; + + if (!isGif) { + finalFilename = req.file.filename.replace(/\.[^.]+$/, '.jpg'); + finalPath = path.join(uploadsDir, finalFilename); + try { + await sharp(origPath).jpeg({ quality: 85 }).toFile(finalPath); + if (origPath !== finalPath) fs.unlinkSync(origPath); + } catch { + try { fs.unlinkSync(origPath); } catch {} + res.status(400).json({ error: 'Не удалось обработать изображение. Попробуйте другой формат.' }); + return; + } + } + + const image_url = `/uploads/${finalFilename}`; execute( 'INSERT INTO cats (user_id, image_url, caption) VALUES (?, ?, ?)', @@ -119,7 +160,7 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res: }); router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => { - const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [req.params.id]); + const cat = queryOne('SELECT user_id, image_url FROM cats WHERE id = ?', [req.params.id]); if (!cat) { res.status(404).json({ error: 'Cat not found' }); return; @@ -132,6 +173,12 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => { execute('DELETE FROM comments WHERE cat_id = ?', [req.params.id]); execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]); execute('DELETE FROM cats WHERE id = ?', [req.params.id]); + + if (cat.image_url) { + const filePath = path.join(uploadsDir, path.basename(cat.image_url)); + try { fs.unlinkSync(filePath); } catch {} + } + res.json({ ok: true }); });