Overview
Three main improvements in this update:
-
Blur placeholders for images: Added a
BlurImagecomponent that shows instant blurhash-decoded placeholders while full images load, with a fallback to tiny Supabase images for photos without blurhash data. -
Unified attendees/likes UI: Created a reusable
StackedAvatarsPopovercomponent for displaying stacked avatars with an expandable popover. RefactoredDetailLikesSectionand unifiedRecentEventsListintoEventsList. -
Image cleanup: Removed the legacy
image_urlcolumn from events, migrated all external event covers to Supabase storage, and adjusted image dimensions for better optimization.
Image Loading: BlurImage Component
The Problem
Images were loading with a blank space before appearing, which created a jarring experience. Photos in the database have blurhash data that could be used for instant placeholders, but it wasn't being utilized.
The Solution
File: src/components/shared/BlurImage.tsx
A wrapper around Next.js Image that:
- Instant blurhash placeholders: If
blurhashprop is provided, decodes it client-side to a data URL (no network request) - Supabase fallback: For images without blurhash, requests a tiny 32px version from Supabase
- Cache detection: Uses
useLayoutEffectto check if image is already cached, skipping the blur entirely - Smooth fade-in: Main image fades in over 200ms when loaded
// Check if image is already cached before first paint
useLayoutEffect(() => {
const img = imgRef.current;
if (img?.complete && img.naturalWidth > 0) {
setIsCached(true);
setIsLoaded(true);
}
}, [srcString]);
Blurhash Decoding
File: src/utils/decodeBlurhash.ts
Decodes blurhash strings to base64 data URLs using canvas:
export function blurhashToDataURL(
blurhash: string | null | undefined,
width: number = 32,
height: number = 32,
): string | null {
const pixels = decode(blurhash, width, height);
const canvas = document.createElement('canvas');
// ... draw pixels to canvas
return canvas.toDataURL();
}
Supabase Blur URL Helper
File: src/utils/supabaseImageLoader.ts
Added helper function to get tiny placeholder URLs:
export function getBlurPlaceholderUrl(src: string | null | undefined): string | null {
if (!src) return null;
const isSupabase = SUPABASE_DOMAINS.some(domain => src.includes(domain));
if (isSupabase) {
const url = new URL(src);
url.searchParams.set('width', '32');
url.searchParams.set('quality', '20');
return url.toString();
}
return null;
}
Components Updated
BlurImage is now used in:
AlbumCard.tsx- Album cover thumbnailsAlbumMiniCard.tsx- Mini album cardsPhotoCard.tsx- Photo management cardsPhotoListItem.tsx- Photo list itemsJustifiedPhotoGrid.tsx- Gallery photo grid (with blurhash)PhotoWithLightbox.tsx- Full photo view (with blurhash)EventImage.tsx- Event cover images
Unified Attendees/Likes UI
StackedAvatarsPopover
File: src/components/shared/StackedAvatarsPopover.tsx
A reusable component for displaying stacked avatars with an expandable popover:
interface StackedAvatarsPopoverProps {
people: AvatarPerson[];
singularLabel: string; // e.g., "attendee"
pluralLabel: string; // e.g., "attendees"
emptyMessage?: string;
showInlineCount?: boolean;
disablePopover?: boolean; // Just show avatars, no interaction
avatarSize?: keyof typeof SIZE_MAP;
}
Features:
- Stacked avatars with max 5 visible, +N indicator for more
- Clickable to expand popover with full list
- Each person links to their profile page
- Loading skeleton state
- Configurable avatar sizes via
avatarSizeprop - Optional popover disable for simpler display
DetailLikesSection Refactor
File: src/components/shared/DetailLikesSection.tsx
Major simplification - now uses StackedAvatarsPopover instead of custom implementation:
// Before: ~200 lines of custom popover logic
// After: Simple wrapper around StackedAvatarsPopover
<StackedAvatarsPopover
people={likerPeople}
singularLabel="like"
pluralLabel="likes"
emptyMessage="No likes yet"
/>
EventCard Attendees
File: src/components/events/EventCard.tsx
Added attendees display to event cards:
interface EventCardProps {
// ...
attendees?: EventAttendee[];
disableAttendeesPopover?: boolean; // For homepage (no popover)
}
Attendees show below the event info with stacked avatars. The popover can be disabled for contexts where interaction isn't desired (like the homepage).
EventsList Unification
File: src/components/events/EventsList.tsx
Merged RecentEventsList functionality into EventsList with a variant prop:
type EventsListVariant = 'full' | 'compact';
interface EventsListProps {
variant?: EventsListVariant; // 'full' for /events, 'compact' for homepage
max?: number; // Limit number of events
disableAttendeesPopover?: boolean;
avatarSize?: keyof typeof SIZE_MAP;
}
fullvariant: Detailed cards with descriptions, dates, locations, imagescompactvariant: Smaller cards usingEventCardcomponent
File: src/components/events/RecentEventsList.tsx - Deleted (merged into EventsList)
Avatar SIZE_MAP Export
File: src/components/auth/Avatar.tsx
Exported SIZE_MAP for type-safe size references in other components:
export const SIZE_MAP = {
xxs: 'w-6 h-6 text-[10px]',
xs: 'w-8 h-8 text-xs',
sm: 'w-10 h-10 text-xs',
// ...
};
Database: Remove image_url Column
Migration
File: supabase/migrations/20260126000000_remove_events_image_url.sql
-- Remove image_url column from events table
-- All event images should now be in cover_image field
ALTER TABLE events DROP COLUMN IF EXISTS image_url;
Global Search Update
File: supabase/migrations/20260125000000_add_global_search.sql
Updated to remove image_url fallback:
-- Before: COALESCE(NULLIF(e.cover_image, ''), NULLIF(e.image_url, ''))
-- After: NULLIF(e.cover_image, '')
Migration Scripts
File: scripts/migrate-event-covers.ts
Script to migrate external event cover images to Supabase storage:
- Fetches all events with external
image_url(non-Supabase URLs) - Downloads each image
- Uploads to
event-coversbucket - Updates
cover_imagefield with new Supabase URL
File: scripts/upload-hero-images.ts
Script to upload local hero images to Supabase:
- Reads images from
public/gallery/ - Uploads to
cpg-public/hero/in Supabase storage - Outputs URLs for updating
src/app/page.tsx
API: Notification Creation
Event Reminders Cron
File: src/app/api/cron/event-reminders/route.ts
Added in-app notification creation alongside email reminders:
- Creates notification for RSVP reminders (event is confirmed)
- Creates notification for attendee reminders (event happening soon)
- Both link to the event page
Event Announcements
File: src/app/api/admin/events/announce/route.ts
Added notification creation for event announcements:
- Notifies all members when a new event is announced
- Links to the event page
Email Attendees
File: src/app/api/admin/events/email-attendees/route.ts
Added notification creation when admin emails attendees:
- Each attendee receives a notification
- Shows the custom message from admin
Attendee Profiles
File: src/app/api/events/past/route.ts
Updated to include full_name and nickname in attendee profiles for the popover:
profiles: Pick<Tables<'profiles'>, 'avatar_url' | 'full_name' | 'nickname'>
Component Updates
EventImage Dimensions
File: src/components/events/EventImage.tsx
Adjusted to 1.5x display size for better quality:
- Small: 960×720 (from 320×240)
- Default: 480×480 (from 640×640)
HeroImage Sizes
File: src/components/shared/HeroImage.tsx
Updated sizes attribute for better optimization.
Popover Z-Index
File: src/components/shared/Popover.tsx
Increased z-index to z-40 for proper stacking above other elements.
All Modified Files (33 total)
New Files (6)
supabase/migrations/20260126000000_remove_events_image_url.sql- Drop image_url columnsrc/components/shared/BlurImage.tsx- Image with blur placeholdersrc/components/shared/StackedAvatarsPopover.tsx- Reusable avatars popoversrc/utils/decodeBlurhash.ts- Blurhash to data URL decoderscripts/migrate-event-covers.ts- Migrate external images to Supabasescripts/upload-hero-images.ts- Upload hero images to Supabase
Deleted Files (1)
src/components/events/RecentEventsList.tsx- Merged into EventsList
Modified Files (27)
docs/revalidation-system.md- Updated documentationsrc/app/admin/events/page.tsx- Remove image_url referencessrc/app/api/admin/events/announce/route.ts- Add notification creationsrc/app/api/admin/events/email-attendees/route.ts- Add notification creationsrc/app/api/cron/event-reminders/route.ts- Add notification creationsrc/app/api/events/past/route.ts- Include full profile in attendeessrc/app/events/[eventSlug]/page.tsx- Use BlurImage, StackedAvatarsPopoversrc/app/page.tsx- Use EventsList with compact variantsrc/components/album/AlbumCard.tsx- Use BlurImagesrc/components/album/AlbumMiniCard.tsx- Use BlurImagesrc/components/auth/Avatar.tsx- Export SIZE_MAPsrc/components/events/AddToCalendar.tsx- Minor cleanupsrc/components/events/EventCard.tsx- Add attendees with popoversrc/components/events/EventImage.tsx- Use BlurImage, adjust dimensionssrc/components/events/EventsList.tsx- Add compact variant, attendeessrc/components/manage/PhotoCard.tsx- Use BlurImagesrc/components/manage/PhotoListItem.tsx- Use BlurImagesrc/components/photo/JustifiedPhotoGrid.tsx- Use BlurImage with blurhashsrc/components/photo/PhotoPageContent.tsx- Pass blurhash to PhotoWithLightboxsrc/components/photo/PhotoWithLightbox.tsx- Use BlurImage with blurhashsrc/components/shared/DetailLikesSection.tsx- Use StackedAvatarsPopoversrc/components/shared/HeroImage.tsx- Update sizes attributesrc/components/shared/Popover.tsx- Increase z-indexsrc/lib/data/events.ts- Include attendees in recent events querysrc/types/events.ts- Update EventAttendee typesrc/utils/supabaseImageLoader.ts- Add getBlurPlaceholderUrl helpersupabase/migrations/20260125000000_add_global_search.sql- Remove image_url fallback