Compare commits

2 Commits

Author SHA1 Message Date
deea405092 Merge pull request 'perf: optimize image loading — resize, thumbnails, caching, lazy load' (#4) from claude/sleepy-shirley-adfe57 into main
Reviewed-on: #4
2026-06-26 10:09:12 +03:00
HeagBoKaT
63013816c8 perf: optimize image loading — resize, thumbnails, caching, lazy load
- Server: resize uploads to max 1200px + generate 600px _thumb.jpg
- Server: cache /uploads with max-age=1y immutable headers
- Client: CatCard uses _thumb.jpg in feed (fallback to full on error)
- Client: first 3 feed cards load eagerly (LCP), rest lazy + decoding=async
- Client: loading placeholder bg while image fetches
- Migration: server/src/scripts/gen-thumbs.ts for existing images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:55:49 +03:00
5 changed files with 73 additions and 9 deletions

View File

@@ -11,12 +11,18 @@ interface CatCardProps {
cat: Cat;
onOpen: (id: number) => void;
likedIds?: Set<number>;
priority?: boolean;
}
export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) {
export default function CatCard({ cat, onOpen, likedIds, priority }: CatCardProps) {
const { user } = useAuth();
const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false);
const [likeCount, setLikeCount] = useState(cat.likes_count);
const [imgLoaded, setImgLoaded] = useState(false);
const thumbSrc = cat.image_url.endsWith('.gif')
? cat.image_url
: cat.image_url.replace(/\.jpg$/, '_thumb.jpg');
const { toast } = useToast();
const likeMutation = useLikeCat(cat.id);
const unlikeMutation = useUnlikeCat(cat.id);
@@ -75,13 +81,16 @@ export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) {
</div>
{/* Изображение */}
<figure className="cat-card-image">
<figure className="cat-card-image relative min-h-[200px] bg-[var(--border-light)]">
<img
src={cat.image_url}
src={thumbSrc}
alt={cat.caption || 'Фото кота'}
className="w-full max-h-[540px] object-contain select-none mx-auto cat-card-img"
className={`w-full max-h-[540px] object-contain select-none mx-auto cat-card-img transition-opacity duration-300 ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
draggable={false}
loading="lazy"
loading={priority ? 'eager' : 'lazy'}
decoding="async"
onLoad={() => setImgLoaded(true)}
onError={e => { e.currentTarget.src = cat.image_url; }}
/>
</figure>

View File

@@ -103,8 +103,8 @@ export default function FeedPage() {
{allCats.length > 0 && (
<>
<div className="space-y-5">
{allCats.map((cat) => (
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
{allCats.map((cat, index) => (
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} priority={index < 3} />
))}
</div>

View File

@@ -60,7 +60,10 @@ async function main() {
return uploadLimiter(req, _res, next);
});
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads'), {
maxAge: '365d',
immutable: true,
}));
app.use('/api/auth', authRoutes);
app.use('/api/cats', catRoutes);

View File

@@ -127,8 +127,17 @@ router.post('/', authMiddleware, upload.single('image'), async (req: AuthRequest
finalFilename = req.file.filename.replace(/\.[^.]+$/, '.jpg');
finalPath = path.join(uploadsDir, finalFilename);
try {
await sharp(origPath).jpeg({ quality: 85 }).toFile(finalPath);
await sharp(origPath)
.resize({ width: 1200, withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(finalPath);
if (origPath !== finalPath) fs.unlinkSync(origPath);
const thumbFilename = finalFilename.replace(/\.jpg$/, '_thumb.jpg');
await sharp(finalPath)
.resize({ width: 600, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(path.join(uploadsDir, thumbFilename));
} catch {
try { fs.unlinkSync(origPath); } catch {}
res.status(400).json({ error: 'Не удалось обработать изображение. Попробуйте другой формат.' });

View File

@@ -0,0 +1,43 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
async function main() {
const files = fs.readdirSync(uploadsDir);
const candidates = files.filter(
f => f.endsWith('.jpg') && !f.endsWith('_thumb.jpg')
);
console.log(`Found ${candidates.length} images to process`);
let created = 0;
let skipped = 0;
for (const filename of candidates) {
const thumbFilename = filename.replace(/\.jpg$/, '_thumb.jpg');
const thumbPath = path.join(uploadsDir, thumbFilename);
if (fs.existsSync(thumbPath)) {
skipped++;
continue;
}
try {
await sharp(path.join(uploadsDir, filename))
.resize({ width: 600, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumbPath);
created++;
console.log(`${thumbFilename}`);
} catch (err) {
console.error(`${filename}: ${err}`);
}
}
console.log(`\nDone: ${created} created, ${skipped} already existed`);
}
main().catch(console.error);