feat: infinite scroll, HEIC support, likes fix, security & backup

- Fix liked hearts not showing after page reload (sync useEffect in CatCard)
- Replace pagination with infinite scroll (useInfiniteQuery + IntersectionObserver)
- Add HEIC/HEIF/AVIF image support with sharp conversion to JPEG on upload
- Validate MIME type alongside file extension to prevent spoofing
- Limit caption to 500 chars; increase upload size limit to 20MB
- Delete physical file from uploads/ when post is removed (cats + admin routes)
- Add WAL journal mode and graceful shutdown (SIGTERM/SIGINT) to db
- Add rate limiter for upload endpoint (10 req / 15 min)
- Add pre-deploy backup of data.db and uploads/ to deploy.sh (keeps last 10)
- Add plan_modernization.md with ideas for future improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:18:46 +03:00
parent 81c9bba56f
commit 94fff09a41
11 changed files with 290 additions and 65 deletions

View File

@@ -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);

View File

@@ -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<number, { username: string; points: number; avatar_url: string | null }>();
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())

View File

@@ -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,
});
}

View File

@@ -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<number | null>(null);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const { data, isLoading, isError, refetch } = useCats(page);
const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useCats();
const { user } = useAuth();
const sentinelRef = useRef<HTMLDivElement | null>(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 (
<div className="mx-auto max-w-[900px] px-5 py-5">
@@ -72,7 +86,7 @@ export default function FeedPage() {
</div>
)}
{data && data.cats.length === 0 && (
{!isLoading && allCats.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center animate-fade-up">
<div className="mb-4 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent-light)]">
<Cat className="h-7 w-7 text-[var(--accent)]" strokeWidth={1.5} />
@@ -86,37 +100,25 @@ export default function FeedPage() {
</div>
)}
{data && data.cats.length > 0 && (
{allCats.length > 0 && (
<>
<div className="space-y-5">
{data.cats.map(cat => (
{allCats.map((cat) => (
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
))}
</div>
{data.totalPages > 1 && (
<nav className="flex items-center justify-center gap-2 mt-8 pb-6">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
className="rounded-xl text-xs h-9 px-3 gap-1">
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
</Button>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-4" />
<div className="flex gap-1">
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => i + 1).map(p => (
<button key={p} onClick={() => setPage(p)}
className={`h-9 min-w-[36px] rounded-xl text-xs font-medium transition-all ${
p === page ? 'bg-[var(--accent)] text-white shadow-sm' : 'text-[var(--fg-tertiary)] hover:bg-[var(--border-light)]'
}`}>
{p}
</button>
))}
</div>
{isFetchingNextPage && (
<div className="flex justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-[var(--fg-tertiary)]" strokeWidth={1.5} />
</div>
)}
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page >= data.totalPages}
className="rounded-xl text-xs h-9 px-3 gap-1">
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
</Button>
</nav>
{!hasNextPage && allCats.length > 0 && (
<p className="text-center text-xs text-[var(--fg-tertiary)] py-6">Всё загружено </p>
)}
</>
)}
@@ -130,4 +132,4 @@ export default function FeedPage() {
{modalCatId && <CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />}
</div>
);
}
}