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:
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user