6 Commits

Author SHA1 Message Date
fef7c160e2 feat:use GMaps places api for searching poi's 2025-10-27 14:57:54 +01:00
43afa6dacc update:logboek 2025-10-21 14:49:55 +02:00
aa9ed77499 UI:Refactor create find modal UI and update finds list/header layout
- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
2025-10-21 14:44:25 +02:00
e1c5846fa4 fix:friends filtering 2025-10-21 14:11:28 +02:00
634ce8adf8 fix:logic of friends and users search 2025-10-20 11:35:44 +02:00
f547ee5a84 add:logs 2025-10-16 19:01:43 +02:00
14 changed files with 1374 additions and 940 deletions

View File

@@ -35,3 +35,6 @@ R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""
# Google Maps API for Places search
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"

641
finds.md
View File

@@ -1,641 +0,0 @@
# 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`:
```typescript
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`:
```typescript
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`:
```typescript
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:
```typescript
// 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`:
```typescript
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:
```typescript
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:**
```json
[
{
"AllowedOrigins": ["https://serengo.ziasvannes.tech", "http://localhost:5173"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
```
3. **Custom Domain (Recommended):**
- Point `media.serengo.ziasvannes.tech` to R2 bucket
- Enables better caching and CDN integration
- Update `R2_PUBLIC_URL` in r2.ts
4. **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.

View File

