Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Optimize/proxy oversized third-party event images

Status: needs-triage

## Context

PageSpeed Insights (via Google Search Console, 2026-07-17) flagged "Çok büyük
ağ yükleri" on both desktop (~6.9 MB total) and mobile (~10.1 MB total) for
`https://eventradar.dev/`. The large payload is almost entirely event
thumbnail images pulled directly from source platforms and rendered
unmodified in `EventCard` (`frontend/src/components/EventCard.jsx`):

- `cdn.prod.website-files.com` — two PNGs at 2.0 MB and 1.35 MB
- `api.ibb.gov.tr` — up to four PNGs, largest at 0.9-2.3 MB
- `learn.pupilica.com` — several images 300-480 KB each
- `framerusercontent.com`, S3 buckets — additional 100 KB-1 MB images

These are `event.image_url` values scraped as-is from each source
(`app/scrapers/*.py`) and stored in the `events.image_url` column
(`app/models/event.py`). The frontend already displays them at a fixed
200px-tall card thumbnail (`.event-image` in `frontend/src/index.css`) with
`loading="lazy"`, but the full multi-megabyte original is still downloaded —
lazy-loading only delays the request, it doesn't shrink it.

## Why this is a separate issue

Fixing this requires either:
1. An image proxy/resizing service (e.g. an on-the-fly resize endpoint in the
FastAPI backend, or a CDN-level image transform) that re-encodes source
images to a card-appropriate size before serving them, or
2. Downloading and caching a resized copy at scrape time in
`app/scrapers/*.py` / `app/services/scraper_service.py`.

Both are a real backend/infra design decision (storage, cache invalidation,
handling source images disappearing) — out of scope for the nginx/CSS/CLS
fixes already shipped in `fix/google-search-console`.

## Suggested approach (not decided)

- Add a lightweight resize step in the scraper pipeline that downloads
`image_url`, re-encodes to e.g. 400x200 WebP, and stores it (S3/local
volume) behind the app's own domain — this also removes the current
dependency on third-party hosts staying up.
- Alternatively, front `image_url` with a resize proxy
(`/api/img?url=...&w=400&h=200`) that streams a transformed image with
caching headers.

## Acceptance criteria

- [ ] Event card thumbnails on the homepage/listing pages are served at a
size proportionate to their 400x200 display box (not multi-MB
originals).
- [ ] Total network payload for the homepage drops meaningfully in a fresh
PageSpeed Insights run (large network payload audit).
- [ ] Broken/unreachable source images still fall back to
`/placeholder-image-colored.webp` (existing `onError` behavior in
`EventCard.jsx` must keep working).
22 changes: 19 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,25 @@
<meta name="twitter:image" content="https://eventradar.dev/banner.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<!--
Bootstrap/FontAwesome/Google Fonts are non-critical for first paint
(the hero renders from custom CSS, not Bootstrap grid classes) but were
loaded as blocking <link rel="stylesheet"> tags, which PageSpeed Insights
flagged as the dominant contributor to LCP under mobile/slow-4G
throttling (~2.5s of the critical path). Loading them as preload+swap
keeps them off the critical rendering path; the <noscript> fallback
covers non-JS clients.
-->
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" as="style" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" as="style" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
</noscript>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
6 changes: 6 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ server {
proxy_set_header X-Real-IP $remote_addr;
}

# Legacy URL indexed by Google before the site restructure; redirect
# instead of soft-404ing through the SPA fallback.
location = /events {
return 301 /;
}

# SPA fallback — all unmatched routes serve index.html
location / {
try_files $uri $uri/ /index.html;
Expand Down
Binary file modified frontend/public/techeventradar_logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions frontend/src/components/EventListing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,9 @@ const EventListing = ({
>
<i className="fas fa-plus"></i>
</div>
<h5 style={{ fontWeight: 700, marginBottom: '0.5rem' }}>
<h4 style={{ fontWeight: 700, marginBottom: '0.5rem' }}>
Bir etkinlik mi kaçırdık?
</h5>
</h4>
<p className="text-muted" style={{ fontSize: '0.9rem', marginBottom: '1.25rem' }}>
Eklenmesini istediğiniz bir etkinlik varsa bize bildirin.
</p>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const Header = () => {
<header className="main-header">
<div className="container header-shell">
<Link to="/" className="logo-link" aria-label="TechEventRadar anasayfa">
<img src="/techeventradar_logo.png" alt="" className="header-logo" width="36" height="36" />
<img src="/techeventradar_logo.png" alt="" className="header-logo" width="69" height="36" />
<span className="logo-text gradient-text">TechEventRadar</span>
</Link>

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
--bg-input: #1E293B;
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--text-muted: #8291A8;
--action-primary: #38BDF8;
--action-primary-dark: #0EA5E9;
--accent-purple: #818CF8;
Expand Down Expand Up @@ -1136,7 +1136,7 @@ h1, h2, h3, h4, h5, h6 {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
min-height: 60vh;
}

.spinner-ring {
Expand Down
26 changes: 22 additions & 4 deletions frontend/src/pages/EventDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ const EventDetailPage = () => {
canonical.setAttribute('href', canonicalUrl)

// JSON-LD Event schema
let organizerUrl
try {
organizerUrl = new URL(event.url).origin
} catch {
organizerUrl = undefined
}

const schema = {
'@context': 'https://schema.org',
'@type': 'Event',
Expand All @@ -91,10 +98,21 @@ const EventDetailPage = () => {
url: `https://eventradar.dev/etkinlik/${id}`,
isAccessibleForFree: true,
eventStatus: 'https://schema.org/EventScheduled',
...(event.location && {
location: { '@type': 'Place', name: event.location },
}),
organizer: { '@type': 'Organization', name: event.source },
location: event.location
? { '@type': 'Place', name: event.location }
: { '@type': 'VirtualLocation', url: event.url },
organizer: {
'@type': 'Organization',
name: event.source,
...(organizerUrl && { url: organizerUrl }),
},
offers: {
'@type': 'Offer',
price: 0,
priceCurrency: 'TRY',
url: event.url,
availability: 'https://schema.org/InStock',
},
}
document.getElementById('event-jsonld')?.remove()
const script = document.createElement('script')
Expand Down
30 changes: 29 additions & 1 deletion scripts/nginx_optimization.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
# tech-event-radar-nginx-config.conf
# Apply this configuration to your Nginx site file (e.g., /etc/nginx/sites-enabled/eventradar.dev)

# 0. Canonicalization — Google Search Console flagged eventradar.dev as
# serving duplicate content across http/https and www/non-www variants
# with no enforced canonical (only the rel=canonical hint in index.html,
# which Google may ignore without a real redirect). Collapse every
# variant to https://eventradar.dev with a 301 *before* it reaches the
# app server block below.

# www -> apex (any scheme)
server {
# ... existing configuration (listen, server_name, etc.) ...
listen 80;
listen 443 ssl;
server_name www.eventradar.dev;
# ... reuse existing ssl_certificate directives here ...
return 301 https://eventradar.dev$request_uri;
}

# http apex -> https apex
server {
listen 80;
server_name eventradar.dev;
return 301 https://eventradar.dev$request_uri;
}

server {
# ... existing configuration (listen 443 ssl, server_name eventradar.dev, etc.) ...

# 1. Gzip Compression
gzip on;
Expand Down Expand Up @@ -42,5 +65,10 @@ server {
add_header Content-Type application/xml;
}

# 4. Legacy URL indexed by Google before the site restructure
location = /events {
return 301 /;
}

# ... rest of your configuration (proxy_pass, etc.) ...
}
Loading