This repository has been archived on 2026-02-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
serengo/finds.md

19 KiB

Serengo "Finds" Feature - Implementation Specification

Feature Overview

Add a "Finds" feature to Serengo that allows users to save, share, and discover location-based experiences. A Find represents a memorable place that users want to share with friends, complete with photos/videos, reviews, and precise location data.

Core Concept

What is a Find?

  • A user-created post about a specific location they've visited
  • Contains: location coordinates, title, description/review, media (photos/videos), timestamp
  • Shareable with friends and the Serengo community
  • Displayed as interactive markers on the map

Database Schema

Add the following tables to src/lib/server/db/schema.ts:

export const find = pgTable('find', {
	id: text('id').primaryKey(),
	userId: text('user_id')
		.notNull()
		.references(() => user.id, { onDelete: 'cascade' }),
	title: text('title').notNull(),
	description: text('description'),
	latitude: numeric('latitude', { precision: 10, scale: 7 }).notNull(),
	longitude: numeric('longitude', { precision: 10, scale: 7 }).notNull(),
	locationName: text('location_name'), // e.g., "Café Belga, Brussels"
	category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
	isPublic: boolean('is_public').default(true),
	createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
	updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});

export const findMedia = pgTable('find_media', {
	id: text('id').primaryKey(),
	findId: text('find_id')
		.notNull()
		.references(() => find.id, { onDelete: 'cascade' }),
	type: text('type').notNull(), // 'photo' or 'video'
	url: text('url').notNull(),
	thumbnailUrl: text('thumbnail_url'),
	orderIndex: integer('order_index').default(0),
	createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});

export const findLike = pgTable('find_like', {
	id: text('id').primaryKey(),
	findId: text('find_id')
		.notNull()
		.references(() => find.id, { onDelete: 'cascade' }),
	userId: text('user_id')
		.notNull()
		.references(() => user.id, { onDelete: 'cascade' }),
	createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});

export const friendship = pgTable('friendship', {
	id: text('id').primaryKey(),
	userId: text('user_id')
		.notNull()
		.references(() => user.id, { onDelete: 'cascade' }),
	friendId: text('friend_id')
		.notNull()
		.references(() => user.id, { onDelete: 'cascade' }),
	status: text('status').notNull(), // 'pending', 'accepted', 'blocked'
	createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});

UI Components to Create

1. FindsMap Component (src/lib/components/FindsMap.svelte)

  • Extends the existing Map component
  • Displays custom markers for Finds (different from location marker)
  • Clicking a Find marker opens a FindPreview modal
  • Shows only friends' Finds by default, with toggle for public Finds
  • Clusters markers when zoomed out for better performance