@@ -4,7 +4,7 @@
Serengo is a location-based social discovery platform where users can save, share, and discover memorable places with media, reviews, and precise location data.
## Current Status: Phase 2A & 2C Complete + UI Integration ✅
## Current Status: Phase 2A, 2C & 2D Complete + UI Integration ✅
### What Serengo Currently Has:
@@ -14,6 +14,7 @@ Serengo is a location-based social discovery platform where users can save, shar
- **NEW**: Modern WebP image processing with JPEG fallbacks
- **NEW**: Full video support with custom VideoPlayer component
- **NEW**: Like/unlike system with optimistic UI updates
- **NEW**: Complete friends & privacy system with friend requests and filtered feeds
- R2 storage integration with enhanced media processing
- Full database schema with proper relationships and social features
- Type-safe API endpoints and error handling
@@ -27,6 +28,9 @@ Serengo is a location-based social discovery platform where users can save, shar
- **NEW**: Video playback with custom controls and fullscreen support
- **NEW**: Like/unlike finds with real-time count updates
- **NEW**: Animated like buttons with heart animations
- **NEW**: Friend request system with send/accept/decline functionality
- **NEW**: Friends management page with user search and relationship status
- **NEW**: Privacy-aware find feeds with friend-specific visibility filters
- Share functionality with clipboard copy
- Real-time map markers with click-to-preview
- Grid layout for find browsing
@@ -108,19 +112,45 @@ Serengo is a location-based social discovery platform where users can save, shar
**Actual Effort**: ~15 hours (including UI integration)
### Phase 2D: Friends & Privacy System (Priority: Medium-High)
### Phase 2D: Friends & Privacy System ✅ COMPLETE
**Goal**: Social connections and privacy controls
**Tasks:**
**Completed Tasks:**
- [ ] Build friend request system (send/accept/decline)
- [ ] Create Friends management page using SHADCN components
- [ ] Implement friend search with user suggestions
- [ ] Update find privacy logic to respect friendships
- [ ] Add friend-specific find visibility filters
- [x] Build friend request system (send/accept/decline)
- [x] Create Friends management page using SHADCN components
- [x] Implement friend search with user suggestions
- [x] Update find privacy logic to respect friendships
- [x] Add friend-specific find visibility filters
**Estimated Effort**: 20-25 hours
**Implementation Details:**
- Created comprehensive friends API with `/api/friends` and `/api/friends/[friendshipId]` endpoints
- Built `/api/users` endpoint for user search with friendship status integration
- Developed complete Friends management page (`/routes/friends/`) with tabs for:
- Friends list with remove functionality
- Friend requests (received/sent) with accept/decline actions
- User search with friend request sending capabilities
- Enhanced finds API to support friend-based privacy filtering with `includeFriends` parameter
- Created `FindsFilter.svelte` component with filter options:
- All Finds (public, friends, and user's finds)
- Public Only (publicly visible finds)
- Friends Only (finds from friends)
- My Finds (user's own finds)
- Updated main page with integrated filter dropdown and real-time filtering
- Enhanced ProfilePanel with Friends navigation link
- Maintained type safety and error handling throughout all implementations
**Technical Architecture:**
- Leveraged existing `friendship` table schema without modifications
- Used SHADCN components (Cards, Badges, Avatars, Buttons, Dropdowns) for consistent UI
- Implemented proper authentication and authorization on all endpoints
- Added comprehensive error handling with descriptive user feedback
- Followed Svelte 5 patterns with `$state`, `$derived`, and `$props` runes
**Actual Effort**: ~22 hours
### Phase 2E: Advanced Filtering & Discovery (Priority: Medium)
@@ -176,11 +206,36 @@ Serengo is a location-based social discovery platform where users can save, shar
- Optimize images/videos for web delivery
**Total Phase 2 Estimated Effort**: 82-105 hours
**Total Phase 2 Completed Effort**: ~49 hours (Phases 2A: 12h + 2C: 15h + 2D: 22h)
**Expected Timeline**: 8-10 weeks (part-time development)
## Next Steps:
1. Begin with Phase 2A (Modern Media Support) for immediate impact
2. Implement Phase 2B (Google Maps POI) for better UX
3. Add Phase 2C (Social Interactions) for user engagement
4. Continue with remaining phases based on user feedback
1. **Completed**: Phase 2A (Modern Media Support) for immediate impact
2. **Next Priority**: Phase 2B (Google Maps POI) for better UX
3. **Completed**: Phase 2C (Social Interactions) for user engagement
4. **Completed**: Phase 2D (Friends & Privacy System) for social connections
5. **Continue with**: Phase 2E (Advanced Filtering) or 2F (Enhanced Sharing) based on user feedback
## Production Ready Features Summary:
**Core Functionality:**
- Create/view finds with photos, videos, descriptions, and categories
- Location-based filtering and discovery with interactive map
- Media carousel with navigation (WebP images and MP4 videos)
- Real-time map markers with click-to-preview functionality
**Social Features:**
- Like/unlike finds with real-time count updates and animations
- Friend request system (send, accept, decline, remove)
- Friends management page with user search capabilities
- Privacy-aware find feeds with customizable visibility filters
**Technical Excellence:**
- Modern media formats (WebP, MP4) with fallback support
- Type-safe API endpoints with comprehensive error handling
- Mobile-optimized responsive design
- Performance-optimized with proper caching and lazy loading

View File

@@ -2,6 +2,88 @@
## Oktober 2025
### 21 Oktober 2025 (Maandag) - 4 uren
**Werk uitgevoerd:**
- **UI Refinement & Bug Fixes**
- Create Find Modal UI refactoring met verbeterde layout
- Finds list en header layout updates voor betere UX
- Friends filtering logica fixes
- Friends en users search functionaliteit verbeteringen
- Modal interface optimalisaties
**Commits:**
- aa9ed77 - UI:Refactor create find modal UI and update finds list/header layout
- e1c5846 - fix:friends filtering
**Details:**
- Verbeterde modal interface met consistente styling
- Fixed filtering logica voor vriendensysteem
- Enhanced search functionaliteit voor gebruikers en vrienden
- UI/UX verbeteringen voor betere gebruikerservaring
---
### 20 Oktober 2025 (Zondag) - 2 uren
**Werk uitgevoerd:**
- **Search Logic Improvements**
- Friends en users search logica geoptimaliseerd
- Filtering verbeteringen voor vriendschapssysteem
- Backend search algoritmes verfijnd
**Commits:**
- 634ce8a - fix:logic of friends and users search
**Details:**
- Verbeterde zoekalgoritmes voor gebruikers
- Geoptimaliseerde filtering voor vriendensysteem
- Backend logica verfijning voor betere performance
---
### 16 Oktober 2025 (Woensdag) - 6 uren
**Werk uitgevoerd:**
- **Phase 2D: Friends & Privacy System - Volledige implementatie**
- Complete vriendschapssysteem geïmplementeerd (verzenden/accepteren/weigeren/verwijderen)
- Friends management pagina ontwikkeld met gebruikerszoekfunctionaliteit
- Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- API endpoints voor vriendschapsbeheer (/api/friends, /api/friends/[friendshipId])
- Gebruikerszoek API met vriendschapsstatus integratie (/api/users)
- FindsFilter component met 4 filteropties (All/Public/Friends/Mine)
- Hoofdpagina uitgebreid met geïntegreerde filteringfunctionaliteit
- ProfilePanel uitgebreid met Friends navigatielink
- Type-safe implementatie met volledige error handling
**Commits:**
- f547ee5 - add:logs
- a01d183 - feat:friends
- fdbd495 - add:cache for r2 storage
- e54c4fb - feat:profile pictures
- bee03a5 - feat:use dynamic sheet for findpreview
- aea3249 - fix:likes;UI:find card&list
**Details:**
- Complete sociale verbindingssysteem voor gebruikers
- Real-time filtering van finds op basis van privacy instellingen
- SHADCN componenten gebruikt voor consistente UI (Cards, Badges, Avatars, Dropdowns)
- Svelte 5 patterns toegepast met $state, $derived, en $props runes
- Bestaande friendship table schema optimaal benut zonder wijzigingen
- Comprehensive authentication en authorization op alle endpoints
- Mobile-responsive design met aangepaste styling voor kleinere schermen
---
### 14 Oktober 2025 (Maandag) - 8 uren
**Werk uitgevoerd:**
@@ -17,7 +99,9 @@
- CSP headers bijgewerkt voor video support
- Volledige UI integratie in FindCard en FindPreview componenten
**Commits:** Nog niet gecommit (staged changes)
**Commits:**
- 067e228 - feat:video player, like button, and media fallbacks
**Details:**
@@ -238,9 +322,9 @@
## Totaal Overzicht
**Totale geschatte uren:** 55 uren
**Werkdagen:** 9 dagen
**Gemiddelde uren per dag:** 6.1 uur
**Totale geschatte uren:** 67 uren
**Werkdagen:** 12 dagen
**Gemiddelde uren per dag:** 5.6 uur
### Project Milestones:
@@ -253,6 +337,9 @@
7. **10 Okt**: Finds feature en media upload systeem
8. **13 Okt**: API architectuur verbetering
9. **14 Okt**: Modern media support en social interactions
10. **16 Okt**: Friends & Privacy System implementatie
11. **20 Okt**: Search logic improvements
12. **21 Okt**: UI refinement en bug fixes
### Hoofdfunctionaliteiten geïmplementeerd:
@@ -275,3 +362,7 @@
- [x] WebP image processing met JPEG fallbacks
- [x] Like/unlike systeem met real-time updates
- [x] Social interactions en animated UI components
- [x] Friends & Privacy System met vriendschapsverzoeken
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
- [x] Real-time find filtering op basis van privacy instellingen

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from './POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
@@ -12,7 +15,6 @@
let { isOpen, onClose, onFindCreated }: Props = $props();
// Form state
let title = $state('');
let description = $state('');
let latitude = $state('');
@@ -23,8 +25,8 @@
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
// Categories
const categories = [
{ value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' },
@@ -35,21 +37,40 @@
{ value: 'other', label: 'Other' }
];
// Auto-fill location when modal opens
let showModal = $state(true);
let isMobile = $state(false);
$effect(() => {
if (isOpen && $coordinates) {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (!showModal) {
onClose();
}
});
$effect(() => {
if (showModal && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
// Handle file selection
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
// Upload media files
async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return;
@@ -71,7 +92,6 @@
uploadedMedia = result.media;
}
// Submit form
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
@@ -83,12 +103,10 @@
isSubmitting = true;
try {
// Upload media first if any
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
// Create the find
const response = await fetch('/api/finds', {
method: 'POST',
headers: {
@@ -110,11 +128,8 @@
throw new Error('Failed to create find');
}
// Reset form and close modal
resetForm();
onClose();
// Notify parent about new find creation
showModal = false;
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
} catch (error) {
console.error('Error creating find:', error);
@@ -124,16 +139,32 @@
}
}
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
title = '';
description = '';
locationName = '';
latitude = '';
longitude = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
useManualLocation = false;
// Reset file input
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
@@ -142,291 +173,443 @@
function closeModal() {
resetForm();
onClose();
showModal = false;
}
</script>
{#if isOpen}
<Modal bind:showModal={isOpen} positioning="center">
{#snippet header()}
<h2>Create Find</h2>
{/snippet}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
<SheetHeader>
<SheetTitle>Create Find</SheetTitle>
</SheetHeader>
<div class="form-container">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="form-group">
<label for="title">Title *</label>
<Input name="title" placeholder="What did you find?" required bind:value={title} />
<span class="char-count">{title.length}/100</span>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
<div class="form-content">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="form-group">
<label for="longitude">Longitude *</label>
<Input name="longitude" type="text" required bind:value={longitude} />
<div class="field">
<Label for="description">Tell us about it</Label>
<textarea
name="description"
placeholder="The best cappuccino in town..."
maxlength="500"
bind:value={description}
></textarea>
</div>
</div>
<div class="form-group">
<label for="category">Category</label>
<select name="category" bind:value={category}>
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
<div class="form-group">
<label for="media-files">Photos/Videos (max 5)</label>
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
multiple
max="5"
onchange={handleFileChange}
/>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-preview">
{#each Array.from(selectedFiles) as file (file.name)}
<span class="file-name">{file.name}</span>
{/each}
{#if useManualLocation}
<div class="field">
<Label for="location-name">Location name</Label>
<Input
name="location-name"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{:else}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for cafés, restaurants, landmarks..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field-group">
<div class="field">
<Label for="category">Category</Label>
<select name="category" bind:value={category} class="select">
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="field">
<Label>Privacy</Label>
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span>{isPublic ? 'Public' : 'Private'}</span>
</label>
</div>
</div>
<div class="field">
<Label for="media-files">Add photo or video</Label>
<div class="file-upload">
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
onchange={handleFileChange}
class="file-input"
/>
<div class="file-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Click to upload</span>
</div>
</div>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-selected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{selectedFiles[0].name}</span>
</div>
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="form-group">
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
<div class="actions">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</button>
</Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</div>
</Modal>
</SheetContent>
</Sheet>
{/if}
<style>
.form-container {
padding: 24px;
max-height: 70vh;
:global(.create-find-sheet) {
padding: 0 !important;
width: 100%;
max-width: 500px;
height: 100vh;
border-radius: 0;
}
@media (max-width: 767px) {
:global(.create-find-sheet) {
height: 90vh;
border-radius: 12px 12px 0 0;
}
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.form-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
margin-bottom: 20px;
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-row {
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
gap: 1rem;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
font-size: 0.9rem;
font-family: 'Washington', serif;
@media (max-width: 640px) {
.field-group {
grid-template-columns: 1fr;
}
}
textarea {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
resize: vertical;
min-height: 100px;
font-family: inherit;
}
textarea:focus {
background-color: #d5d5d5;
}
textarea::placeholder {
color: #888;
}
select {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
cursor: pointer;
}
select:focus {
background-color: #d5d5d5;
}
input[type='file'] {
width: 100%;
padding: 12px;
border: 2px dashed #ccc;
min-height: 80px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background-color: #f9f9f9;
cursor: pointer;
background: hsl(var(--background));
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s ease;
}
input[type='file']:hover {
border-color: #999;
textarea:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
.char-count {
font-size: 0.8rem;
color: #666;
text-align: right;
display: block;
margin-top: 4px;
textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.file-preview {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
.select {
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
}
.file-name {
background-color: #f0f0f0;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #666;
.select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
.privacy-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: normal;
font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
transition: background-color 0.2s ease;
}
.privacy-toggle:hover {
background: hsl(var(--muted));
}
.privacy-toggle input[type='checkbox'] {
margin-right: 8px;
width: 18px;
height: 18px;
width: 16px;
height: 16px;
accent-color: hsl(var(--primary));
}
.privacy-help {
font-size: 0.8rem;
color: #666;
margin-top: 4px;
margin-left: 26px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
}
.button {
flex: 1;
padding: 1.25rem 2rem;
border: none;
border-radius: 2rem;
font-size: 1rem;
font-weight: 500;
.file-upload {
position: relative;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.3);
transition: all 0.2s ease;
cursor: pointer;
height: 3.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
.file-upload:hover {
border-color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
}
.button:disabled:hover {
transform: none;
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.button.secondary {
background-color: #6c757d;
color: white;
.file-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
}
.button.secondary:hover:not(:disabled) {
background-color: #5a6268;
transform: translateY(-1px);
.file-content span {
font-size: 0.875rem;
}
.file-selected {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.file-selected svg {
color: hsl(var(--primary));
flex-shrink: 0;
}
.file-selected span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
}
.actions :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
height: auto;
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
cursor: pointer;
transition: all 0.2s ease;
}
.toggle-button:hover {
background: hsl(var(--muted));
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
height: auto;
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
cursor: pointer;
transition: all 0.2s ease;
}
.edit-coords-button:hover {
background: hsl(var(--muted));
}
@media (max-width: 640px) {
.form-container {
padding: 16px;
.form-content {
padding: 1rem;
gap: 1rem;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
.actions {
padding: 1rem;
flex-direction: column;
}
.actions :global(button) {
flex: none;
}
}
</style>

View File

@@ -26,6 +26,7 @@
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
@@ -33,7 +34,8 @@
onFindExplore,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display'
emptyMessage = 'No finds to display',
hideTitle = false
}: FindsListProps = $props();
function handleFindExplore(id: string) {
@@ -42,9 +44,11 @@
</script>
<section class="finds-feed">
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if finds.length > 0}
<div class="feed-container">
@@ -98,6 +102,7 @@
<style>
.finds-feed {
width: 100%;
padding: 0 24px 24px 24px;
}
.feed-header {

View File

@@ -0,0 +1,403 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
onPlaceSelected: (place: PlaceResult) => void;
placeholder?: string;
label?: string;
showNearbyButton?: boolean;
}
let {
onPlaceSelected,
placeholder = 'Search for a place or address...',
label = 'Search location',
showNearbyButton = true
}: Props = $props();
let searchQuery = $state('');
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
let nearbyPlaces = $state<PlaceResult[]>([]);
let isLoading = $state(false);
let showSuggestions = $state(false);
let showNearby = $state(false);
let debounceTimeout: ReturnType<typeof setTimeout>;
async function searchPlaces(query: string) {
if (!query.trim()) {
suggestions = [];
showSuggestions = false;
return;
}
isLoading = true;
try {
const params = new URLSearchParams({
action: 'autocomplete',
query: query.trim()
});
if ($coordinates) {
params.set('lat', $coordinates.latitude.toString());
params.set('lng', $coordinates.longitude.toString());
}
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
suggestions = await response.json();
showSuggestions = true;
}
} catch (error) {
console.error('Error searching places:', error);
} finally {
isLoading = false;
}
}
async function selectPlace(placeId: string, description: string) {
isLoading = true;
try {
const params = new URLSearchParams({
action: 'details',
placeId
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
const place: PlaceResult = await response.json();
onPlaceSelected(place);
searchQuery = description;
showSuggestions = false;
}
} catch (error) {
console.error('Error getting place details:', error);
} finally {
isLoading = false;
}
}
async function findNearbyPlaces() {
if (!$coordinates) {
alert('Please enable location access first');
return;
}
isLoading = true;
showNearby = true;
try {
const params = new URLSearchParams({
action: 'nearby',
lat: $coordinates.latitude.toString(),
lng: $coordinates.longitude.toString(),
radius: '2000' // 2km radius
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
nearbyPlaces = await response.json();
}
} catch (error) {
console.error('Error finding nearby places:', error);
} finally {
isLoading = false;
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
searchQuery = target.value;
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
searchPlaces(searchQuery);
}, 300);
}
function selectNearbyPlace(place: PlaceResult) {
onPlaceSelected(place);
searchQuery = place.name;
showNearby = false;
showSuggestions = false;
}
function handleClickOutside(event: Event) {
const target = event.target as HTMLElement;
if (!target.closest('.poi-search-container')) {
showSuggestions = false;
showNearby = false;
}
}
// Close dropdowns when clicking outside
if (typeof window !== 'undefined') {
document.addEventListener('click', handleClickOutside);
}
</script>
<div class="poi-search-container">
<div class="field">
<Label for="poi-search">{label}</Label>
<div class="search-input-container">
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
{#if showNearbyButton && $coordinates}
<Button
type="button"
variant="ghost"
size="sm"
onclick={findNearbyPlaces}
disabled={isLoading}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
Nearby
</Button>
{/if}
</div>
</div>
{#if showSuggestions && suggestions.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Search Results</div>
{#each suggestions as suggestion (suggestion.placeId)}
<button
class="suggestion-item"
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{suggestion.description}</span>
<div class="suggestion-types">
{#each suggestion.types.slice(0, 2) as type}
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
{/each}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if showNearby && nearbyPlaces.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Nearby Places</div>
{#each nearbyPlaces as place (place.placeId)}
<button
class="suggestion-item"
onclick={() => selectNearbyPlace(place)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{place.name}</span>
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
{#if place.rating}
<div class="suggestion-rating">
<span class="rating-stars"></span>
<span>{place.rating.toFixed(1)}</span>
</div>
{/if}
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if isLoading}
<div class="loading-indicator">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="32"
stroke-dashoffset="32"
/>
</svg>
<span>Searching...</span>
</div>
{/if}
</div>
<style>
.poi-search-container {
position: relative;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-input-container {
position: relative;
display: flex;
gap: 0.5rem;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: 0.25rem;
backdrop-filter: blur(8px);
}
.suggestions-header {
padding: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted));
backdrop-filter: blur(12px);
}
.suggestion-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: none;
background: hsl(var(--background));
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid hsl(var(--border));
backdrop-filter: blur(12px);
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover:not(:disabled) {
background: hsl(var(--muted) / 0.5);
}
.suggestion-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.suggestion-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.suggestion-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.suggestion-address {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.suggestion-types {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.suggestion-type {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 4px;
text-transform: capitalize;
}
.suggestion-rating {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.rating-stars {
color: #fbbf24;
}
.suggestion-icon {
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.loading-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.search-input-container {
flex-direction: column;
}
}
</style>

194
src/lib/utils/places.ts Normal file
View File

@@ -0,0 +1,194 @@
export interface PlaceResult {
placeId: string;
name: string;
formattedAddress: string;
latitude: number;
longitude: number;
types: string[];
vicinity?: string;
rating?: number;
priceLevel?: number;
}
interface GooglePlacesPrediction {
place_id: string;
description: string;
types: string[];
}
interface GooglePlacesResult {
place_id: string;
name: string;
formatted_address?: string;
vicinity?: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
types?: string[];
rating?: number;
price_level?: number;
}
export interface PlaceSearchOptions {
query?: string;
location?: { lat: number; lng: number };
radius?: number;
type?: string;
}
export class GooglePlacesService {
private apiKey: string;
private baseUrl = 'https://maps.googleapis.com/maps/api/place';
constructor(apiKey: string) {
this.apiKey = apiKey;
}
/**
* Search for places using text query
*/
async searchPlaces(
query: string,
location?: { lat: number; lng: number }
): Promise<PlaceResult[]> {
const url = new URL(`${this.baseUrl}/textsearch/json`);
url.searchParams.set('query', query);
url.searchParams.set('key', this.apiKey);
if (location) {
url.searchParams.set('location', `${location.lat},${location.lng}`);
url.searchParams.set('radius', '50000'); // 50km radius
}
try {
const response = await fetch(url.toString());
const data = await response.json();
if (data.status !== 'OK') {
throw new Error(`Places API error: ${data.status}`);
}
return data.results.map(this.formatPlaceResult);
} catch (error) {
console.error('Error searching places:', error);
throw error;
}
}
/**
* Get place autocomplete suggestions
*/
async getAutocompleteSuggestions(
input: string,
location?: { lat: number; lng: number }
): Promise<Array<{ placeId: string; description: string; types: string[] }>> {
const url = new URL(`${this.baseUrl}/autocomplete/json`);
url.searchParams.set('input', input);
url.searchParams.set('key', this.apiKey);
if (location) {
url.searchParams.set('location', `${location.lat},${location.lng}`);
url.searchParams.set('radius', '50000');
}
try {
const response = await fetch(url.toString());
const data = await response.json();
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
throw new Error(`Places API error: ${data.status}`);
}
return (
data.predictions?.map((prediction: GooglePlacesPrediction) => ({
placeId: prediction.place_id,
description: prediction.description,
types: prediction.types
})) || []
);
} catch (error) {
console.error('Error getting autocomplete suggestions:', error);
throw error;
}
}
/**
* Get detailed information about a place
*/
async getPlaceDetails(placeId: string): Promise<PlaceResult> {
const url = new URL(`${this.baseUrl}/details/json`);
url.searchParams.set('place_id', placeId);
url.searchParams.set(
'fields',
'place_id,name,formatted_address,geometry,types,vicinity,rating,price_level'
);
url.searchParams.set('key', this.apiKey);
try {
const response = await fetch(url.toString());
const data = await response.json();
if (data.status !== 'OK') {
throw new Error(`Places API error: ${data.status}`);
}
return this.formatPlaceResult(data.result);
} catch (error) {
console.error('Error getting place details:', error);
throw error;
}
}
/**
* Find nearby places
*/
async findNearbyPlaces(
location: { lat: number; lng: number },
radius: number = 5000,
type?: string
): Promise<PlaceResult[]> {
const url = new URL(`${this.baseUrl}/nearbysearch/json`);
url.searchParams.set('location', `${location.lat},${location.lng}`);
url.searchParams.set('radius', radius.toString());
url.searchParams.set('key', this.apiKey);
if (type) {
url.searchParams.set('type', type);
}
try {
const response = await fetch(url.toString());
const data = await response.json();
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
throw new Error(`Places API error: ${data.status}`);
}
return data.results?.map(this.formatPlaceResult) || [];
} catch (error) {
console.error('Error finding nearby places:', error);
throw error;
}
}
private formatPlaceResult = (place: GooglePlacesResult): PlaceResult => {
return {
placeId: place.place_id,
name: place.name,
formattedAddress: place.formatted_address || place.vicinity || '',
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng,
types: place.types || [],
vicinity: place.vicinity,
rating: place.rating,
priceLevel: place.price_level
};
};
}
export function createPlacesService(apiKey: string): GooglePlacesService {
return new GooglePlacesService(apiKey);
}

View File

@@ -24,6 +24,7 @@
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media: Array<{
id: string;
findId: string;
@@ -53,6 +54,7 @@
};
likeCount?: number;
isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{
type: string;
url: string;
@@ -102,6 +104,7 @@
},
likeCount: serverFind.likeCount,
isLiked: serverFind.isLikedByUser,
isFromFriend: serverFind.isFromFriend,
media: serverFind.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
@@ -120,7 +123,7 @@
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
return allFinds.filter((find) => find.isFromFriend === true);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
@@ -217,19 +220,22 @@
</div>
<div class="finds-section">
<div class="finds-header">
<div class="finds-title-section">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
<div class="finds-sticky-header">
<div class="finds-header-content">
<div class="finds-title-section">
<h2 class="finds-title">Finds</h2>
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div>
</main>
@@ -285,27 +291,44 @@
.finds-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.finds-header {
position: relative;
.finds-sticky-header {
position: sticky;
top: 0;
z-index: 50;
background: white;
border-bottom: 1px solid hsl(var(--border));
padding: 24px 24px 16px 24px;
border-radius: 12px 12px 0 0;
}
.finds-header-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.finds-title-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 1rem;
flex: 1;
}
.finds-title {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
color: hsl(var(--foreground));
}
:global(.create-find-button) {
position: absolute;
top: 0;
right: 0;
z-index: 10;
flex-shrink: 0;
}
.fab {
@@ -338,14 +361,25 @@
gap: 16px;
}
.finds-section {
padding: 16px;
.finds-sticky-header {
padding: 16px 16px 12px 16px;
}
.finds-header-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.finds-title-section {
flex-direction: column;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12px;
align-items: stretch;
}
.finds-title {
font-size: 1.5rem;
}
:global(.create-find-button) {
@@ -366,8 +400,8 @@
padding: 12px;
}
.finds-section {
padding: 12px;
.finds-sticky-header {
padding: 12px 12px 8px 12px;
}
}
</style>

View File

@@ -107,7 +107,15 @@ export const GET: RequestHandler = async ({ url, locals }) => {
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
) THEN 1 ELSE 0 END`,
isFromFriend: sql<boolean>`CASE WHEN ${
friendIds.length > 0
? sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
: sql`FALSE`
} THEN 1 ELSE 0 END`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
@@ -199,7 +207,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
...findItem,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser)
isLikedByUser: Boolean(findItem.isLikedByUser),
isFromFriend: Boolean(findItem.isFromFriend)
};
})
);

View File

@@ -24,30 +24,46 @@ export const GET: RequestHandler = async ({ url, locals }) => {
if (type === 'friends') {
// Get accepted friendships where user is either sender or receiver
friendships = await db
const friendshipsRaw = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
createdAt: friendship.createdAt
})
.from(friendship)
.innerJoin(
user,
or(
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
)
)
.where(
and(
eq(friendship.status, status),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
// Get friend details for each friendship
friendships = await Promise.all(
friendshipsRaw.map(async (f) => {
const friendUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
const friendUser = await db
.select({
username: user.username,
profilePictureUrl: user.profilePictureUrl
})
.from(user)
.where(eq(user.id, friendUserId))
.limit(1);
return {
id: f.id,
userId: f.userId,
friendId: f.friendId,
status: f.status,
createdAt: f.createdAt,
friendUsername: friendUser[0]?.username || '',
friendProfilePictureUrl: friendUser[0]?.profilePictureUrl || null
};
})
);
} else if (type === 'sent') {
// Get friend requests sent by current user
friendships = await db

View File

@@ -0,0 +1,83 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { createPlacesService } from '$lib/utils/places';
const getPlacesService = () => {
const apiKey = env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set');
}
return createPlacesService(apiKey);
};
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const action = url.searchParams.get('action');
const query = url.searchParams.get('query');
const placeId = url.searchParams.get('placeId');
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius');
const type = url.searchParams.get('type');
try {
const placesService = getPlacesService();
let location;
if (lat && lng) {
location = { lat: parseFloat(lat), lng: parseFloat(lng) };
}
switch (action) {
case 'search': {
if (!query) {
throw error(400, 'Query parameter is required for search');
}
const searchResults = await placesService.searchPlaces(query, location);
return json(searchResults);
}
case 'autocomplete': {
if (!query) {
throw error(400, 'Query parameter is required for autocomplete');
}
const suggestions = await placesService.getAutocompleteSuggestions(query, location);
return json(suggestions);
}
case 'details': {
if (!placeId) {
throw error(400, 'PlaceId parameter is required for details');
}
const placeDetails = await placesService.getPlaceDetails(placeId);
return json(placeDetails);
}
case 'nearby': {
if (!location) {
throw error(400, 'Location parameters (lat, lng) are required for nearby search');
}
const radiusNum = radius ? parseInt(radius) : 5000;
const nearbyResults = await placesService.findNearbyPlaces(
location,
radiusNum,
type || undefined
);
return json(nearbyResults);
}
default:
throw error(400, 'Invalid action parameter');
}
} catch (err) {
console.error('Places API error:', err);
if (err instanceof Error) {
throw error(500, err.message);
}
throw error(500, 'Failed to fetch places data');
}
};

View File

@@ -38,25 +38,22 @@ export const GET: RequestHandler = async ({ url, locals }) => {
}> = [];
if (userIds.length > 0) {
existingFriendships = await db
// Use a simpler approach: get all friendships involving the current user
// and then filter for the relevant user IDs
const allUserFriendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status
})
.from(friendship)
.where(
or(
and(
eq(friendship.userId, locals.user.id),
or(...userIds.map((id) => eq(friendship.friendId, id)))
),
and(
eq(friendship.friendId, locals.user.id),
or(...userIds.map((id) => eq(friendship.userId, id)))
)
)
);
.where(or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id)));
// Filter to only include friendships with users in our search results
existingFriendships = allUserFriendships.filter((f) => {
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
return userIds.includes(otherUserId);
});
}
// Create a map of friendship statuses

View File

@@ -134,6 +134,8 @@
// Search users when query changes with debounce
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
// Track searchQuery dependency explicitly
searchQuery;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchUsers, 300);
});