diff --git a/README.md b/README.md index 4f48ba7..c1cdb84 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,23 @@ npm run dev:photos # go run ./cmd/photo-api npm run build:photos # go build -o bin/photo-api npm run migrate:photos # apply photos_* migrations npm run test:photos # go test ./... + +# Move the photo library between storage backends (both must be configured +# in photo-api/.env; the source is left untouched, reruns skip what is there) +npm run sync:photos:to-s3 # local disk -> S3 +npm run sync:photos:to-local # S3 -> local disk +npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite + # --concurrency=N --gallery= + +# Uploads are deduplicated per gallery by content hash. Photos uploaded before +# that existed need hashing once (idempotent, deletes nothing): +npm run backfill:photos:checksums ``` +After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and +restart the service to serve from the new backend. See +[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends). + You can also run per workspace: ```bash @@ -117,6 +132,7 @@ Key settings (see `photo-api/.env.example`): - **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend - **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration). - **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs +- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first - **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped) ### Frontend (`frontend/.env`) diff --git a/deploy/photos.nginx.conf b/deploy/photos.nginx.conf new file mode 100644 index 0000000..8693202 --- /dev/null +++ b/deploy/photos.nginx.conf @@ -0,0 +1,109 @@ +# ============================================================ +# Spanglish Community - Photo Gallery API +# photos.spanglishcommunity.com +# ============================================================ + +server { + listen 80; + server_name photos.spanglishcommunity.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://photos.spanglishcommunity.com$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + + server_name photos.spanglishcommunity.com; + + # Photos can be larger than typical JSON payloads + client_max_body_size 25m; + + # SSL + ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Security + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logs + access_log /var/log/nginx/spanglish_photo_access.log; + error_log /var/log/nginx/spanglish_photo_error.log; + + # CORS Configuration + set $cors_origin ""; + if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") { + set $cors_origin $http_origin; + } + + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + # Ensure 413 returns JSON + CORS + error_page 413 = @payload_too_large; + location @payload_too_large { + default_type application/json; + return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; + } + + # Ensure 429 (rate limited) returns JSON + CORS + error_page 429 = @rate_limited; + location @rate_limited { + default_type application/json; + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + return 429 '{"error":"Too many requests. Please slow down."}'; + } + + location / { + limit_req zone=spanglish_photo_limit burst=40 nodelay; + + # Preflight + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://spanglish_photo_api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + proxy_hide_header 'Access-Control-Allow-Headers'; + proxy_hide_header 'Access-Control-Allow-Credentials'; + proxy_hide_header 'Access-Control-Expose-Headers'; + + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + + # Buffer large image uploads to disk rather than memory + proxy_request_buffering on; + proxy_max_temp_file_size 1024m; + } +} \ No newline at end of file diff --git a/deploy/prod_nginx/backend.nginx.conf b/deploy/prod_nginx/backend.nginx.conf new file mode 100644 index 0000000..fcb68da --- /dev/null +++ b/deploy/prod_nginx/backend.nginx.conf @@ -0,0 +1,146 @@ +# ============================================================ +# Spanglish Community - Backend API +# api.spanglishcommunity.com +# ============================================================ + +server { + listen 80; + server_name api.spanglishcommunity.com; + + # ACME + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Force HTTPS + location / { + return 301 https://api.spanglishcommunity.com$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + + server_name api.spanglishcommunity.com; + + # Upload size limit (avoid nginx 413 on media uploads) + # Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB). + client_max_body_size 20m; + + # SSL + ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Security (API) + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + + # Logs + access_log /var/log/nginx/spanglish_api_access.log; + error_log /var/log/nginx/spanglish_api_error.log; + + # CORS Configuration (set once, used everywhere) + set $cors_origin ""; + if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") { + set $cors_origin $http_origin; + } + + # Add CORS headers to all responses (including nginx-generated errors) + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + # Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked") + error_page 413 = @payload_too_large; + location @payload_too_large { + default_type application/json; + return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; + } + + # Photo gallery service (photo-api, port 3003). ^~ wins over the "/" + # prefix below, so /api/photos/* reaches the Go photo-api instead of the + # Node backend (which has no photo routes and would 404). The admin UI + # calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS + # must be handled here just like location / below. + location ^~ /api/photos/ { + limit_req zone=spanglish_api_limit burst=50 nodelay; + + # Handle preflight OPTIONS requests (add_header inside if{} does NOT + # inherit server-level headers, so repeat all CORS headers here). + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://spanglish_photo_api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Strip CORS headers from the service (nginx handles CORS here) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + proxy_hide_header 'Access-Control-Allow-Headers'; + proxy_hide_header 'Access-Control-Allow-Credentials'; + proxy_hide_header 'Access-Control-Expose-Headers'; + + # Photo batches can be large; allow bigger bodies + unbuffered upload. + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + + location / { + limit_req zone=spanglish_api_limit burst=50 nodelay; + + # Handle preflight OPTIONS requests + # NOTE: add_header inside if{} does NOT inherit server-level headers, + # so we must repeat all CORS headers here. + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://spanglish_backend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Strip CORS headers from backend (nginx handles CORS at server level) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + proxy_hide_header 'Access-Control-Allow-Headers'; + proxy_hide_header 'Access-Control-Allow-Credentials'; + proxy_hide_header 'Access-Control-Expose-Headers'; + + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } +} diff --git a/deploy/prod_nginx/frontend.nginx.conf b/deploy/prod_nginx/frontend.nginx.conf new file mode 100644 index 0000000..32c9500 --- /dev/null +++ b/deploy/prod_nginx/frontend.nginx.conf @@ -0,0 +1,115 @@ +# ============================================================ +# Spanglish Community - Frontend +# spanglishcommunity.com / www +# ============================================================ + +server { + listen 80; + server_name spanglishcommunity.com www.spanglishcommunity.com; + + # ACME + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Force HTTPS + location / { + return 301 https://spanglishcommunity.com$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + + server_name spanglishcommunity.com www.spanglishcommunity.com; + + # Upload size limit (covers same-origin /api uploads via this vhost) + client_max_body_size 20m; + + # SSL + ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Security + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logs + access_log /var/log/nginx/spanglish_frontend_access.log; + error_log /var/log/nginx/spanglish_frontend_error.log; + + # Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over + # the /api prefix below so same-origin image/gallery requests reach the + # photo-api instead of the Node backend (which has no photo routes -> 404). + location ^~ /api/photos/ { + proxy_pass http://spanglish_photo_api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Photo uploads/batches can be large; allow bigger bodies. + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + + # Let the photo-api set Cache-Control per image visibility (public vs. + # token-gated) rather than forcing a cache policy here. + } + + # Proxy /api to backend + location /api { + proxy_pass http://spanglish_backend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + + # Proxy /uploads to backend + location /uploads { + proxy_pass http://spanglish_backend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache static files + proxy_cache_valid 200 1d; + expires 1d; + add_header Cache-Control "public, immutable"; + } + + # Frontend App + location / { + proxy_pass http://spanglish_frontend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket / HMR + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 60s; + proxy_connect_timeout 60s; + } +} diff --git a/deploy/prod_nginx/photos.ngnx.conf b/deploy/prod_nginx/photos.ngnx.conf new file mode 100644 index 0000000..2897366 --- /dev/null +++ b/deploy/prod_nginx/photos.ngnx.conf @@ -0,0 +1,108 @@ +# ============================================================ +# Spanglish Community - Photo Gallery API +# photos.spanglishcommunity.com +# ============================================================ + +server { + listen 80; + server_name photos.spanglishcommunity.com; + + location /.well-known/acme-challenge/ { + } + + location / { + return 301 https://photos.spanglishcommunity.com$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + + server_name photos.spanglishcommunity.com; + + # Photos can be larger than typical JSON payloads + client_max_body_size 25m; + + # SSL + ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Security + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logs + access_log /var/log/nginx/spanglish_photo_access.log; + error_log /var/log/nginx/spanglish_photo_error.log; + + # CORS Configuration + set $cors_origin ""; + if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") { + set $cors_origin $http_origin; + } + + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + # Ensure 413 returns JSON + CORS + error_page 413 = @payload_too_large; + location @payload_too_large { + default_type application/json; + return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; + } + + # Ensure 429 (rate limited) returns JSON + CORS + error_page 429 = @rate_limited; + location @rate_limited { + default_type application/json; + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + return 429 '{"error":"Too many requests. Please slow down."}'; + } + + location / { + limit_req zone=spanglish_photo_limit burst=40 nodelay; + + # Preflight + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://spanglish_photo_api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + proxy_hide_header 'Access-Control-Allow-Headers'; + proxy_hide_header 'Access-Control-Allow-Credentials'; + proxy_hide_header 'Access-Control-Expose-Headers'; + + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + + # Buffer large image uploads to disk rather than memory + proxy_request_buffering on; + proxy_max_temp_file_size 1024m; + } +} diff --git a/deploy/prod_nginx/spanglish_upstreams.conf b/deploy/prod_nginx/spanglish_upstreams.conf new file mode 100644 index 0000000..a60eda8 --- /dev/null +++ b/deploy/prod_nginx/spanglish_upstreams.conf @@ -0,0 +1,12 @@ +upstream spanglish_frontend { + server 127.0.0.1:3019; +} +upstream spanglish_backend { + server 127.0.0.1:3018; +} +upstream spanglish_photo_api { + server 127.0.0.1:3003; +} + +limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s; +limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s; \ No newline at end of file diff --git a/frontend/src/app/(public)/events/[id]/gallery/loading.tsx b/frontend/src/app/(public)/events/[id]/gallery/loading.tsx new file mode 100644 index 0000000..facfe07 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/gallery/loading.tsx @@ -0,0 +1,6 @@ +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; + +// Shown while the server component fetches the event gallery. +export default function Loading() { + return ; +} diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx index 101025e..ea39a09 100644 --- a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -7,11 +7,17 @@ import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { photosApi, PhotoGallery, Photo } from '@/lib/api'; import Button from '@/components/ui/Button'; -import { ImageGridSkeleton } from '@/components/ui/Skeleton'; +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; +import PhotoTile from '@/components/gallery/PhotoTile'; +import { + GalleryContainer, + GalleryHeroFrame, + MasonryGrid, +} from '@/components/gallery/GalleryLayout'; +import { useDownloads } from '@/components/gallery/useDownloads'; import Lightbox from '@/components/Lightbox'; import LoginModal from '@/components/LoginModal'; import { - ArrowDownTrayIcon, CalendarIcon, CameraIcon, LinkIcon, @@ -42,6 +48,17 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien const [denied, setDenied] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); const [loginOpen, setLoginOpen] = useState(false); + const downloads = useDownloads(es); + const downloadLabels = { + download: es ? 'Descargar' : 'Download', + downloading: es ? 'Descargando…' : 'Downloading…', + }; + const downloadPhoto = (photo: Photo) => + downloads.start({ + id: photo.id, + url: photo.urls.original, + filename: photo.originalFilename, + }); // Server-rendered public galleries need no client fetch. Everything else // (link/ticket/private) is fetched here with the share token and/or the @@ -78,14 +95,10 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien }; }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); + // Mirrors the hero + masonry layout below, so the real page drops straight + // into the placeholder's geometry instead of replacing it. if (loading || (authLoading && !initial)) { - return ( -
-
- -
-
- ); + return ; } // Gate pages for restricted galleries. After a successful login in the @@ -254,47 +267,48 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien return (
- {/* Hero */} -
- {heroUrl && ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - -
- + {/* Hero — same frame the skeleton renders (GalleryLayout). */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ) : null + } + > +