2. CreateFindModal Component (src/lib/components/CreateFindModal.svelte)

  • Modal that opens when user clicks "Create Find" button
  • Form fields:
    • Title (required, max 100 chars)
    • Description/Review (optional, max 500 chars)
    • Location (auto-filled from user's current location, editable)
    • Category selector (dropdown)
    • Photo/Video upload (up to 5 files)
    • Privacy toggle (Public/Friends Only)
  • Image preview grid with drag-to-reorder
  • Submit creates Find and refreshes map

3. FindPreview Component (src/lib/components/FindPreview.svelte)

  • Compact card showing Find details
  • Image/video carousel
  • Title, description, username, timestamp
  • Like button with count
  • Share button
  • "Get Directions" button (opens in maps app)
  • Edit/Delete buttons (if user owns the Find)

4. FindsList Component (src/lib/components/FindsList.svelte)

  • Feed view of Finds (alternative to map view)
  • Infinite scroll or pagination
  • Filter by: Friends, Public, Category, Date range
  • Sort by: Recent, Popular (most liked), Nearest

5. FindsFilter Component (src/lib/components/FindsFilter.svelte)

  • Dropdown/sidebar with filters:
    • View: Friends Only / Public / My Finds
    • Category: All / Cafes / Restaurants / Parks / etc.
    • Distance: Within 1km / 5km / 10km / 50km / Any

Routes to Add

/finds - Main Finds page

  • Server load: Fetch user's friends' Finds + public Finds within radius
  • Toggle between Map view and List view
  • "Create Find" floating action button
  • Integrated FindsFilter component

/finds/[id] - Individual Find detail page

  • Full Find details with all photos/videos
  • Comments section (future feature)
  • Share button with social media integration
  • Similar Finds nearby

/finds/create - Create Find page (alternative to modal)

  • Full-page form for creating a Find
  • Better for mobile experience
  • Can use when coming from external share (future feature)

/profile/[userId]/finds - User's Finds profile

  • Grid view of all user's Finds
  • Can filter by category
  • Private Finds only visible to owner

API Endpoints

POST /api/finds - Create Find

  • Upload images/videos to storage (suggest Cloudflare R2 or similar)
  • Create Find record in database
  • Return created Find with media URLs

GET /api/finds?lat={lat}&lng={lng}&radius={km} - Get nearby Finds

  • Query Finds within radius of coordinates
  • Filter by privacy settings and friendship status
  • Return with user info and media

PATCH /api/finds/[id] - Update Find

  • Only owner can update
  • Update title, description, category, privacy

DELETE /api/finds/[id] - Delete Find

  • Only owner can delete
  • Cascade delete media files

POST /api/finds/[id]/like - Like/Unlike Find

  • Toggle like status
  • Return updated like count

File Upload Strategy with Cloudflare R2

Using Cloudflare R2 (Free Tier: 10GB storage, 1M Class A ops/month, 10M Class B ops/month)

Setup Configuration

Create src/lib/server/r2.ts:

import {
	S3Client,
	PutObjectCommand,
	DeleteObjectCommand,
	GetObjectCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import {
	R2_ACCOUNT_ID,
	R2_ACCESS_KEY_ID,
	R2_SECRET_ACCESS_KEY,
	R2_BUCKET_NAME
} from '$env/static/private';

export const r2Client = new S3Client({
	region: 'auto',
	endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: R2_ACCESS_KEY_ID,
		secretAccessKey: R2_SECRET_ACCESS_KEY
	}
});

export const R2_PUBLIC_URL = `https://pub-${R2_ACCOUNT_ID}.r2.dev`; // Configure custom domain for production

// Upload file to R2
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
	const buffer = Buffer.from(await file.arrayBuffer());

	await r2Client.send(
		new PutObjectCommand({
			Bucket: R2_BUCKET_NAME,
			Key: path,
			Body: buffer,
			ContentType: contentType,
			// Cache control for PWA compatibility
			CacheControl: 'public, max-age=31536000, immutable'
		})
	);

	return `${R2_PUBLIC_URL}/${path}`;
}

// Delete file from R2
export async function deleteFromR2(path: string): Promise<void> {
	await r2Client.send(
		new DeleteObjectCommand({
			Bucket: R2_BUCKET_NAME,
			Key: path
		})
	);
}

// Generate signed URL for temporary access (optional, for private files)
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
	const command = new GetObjectCommand({
		Bucket: R2_BUCKET_NAME,
		Key: path
	});

	return await getSignedUrl(r2Client, command, { expiresIn });
}

Environment Variables

Add to .env:

R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET_NAME=serengo-finds

Image Processing & Thumbnail Generation

Create src/lib/server/media-processor.ts:

import sharp from 'sharp';
import { uploadToR2 } from './r2';

const THUMBNAIL_SIZE = 400;
const MAX_IMAGE_SIZE = 1920;

export async function processAndUploadImage(
	file: File,
	findId: string,
	index: number
): Promise<{ url: string; thumbnailUrl: string }> {
	const buffer = Buffer.from(await file.arrayBuffer());

	// Generate unique filename
	const timestamp = Date.now();
	const extension = file.name.split('.').pop();
	const filename = `finds/${findId}/image-${index}-${timestamp}`;

	// Process full-size image (resize if too large, optimize)
	const processedImage = await sharp(buffer)
		.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
			fit: 'inside',
			withoutEnlargement: true
		})
		.jpeg({ quality: 85, progressive: true })
		.toBuffer();

	// Generate thumbnail
	const thumbnail = await sharp(buffer)
		.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
			fit: 'cover',
			position: 'centre'
		})
		.jpeg({ quality: 80 })
		.toBuffer();

	// Upload both to R2
	const imageFile = new File([processedImage], `${filename}.jpg`, { type: 'image/jpeg' });
	const thumbFile = new File([thumbnail], `${filename}-thumb.jpg`, { type: 'image/jpeg' });

	const [url, thumbnailUrl] = await Promise.all([
		uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'),
		uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg')
	]);

	return { url, thumbnailUrl };
}

export async function processAndUploadVideo(
	file: File,
	findId: string,
	index: number
): Promise<{ url: string; thumbnailUrl: string }> {
	const timestamp = Date.now();
	const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`;

	// Upload video directly (no processing on server to save resources)
	const url = await uploadToR2(file, filename, 'video/mp4');

	// For video thumbnail, generate on client-side or use placeholder
	// This keeps server-side processing minimal
	const thumbnailUrl = `/video-placeholder.jpg`; // Use static placeholder

	return { url, thumbnailUrl };
}

PWA Service Worker Compatibility

Update src/service-worker.ts to handle R2 media:

// Add to the existing service worker after the IMAGE_CACHE declaration

const R2_DOMAINS = [
	'pub-', // R2 public URLs pattern
	'r2.dev',
	'r2.cloudflarestorage.com'
];

// In the fetch event listener, update image handling:

// Handle R2 media with cache-first strategy
if (url.hostname.includes('r2.dev') || R2_DOMAINS.some((domain) => url.hostname.includes(domain))) {
	const cachedResponse = await imageCache.match(event.request);
	if (cachedResponse) {
		return cachedResponse;
	}

	try {
		const response = await fetch(event.request);
		if (response.ok) {
			// Cache R2 media for offline access
			imageCache.put(event.request, response.clone());
		}
		return response;
	} catch {
		// Return cached version or fallback
		return cachedResponse || new Response('Media not available offline', { status: 404 });
	}
}

API Endpoint for Upload

Create src/routes/api/finds/upload/+server.ts:

import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
import { generateUserId } from '$lib/server/auth'; // Reuse ID generation

const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
const MAX_FILES = 5;
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime'];

export const POST: RequestHandler = async ({ request, locals }) => {
	if (!locals.user) {
		throw error(401, 'Unauthorized');
	}

	const formData = await request.formData();
	const files = formData.getAll('files') as File[];
	const findId = (formData.get('findId') as string) || generateUserId(); // Generate if creating new Find

	if (files.length === 0 || files.length > MAX_FILES) {
		throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
	}

	const uploadedMedia: Array<{ type: string; url: string; thumbnailUrl: string }> = [];

	for (let i = 0; i < files.length; i++) {
		const file = files[i];

		// Validate file size
		if (file.size > MAX_FILE_SIZE) {
			throw error(400, `File ${file.name} exceeds maximum size of 100MB`);
		}

		// Process based on type
		if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
			const result = await processAndUploadImage(file, findId, i);
			uploadedMedia.push({ type: 'photo', ...result });
		} else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
			const result = await processAndUploadVideo(file, findId, i);
			uploadedMedia.push({ type: 'video', ...result });
		} else {
			throw error(400, `File type ${file.type} not allowed`);
		}
	}

	return json({ findId, media: uploadedMedia });
};

Client-Side Upload Component

Update CreateFindModal.svelte to handle uploads:

async function handleFileUpload(files: FileList) {
	const formData = new FormData();

	Array.from(files).forEach((file) => {
		formData.append('files', file);
	});

	// If editing existing Find, include findId
	if (existingFindId) {
		formData.append('findId', existingFindId);
	}

	const response = await fetch('/api/finds/upload', {
		method: 'POST',
		body: formData
	});

	const result = await response.json();

	// Store result for Find creation
	uploadedMedia = result.media;
	generatedFindId = result.findId;
}

R2 Bucket Configuration

Important R2 Settings for PWA:

  1. Public Access: Enable public bucket access for serengo-finds bucket
  2. CORS Configuration:
[
	{
		"AllowedOrigins": ["https://serengo.ziasvannes.tech", "http://localhost:5173"],
		"AllowedMethods": ["GET", "HEAD"],
		"AllowedHeaders": ["*"],
		"MaxAgeSeconds": 3600
	}
]
  1. Custom Domain (Recommended):

    • Point media.serengo.ziasvannes.tech to R2 bucket
    • Enables better caching and CDN integration
    • Update R2_PUBLIC_URL in r2.ts
  2. Cache Headers: Files uploaded with Cache-Control: public, max-age=31536000, immutable

    • PWA service worker can cache indefinitely
    • Perfect for user-uploaded content that won't change

Offline Support Strategy

How PWA handles R2 media:

  1. First Visit (Online):

    • Finds and media load from R2
    • Service worker caches all media in IMAGE_CACHE
    • Thumbnails cached for fast list views
  2. Subsequent Visits (Online):

    • Service worker serves from cache immediately (cache-first)
    • No network delay for previously viewed media
  3. Offline:

    • All cached Finds and media available
    • New Find creation queued (implement with IndexedDB)
    • Upload when connection restored

Cost Optimization

R2 Free Tier Limits:

  • 10GB storage (≈ 10,000 high-quality photos or 100 videos)
  • 1M Class A operations (write/upload)
  • 10M Class B operations (read/download)

Stay Within Free Tier:

  • Aggressive thumbnail generation (reduces bandwidth)
  • Cache-first strategy (reduces reads)
  • Lazy load images (only fetch what's visible)
  • Compress images server-side (sharp optimization)
  • Video uploads: limit to 100MB, encourage shorter clips

Monitoring:

  • Track R2 usage in Cloudflare dashboard
  • Alert at 80% of free tier limits
  • Implement rate limiting on uploads (max 10 Finds/day per user)

Map Integration

Update src/lib/components/Map.svelte:

  • Add prop finds?: Find[] to display Find markers
  • Create custom Find marker component with distinct styling
  • Different marker colors for:
    • Your own Finds (blue)
    • Friends' Finds (green)
    • Public Finds (orange)
  • Add clustering for better performance with many markers
  • Implement marker click handler to open FindPreview modal

Social Features (Phase 2)

Friends System

  • Add friend request functionality
  • Accept/decline friend requests
  • Friends list page
  • Privacy settings respect friendship status

Notifications

  • Notify when friend creates a Find nearby
  • Notify when someone likes your Find
  • Notify on friend request

Discovery Feed

  • Trending Finds in your area
  • Recommended Finds based on your history
  • Explore by category

Mobile Considerations

  • FindsMap should be touch-friendly with proper zoom controls
  • Create Find button as floating action button (FAB) for easy access
  • Optimize image upload for mobile (compress before upload)
  • Consider PWA features for photo capture
  • Offline support: Queue Finds when offline, sync when online

Security & Privacy

  • Validate all user inputs (title, description lengths)
  • Sanitize text content to prevent XSS
  • Check ownership before allowing edit/delete
  • Respect privacy settings in all queries
  • Rate limit Find creation (e.g., max 20 per day)
  • Implement CSRF protection (already in place)
  • Validate file types and sizes on upload

Performance Optimizations

  • Lazy load images in FindsList and FindPreview
  • Implement virtual scrolling for long lists
  • Cache Find queries with stale-while-revalidate
  • Use CDN for media files
  • Implement marker clustering on map
  • Paginate Finds feed (20-50 per page)
  • Index database on: (latitude, longitude), userId, createdAt

Styling Guidelines

  • Use existing Serengo design system and components
  • Find markers should be visually distinct from location marker
  • Maintain newspaper-inspired aesthetic
  • Use Washington font for Find titles
  • Keep UI clean and uncluttered
  • Smooth animations for modal open/close
  • Skeleton loading states for Finds feed

Success Metrics to Track

  • Number of Finds created per user
  • Photo/video upload rate
  • Likes per Find
  • Map engagement (clicks on Find markers)
  • Share rate
  • Friend connections made

Implementation Priority

Phase 1 (MVP):

  1. Database schema and migrations
  2. Create Find functionality (with photos only)
  3. FindsMap with markers
  4. FindPreview modal
  5. Basic Find feed

Phase 2: 6. Video support 7. Like functionality 8. Friends system 9. Enhanced filters and search 10. Share functionality

Phase 3: 11. Comments on Finds 12. Discovery feed 13. Notifications 14. Advanced analytics

Example User Flow

  1. User opens Serengo app and sees map with their friends' Finds
  2. User visits a cool café and wants to share it
  3. Clicks "Create Find" FAB button
  4. Modal opens with their current location pre-filled
  5. User adds title "Amazing Belgian Waffles at Café Belga"
  6. Writes review: "Best waffles I've had in Brussels! Great atmosphere too."
  7. Uploads 2 photos of the waffles and café interior
  8. Selects category "Cafe" and keeps it Public
  9. Clicks "Share Find"
  10. Find appears on map as new marker
  11. Friends see it in their feed and can like/save it
  12. Friends can click marker to see details and get directions

Next Steps

  1. Run database migrations to create new tables
  2. Implement basic Create Find API endpoint
  3. Build CreateFindModal component
  4. Update Map component to display Find markers
  5. Test end-to-end flow
  6. Iterate based on user feedback

This feature will transform Serengo from a simple location app into a social discovery platform where users share and explore unexpected places together.