+ {title} +

+ {description && ( +

{description}

)} -
-

- {title} -

- {description && ( -

{description}

+
+ + + {readyPhotos.length} {es ? 'fotos' : 'photos'} + + {gallery.event && ( + + + {eventTitle} + {eventDate && · {eventDate}} + )} -
- - - {readyPhotos.length} {es ? 'fotos' : 'photos'} - - {gallery.event && ( - - - {eventTitle} - {eventDate && · {eventDate}} - - )} -
-
+ {/* Masonry grid */} -
+ {readyPhotos.length === 0 ? (
@@ -303,46 +317,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien

) : ( -
+ {readyPhotos.map((photo, i) => ( -
setLightboxIndex(i)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setLightboxIndex(i); - } - }} - className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow" - style={ - photo.width && photo.height - ? { aspectRatio: `${photo.width} / ${photo.height}` } - : undefined - } - > - {/* eslint-disable-next-line @next/next/no-img-element */} - - + photo={photo} + eager={i < 8} + onOpen={() => setLightboxIndex(i)} + onDownload={() => downloadPhoto(photo)} + downloading={downloads.isPending(photo.id)} + labels={downloadLabels} + /> ))} -
+
)} {lightboxIndex !== null && ( @@ -351,9 +338,16 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien index={lightboxIndex} onClose={() => setLightboxIndex(null)} onNavigate={setLightboxIndex} + onDownload={(it) => + downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename }) + } + downloadingId={ + lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null + } + downloadLabels={downloadLabels} /> )} -
+
{/* Call to action: send attendees to their dashboard, everyone else to the next event. Auth state comes from the same useAuth() the gate diff --git a/frontend/src/app/(public)/photos/[slug]/loading.tsx b/frontend/src/app/(public)/photos/[slug]/loading.tsx new file mode 100644 index 0000000..c5ac403 --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/loading.tsx @@ -0,0 +1,7 @@ +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; + +// Shown while the server component fetches the gallery, so the first paint is +// already the gallery's layout rather than an empty page. +export default function Loading() { + return ; +} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 1b1cc04..4e5ed84 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; +import { usePrivacy } from '@/context/PrivacyContext'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; import { isManualProvider } from '@/lib/api/payments'; import { parseDate, formatRucDisplay } from '@/lib/utils'; @@ -36,6 +37,7 @@ type Tab = 'pending_approval' | 'all'; export default function AdminPaymentsPage() { const { t, locale } = useLanguage(); + const { privacyMode } = usePrivacy(); const [payments, setPayments] = useState([]); const [pendingApprovalPayments, setPendingApprovalPayments] = useState([]); // Manual-gateway payments still in bare 'pending': the customer may have paid @@ -778,6 +780,7 @@ export default function AdminPaymentsPage() { )} {/* Summary Cards */} + {!privacyMode && (
@@ -846,6 +849,7 @@ export default function AdminPaymentsPage() {
+ )} {/* Tabs */}
diff --git a/frontend/src/app/admin/photos/[id]/page.tsx b/frontend/src/app/admin/photos/[id]/page.tsx index 3f1dd3d..fff6d48 100644 --- a/frontend/src/app/admin/photos/[id]/page.tsx +++ b/frontend/src/app/admin/photos/[id]/page.tsx @@ -18,6 +18,7 @@ import { CheckCircleIcon, ChevronDownIcon, ChevronUpIcon, + DocumentDuplicateIcon, ExclamationCircleIcon, ExclamationTriangleIcon, LinkIcon, @@ -34,7 +35,9 @@ interface UploadItem { name: string; sizeBytes: number; progress: number; // 0..1 while uploading - status: 'queued' | 'uploading' | 'processing' | 'error'; + // 'duplicate' is terminal: the gallery already held these bytes, so nothing + // was stored and no new tile appears in the grid. + status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate'; error?: string; photoId?: string; } @@ -117,8 +120,19 @@ export default function AdminGalleryDetailPage() { const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) => patchUpload(key, { progress: fraction }) ); - setPhotos((prev) => [...prev, ...added]); - patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id }); + // A duplicate echoes back a photo already in the grid, so merge by id + // instead of appending (`duplicate` is an upload outcome, not photo + // state, so it is dropped here). + setPhotos((prev) => { + const byId = new Map(prev.map((p) => [p.id, p])); + added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo)); + return Array.from(byId.values()); + }); + patchUpload(key, { + status: added[0]?.duplicate ? 'duplicate' : 'processing', + progress: 1, + photoId: added[0]?.id, + }); } catch (err) { patchUpload(key, { status: 'error', @@ -148,7 +162,8 @@ export default function AdminGalleryDetailPage() { }; // A row is "done" once its photo finished processing; the panel derives - // this from the photos list instead of tracking it separately. + // this from the photos list instead of tracking it separately. 'duplicate' + // is terminal and never reconciled — its photo was already there. const displayStatus = (u: UploadItem): { state: string; error?: string } => { if (u.status === 'processing' && u.photoId) { const photo = photos.find((p) => p.id === u.photoId); @@ -579,6 +594,11 @@ export default function AdminGalleryDetailPage() { {ds.state === 'queued' && (es ? 'En cola' : 'Queued')} {ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')} {ds.state === 'done' && formatBytes(u.sizeBytes)} + {ds.state === 'duplicate' && ( + + {es ? 'Ya está en esta galería' : 'Already in this gallery'} + + )} {ds.state === 'error' && ( {ds.error} @@ -588,6 +608,9 @@ export default function AdminGalleryDetailPage() {
{ds.state === 'done' && } + {ds.state === 'duplicate' && ( + + )} {ds.state === 'error' && } {ds.state === 'processing' && (
diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx index d77fe56..d9aa42f 100644 --- a/frontend/src/components/Lightbox.tsx +++ b/frontend/src/components/Lightbox.tsx @@ -7,6 +7,7 @@ import { ChevronRightIcon, ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; +import Spinner from '@/components/ui/Spinner'; export interface LightboxItem { id: string; @@ -24,17 +25,35 @@ interface LightboxProps { onNavigate: (index: number) => void; /** Extra per-item action buttons rendered in the top bar (admin use). */ renderActions?: (item: LightboxItem, index: number) => React.ReactNode; + /** + * Handles the download in JS instead of navigating, so the button can show + * progress. Without it the button stays a plain . + */ + onDownload?: (item: LightboxItem) => void; + /** Id of the item currently downloading (pairs with onDownload). */ + downloadingId?: string | null; + downloadLabels?: { download: string; downloading: string }; } /** * Full-screen photo lightbox with keyboard and swipe navigation, in the * style of the admin gallery preview modal (fixed inset-0 bg-black/90). */ -export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { +export default function Lightbox({ + items, + index, + onClose, + onNavigate, + renderActions, + onDownload, + downloadingId, + downloadLabels, +}: LightboxProps) { const touchStart = useRef<{ x: number; y: number } | null>(null); const activeThumbRef = useRef(null); const item = items[index]; const hasMultiple = items.length > 1; + const downloading = !!downloadingId && item?.id === downloadingId; const prev = useCallback(() => { onNavigate(index > 0 ? index - 1 : items.length - 1); @@ -98,14 +117,37 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi className="absolute top-4 left-4 z-10 flex items-center gap-4" onClick={(e) => e.stopPropagation()} > - - - + {onDownload ? ( + + ) : ( + + + + )} {renderActions?.(item, index)}
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx new file mode 100644 index 0000000..37871f9 --- /dev/null +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -0,0 +1,46 @@ +// Layout shell shared by the public gallery page (GalleryClient) and its +// loading placeholder (GallerySkeleton). Column count, gaps, radii and +// container padding are defined once here, so the skeleton's geometry can +// never drift from the grid it stands in for. + +export function GalleryHeroFrame({ + children, + backdrop, +}: { + children: React.ReactNode; + /** Cover image + scrim, absolutely positioned behind the text. */ + backdrop?: React.ReactNode; +}) { + return ( +
+ {backdrop} +
{children}
+
+ ); +} + +export function GalleryContainer({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function MasonryGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// Per-tile geometry. Deliberately carries no background so callers can pick +// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without +// two `bg-*` utilities fighting over CSS order. +export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl'; + +/** + * Reserves the tile's aspect ratio up front so the image can load into a box + * that is already the right size — late arrivals never reflow the columns. + * Returns undefined when the photo has no stored dimensions. + */ +export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined { + return width && height ? { aspectRatio: `${width} / ${height}` } : undefined; +} diff --git a/frontend/src/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx new file mode 100644 index 0000000..60eccc4 --- /dev/null +++ b/frontend/src/components/gallery/GallerySkeleton.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx'; +import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton'; +import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout'; + +// Loading placeholder for the public gallery page. It renders through the same +// hero frame, container and masonry grid as the real page (see GalleryLayout), +// so replacing it with photos changes only the pixels inside the tiles. + +// Portrait/landscape/square mix standing in for a real event set. Fixed order +// on purpose — a random shuffle would differ between the server and client +// render and blow up hydration. +const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3]; + +interface GallerySkeletonProps { + /** Number of tiles to draw; ignored when `ratios` is given. */ + count?: number; + /** + * Real width/height ratios when the caller already knows them (e.g. a + * cached gallery payload). Produces a grid with exactly the right geometry + * instead of the guessed mix above. + */ + ratios?: number[]; +} + +export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) { + const tiles = + ratios && ratios.length > 0 + ? ratios + : Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]); + + return ( + + + {/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */} + +
+ + +
+
+ + + + {tiles.map((ratio, i) => ( +