Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fef7c160e2
|
|||
|
43afa6dacc
|
|||
|
aa9ed77499
|
|||
|
e1c5846fa4
|
|||
|
634ce8adf8
|
|||
|
f547ee5a84
|
@@ -35,3 +35,6 @@ R2_ACCOUNT_ID=""
|
|||||||
R2_ACCESS_KEY_ID=""
|
R2_ACCESS_KEY_ID=""
|
||||||
R2_SECRET_ACCESS_KEY=""
|
R2_SECRET_ACCESS_KEY=""
|
||||||
R2_BUCKET_NAME=""
|
R2_BUCKET_NAME=""
|
||||||
|
|
||||||
|
# Google Maps API for Places search
|
||||||
|
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||||
|
|||||||
641
finds.md
641
finds.md
@@ -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.
|
|
||||||
@@ -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.
|
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:
|
### 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**: Modern WebP image processing with JPEG fallbacks
|
||||||
- **NEW**: Full video support with custom VideoPlayer component
|
- **NEW**: Full video support with custom VideoPlayer component
|
||||||
- **NEW**: Like/unlike system with optimistic UI updates
|
- **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
|
- R2 storage integration with enhanced media processing
|
||||||
- Full database schema with proper relationships and social features
|
- Full database schema with proper relationships and social features
|
||||||
- Type-safe API endpoints and error handling
|
- 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**: Video playback with custom controls and fullscreen support
|
||||||
- **NEW**: Like/unlike finds with real-time count updates
|
- **NEW**: Like/unlike finds with real-time count updates
|
||||||
- **NEW**: Animated like buttons with heart animations
|
- **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
|
- Share functionality with clipboard copy
|
||||||
- Real-time map markers with click-to-preview
|
- Real-time map markers with click-to-preview
|
||||||
- Grid layout for find browsing
|
- 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)
|
**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
|
**Goal**: Social connections and privacy controls
|
||||||
|
|
||||||
**Tasks:**
|
**Completed Tasks:**
|
||||||
|
|
||||||
- [ ] Build friend request system (send/accept/decline)
|
- [x] Build friend request system (send/accept/decline)
|
||||||
- [ ] Create Friends management page using SHADCN components
|
- [x] Create Friends management page using SHADCN components
|
||||||
- [ ] Implement friend search with user suggestions
|
- [x] Implement friend search with user suggestions
|
||||||
- [ ] Update find privacy logic to respect friendships
|
- [x] Update find privacy logic to respect friendships
|
||||||
- [ ] Add friend-specific find visibility filters
|
- [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)
|
### 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
|
- Optimize images/videos for web delivery
|
||||||
|
|
||||||
**Total Phase 2 Estimated Effort**: 82-105 hours
|
**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)
|
**Expected Timeline**: 8-10 weeks (part-time development)
|
||||||
|
|
||||||
## Next Steps:
|
## Next Steps:
|
||||||
|
|
||||||
1. Begin with Phase 2A (Modern Media Support) for immediate impact
|
1. ✅ **Completed**: Phase 2A (Modern Media Support) for immediate impact
|
||||||
2. Implement Phase 2B (Google Maps POI) for better UX
|
2. **Next Priority**: Phase 2B (Google Maps POI) for better UX
|
||||||
3. Add Phase 2C (Social Interactions) for user engagement
|
3. ✅ **Completed**: Phase 2C (Social Interactions) for user engagement
|
||||||
4. Continue with remaining phases based on user feedback
|
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
|
||||||
|
|||||||
@@ -2,6 +2,88 @@
|
|||||||
|
|
||||||
## Oktober 2025
|
## 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
|
### 14 Oktober 2025 (Maandag) - 8 uren
|
||||||
|
|
||||||
**Werk uitgevoerd:**
|
**Werk uitgevoerd:**
|
||||||
@@ -17,7 +99,9 @@
|
|||||||
- CSP headers bijgewerkt voor video support
|
- CSP headers bijgewerkt voor video support
|
||||||
- Volledige UI integratie in FindCard en FindPreview componenten
|
- 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:**
|
**Details:**
|
||||||
|
|
||||||
@@ -238,9 +322,9 @@
|
|||||||
|
|
||||||
## Totaal Overzicht
|
## Totaal Overzicht
|
||||||
|
|
||||||
**Totale geschatte uren:** 55 uren
|
**Totale geschatte uren:** 67 uren
|
||||||
**Werkdagen:** 9 dagen
|
**Werkdagen:** 12 dagen
|
||||||
**Gemiddelde uren per dag:** 6.1 uur
|
**Gemiddelde uren per dag:** 5.6 uur
|
||||||
|
|
||||||
### Project Milestones:
|
### Project Milestones:
|
||||||
|
|
||||||
@@ -253,6 +337,9 @@
|
|||||||
7. **10 Okt**: Finds feature en media upload systeem
|
7. **10 Okt**: Finds feature en media upload systeem
|
||||||
8. **13 Okt**: API architectuur verbetering
|
8. **13 Okt**: API architectuur verbetering
|
||||||
9. **14 Okt**: Modern media support en social interactions
|
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:
|
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||||
|
|
||||||
@@ -275,3 +362,7 @@
|
|||||||
- [x] WebP image processing met JPEG fallbacks
|
- [x] WebP image processing met JPEG fallbacks
|
||||||
- [x] Like/unlike systeem met real-time updates
|
- [x] Like/unlike systeem met real-time updates
|
||||||
- [x] Social interactions en animated UI components
|
- [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
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||||
import Input from '$lib/components/Input.svelte';
|
import { Input } from '$lib/components/input';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import { Label } from '$lib/components/label';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import POISearch from './POISearch.svelte';
|
||||||
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,7 +15,6 @@
|
|||||||
|
|
||||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||||
|
|
||||||
// Form state
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
let latitude = $state('');
|
let latitude = $state('');
|
||||||
@@ -23,8 +25,8 @@
|
|||||||
let selectedFiles = $state<FileList | null>(null);
|
let selectedFiles = $state<FileList | null>(null);
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||||
|
let useManualLocation = $state(false);
|
||||||
|
|
||||||
// Categories
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'cafe', label: 'Café' },
|
{ value: 'cafe', label: 'Café' },
|
||||||
{ value: 'restaurant', label: 'Restaurant' },
|
{ value: 'restaurant', label: 'Restaurant' },
|
||||||
@@ -35,21 +37,40 @@
|
|||||||
{ value: 'other', label: 'Other' }
|
{ value: 'other', label: 'Other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Auto-fill location when modal opens
|
let showModal = $state(true);
|
||||||
|
let isMobile = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$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();
|
latitude = $coordinates.latitude.toString();
|
||||||
longitude = $coordinates.longitude.toString();
|
longitude = $coordinates.longitude.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle file selection
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
selectedFiles = target.files;
|
selectedFiles = target.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload media files
|
|
||||||
async function uploadMedia(): Promise<void> {
|
async function uploadMedia(): Promise<void> {
|
||||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
@@ -71,7 +92,6 @@
|
|||||||
uploadedMedia = result.media;
|
uploadedMedia = result.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit form
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const lat = parseFloat(latitude);
|
const lat = parseFloat(latitude);
|
||||||
const lng = parseFloat(longitude);
|
const lng = parseFloat(longitude);
|
||||||
@@ -83,12 +103,10 @@
|
|||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload media first if any
|
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
await uploadMedia();
|
await uploadMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the find
|
|
||||||
const response = await fetch('/api/finds', {
|
const response = await fetch('/api/finds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -110,11 +128,8 @@
|
|||||||
throw new Error('Failed to create find');
|
throw new Error('Failed to create find');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form and close modal
|
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
showModal = false;
|
||||||
|
|
||||||
// Notify parent about new find creation
|
|
||||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating find:', 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() {
|
function resetForm() {
|
||||||
title = '';
|
title = '';
|
||||||
description = '';
|
description = '';
|
||||||
locationName = '';
|
locationName = '';
|
||||||
|
latitude = '';
|
||||||
|
longitude = '';
|
||||||
category = 'cafe';
|
category = 'cafe';
|
||||||
isPublic = true;
|
isPublic = true;
|
||||||
selectedFiles = null;
|
selectedFiles = null;
|
||||||
uploadedMedia = [];
|
uploadedMedia = [];
|
||||||
|
useManualLocation = false;
|
||||||
|
|
||||||
// Reset file input
|
|
||||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
@@ -142,291 +173,443 @@
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
showModal = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<Modal bind:showModal={isOpen} positioning="center">
|
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||||
{#snippet header()}
|
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||||
<h2>Create Find</h2>
|
<SheetHeader>
|
||||||
{/snippet}
|
<SheetTitle>Create Find</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div class="form-container">
|
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}}
|
}}
|
||||||
|
class="form"
|
||||||
>
|
>
|
||||||
<div class="form-group">
|
<div class="form-content">
|
||||||
<label for="title">Title *</label>
|
<div class="field">
|
||||||
<Input name="title" placeholder="What did you find?" required bind:value={title} />
|
<Label for="title">What did you find?</Label>
|
||||||
<span class="char-count">{title.length}/100</span>
|
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label for="description">Description</label>
|
<Label for="description">Tell us about it</Label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Tell us about this place..."
|
placeholder="The best cappuccino in town..."
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
></textarea>
|
></textarea>
|
||||||
<span class="char-count">{description.length}/500</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="location-section">
|
||||||
<label for="location-name">Location Name</label>
|
<div class="location-header">
|
||||||
|
<Label>Location</Label>
|
||||||
|
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||||
|
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if useManualLocation}
|
||||||
|
<div class="field">
|
||||||
|
<Label for="location-name">Location name</Label>
|
||||||
<Input
|
<Input
|
||||||
name="location-name"
|
name="location-name"
|
||||||
placeholder="e.g., Café Belga, Brussels"
|
placeholder="Café Central, Brussels"
|
||||||
bind:value={locationName}
|
bind:value={locationName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="form-row">
|
<POISearch
|
||||||
<div class="form-group">
|
onPlaceSelected={handlePlaceSelected}
|
||||||
<label for="latitude">Latitude *</label>
|
placeholder="Search for cafés, restaurants, landmarks..."
|
||||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
label=""
|
||||||
</div>
|
showNearbyButton={true}
|
||||||
<div class="form-group">
|
/>
|
||||||
<label for="longitude">Longitude *</label>
|
{/if}
|
||||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="field-group">
|
||||||
<label for="category">Category</label>
|
<div class="field">
|
||||||
<select name="category" bind:value={category}>
|
<Label for="category">Category</Label>
|
||||||
|
<select name="category" bind:value={category} class="select">
|
||||||
{#each categories as cat (cat.value)}
|
{#each categories as cat (cat.value)}
|
||||||
<option value={cat.value}>{cat.label}</option>
|
<option value={cat.value}>{cat.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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="form-group">
|
<div class="field">
|
||||||
<label for="media-files">Photos/Videos (max 5)</label>
|
<Label for="media-files">Add photo or video</Label>
|
||||||
|
<div class="file-upload">
|
||||||
<input
|
<input
|
||||||
id="media-files"
|
id="media-files"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||||
multiple
|
|
||||||
max="5"
|
|
||||||
onchange={handleFileChange}
|
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}
|
{#if selectedFiles && selectedFiles.length > 0}
|
||||||
<div class="file-preview">
|
<div class="file-selected">
|
||||||
{#each Array.from(selectedFiles) as file (file.name)}
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
<span class="file-name">{file.name}</span>
|
<path
|
||||||
{/each}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{#if useManualLocation || (!latitude && !longitude)}
|
||||||
<label class="privacy-toggle">
|
<div class="field-group">
|
||||||
<input type="checkbox" bind:checked={isPublic} />
|
<div class="field">
|
||||||
<span class="checkmark"></span>
|
<Label for="latitude">Latitude</Label>
|
||||||
Make this find public
|
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||||
</label>
|
|
||||||
<p class="privacy-help">
|
|
||||||
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
<div class="form-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button secondary"
|
onclick={() => (useManualLocation = true)}
|
||||||
onclick={closeModal}
|
class="edit-coords-button"
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||||
{isSubmitting ? 'Creating...' : 'Create Find'}
|
{isSubmitting ? 'Creating...' : 'Create Find'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</SheetContent>
|
||||||
</Modal>
|
</Sheet>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.form-container {
|
:global(.create-find-sheet) {
|
||||||
padding: 24px;
|
padding: 0 !important;
|
||||||
max-height: 70vh;
|
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;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.field {
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.field-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
@media (max-width: 640px) {
|
||||||
display: block;
|
.field-group {
|
||||||
margin-bottom: 8px;
|
grid-template-columns: 1fr;
|
||||||
font-weight: 500;
|
}
|
||||||
color: #333;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: 'Washington', serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
min-height: 80px;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 0.75rem;
|
||||||
border: none;
|
border: 1px solid hsl(var(--border));
|
||||||
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;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #f9f9f9;
|
background: hsl(var(--background));
|
||||||
cursor: pointer;
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file']:hover {
|
textarea:focus {
|
||||||
border-color: #999;
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-count {
|
textarea::placeholder {
|
||||||
font-size: 0.8rem;
|
color: hsl(var(--muted-foreground));
|
||||||
color: #666;
|
|
||||||
text-align: right;
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-preview {
|
.select {
|
||||||
margin-top: 8px;
|
padding: 0.75rem;
|
||||||
display: flex;
|
border: 1px solid hsl(var(--border));
|
||||||
flex-wrap: wrap;
|
border-radius: 8px;
|
||||||
gap: 8px;
|
background: hsl(var(--background));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.select:focus {
|
||||||
background-color: #f0f0f0;
|
border-color: hsl(var(--primary));
|
||||||
padding: 4px 8px;
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle {
|
.privacy-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
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'] {
|
.privacy-toggle input[type='checkbox'] {
|
||||||
margin-right: 8px;
|
width: 16px;
|
||||||
width: 18px;
|
height: 16px;
|
||||||
height: 18px;
|
accent-color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-help {
|
.file-upload {
|
||||||
font-size: 0.8rem;
|
position: relative;
|
||||||
color: #666;
|
border: 2px dashed hsl(var(--border));
|
||||||
margin-top: 4px;
|
border-radius: 8px;
|
||||||
margin-left: 26px;
|
background: hsl(var(--muted) / 0.3);
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 3.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.file-upload:hover {
|
||||||
opacity: 0.6;
|
border-color: hsl(var(--primary));
|
||||||
cursor: not-allowed;
|
background: hsl(var(--muted) / 0.5);
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled:hover {
|
.file-input {
|
||||||
transform: none;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.secondary {
|
.file-content {
|
||||||
background-color: #6c757d;
|
display: flex;
|
||||||
color: white;
|
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) {
|
.file-content span {
|
||||||
background-color: #5a6268;
|
font-size: 0.875rem;
|
||||||
transform: translateY(-1px);
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.form-container {
|
.form-content {
|
||||||
padding: 16px;
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.actions {
|
||||||
grid-template-columns: 1fr;
|
padding: 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions :global(button) {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
title?: string;
|
title?: string;
|
||||||
showEmpty?: boolean;
|
showEmpty?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
onFindExplore,
|
onFindExplore,
|
||||||
title = 'Finds',
|
title = 'Finds',
|
||||||
showEmpty = true,
|
showEmpty = true,
|
||||||
emptyMessage = 'No finds to display'
|
emptyMessage = 'No finds to display',
|
||||||
|
hideTitle = false
|
||||||
}: FindsListProps = $props();
|
}: FindsListProps = $props();
|
||||||
|
|
||||||
function handleFindExplore(id: string) {
|
function handleFindExplore(id: string) {
|
||||||
@@ -42,9 +44,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="finds-feed">
|
<section class="finds-feed">
|
||||||
|
{#if !hideTitle}
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
<h2 class="feed-title">{title}</h2>
|
<h2 class="feed-title">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if finds.length > 0}
|
{#if finds.length > 0}
|
||||||
<div class="feed-container">
|
<div class="feed-container">
|
||||||
@@ -98,6 +102,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.finds-feed {
|
.finds-feed {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-header {
|
.feed-header {
|
||||||
|
|||||||
403
src/lib/components/POISearch.svelte
Normal file
403
src/lib/components/POISearch.svelte
Normal 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
194
src/lib/utils/places.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLikedByUser?: boolean;
|
isLikedByUser?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
media: Array<{
|
media: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
findId: string;
|
findId: string;
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
};
|
};
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -102,6 +104,7 @@
|
|||||||
},
|
},
|
||||||
likeCount: serverFind.likeCount,
|
likeCount: serverFind.likeCount,
|
||||||
isLiked: serverFind.isLikedByUser,
|
isLiked: serverFind.isLikedByUser,
|
||||||
|
isFromFriend: serverFind.isFromFriend,
|
||||||
media: serverFind.media?.map(
|
media: serverFind.media?.map(
|
||||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||||
type: m.type,
|
type: m.type,
|
||||||
@@ -120,7 +123,7 @@
|
|||||||
case 'public':
|
case 'public':
|
||||||
return allFinds.filter((find) => find.isPublic === 1);
|
return allFinds.filter((find) => find.isPublic === 1);
|
||||||
case 'friends':
|
case 'friends':
|
||||||
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
|
return allFinds.filter((find) => find.isFromFriend === true);
|
||||||
case 'mine':
|
case 'mine':
|
||||||
return allFinds.filter((find) => find.userId === data.user!.id);
|
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||||
case 'all':
|
case 'all':
|
||||||
@@ -217,11 +220,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="finds-section">
|
<div class="finds-section">
|
||||||
<div class="finds-header">
|
<div class="finds-sticky-header">
|
||||||
|
<div class="finds-header-content">
|
||||||
<div class="finds-title-section">
|
<div class="finds-title-section">
|
||||||
|
<h2 class="finds-title">Finds</h2>
|
||||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||||
</div>
|
</div>
|
||||||
<FindsList {finds} onFindExplore={handleFindExplore} />
|
|
||||||
<Button onclick={openCreateModal} class="create-find-button">
|
<Button onclick={openCreateModal} class="create-find-button">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
<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="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
@@ -231,6 +235,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Floating action button for mobile -->
|
<!-- Floating action button for mobile -->
|
||||||
@@ -285,27 +291,44 @@
|
|||||||
.finds-section {
|
.finds-section {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-header {
|
.finds-sticky-header {
|
||||||
position: relative;
|
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 {
|
.finds-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
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) {
|
:global(.create-find-button) {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
@@ -338,14 +361,25 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-section {
|
.finds-sticky-header {
|
||||||
padding: 16px;
|
padding: 16px 16px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title-section {
|
.finds-title-section {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
}
|
||||||
|
|
||||||
|
.finds-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.create-find-button) {
|
:global(.create-find-button) {
|
||||||
@@ -366,8 +400,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-section {
|
.finds-sticky-header {
|
||||||
padding: 12px;
|
padding: 12px 12px 8px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,7 +107,15 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
SELECT 1 FROM ${findLike}
|
SELECT 1 FROM ${findLike}
|
||||||
WHERE ${findLike.findId} = ${find.id}
|
WHERE ${findLike.findId} = ${find.id}
|
||||||
AND ${findLike.userId} = ${locals.user.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)
|
.from(find)
|
||||||
.innerJoin(user, eq(find.userId, user.id))
|
.innerJoin(user, eq(find.userId, user.id))
|
||||||
@@ -199,7 +207,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
...findItem,
|
...findItem,
|
||||||
profilePictureUrl: userProfilePictureUrl,
|
profilePictureUrl: userProfilePictureUrl,
|
||||||
media: mediaWithSignedUrls,
|
media: mediaWithSignedUrls,
|
||||||
isLikedByUser: Boolean(findItem.isLikedByUser)
|
isLikedByUser: Boolean(findItem.isLikedByUser),
|
||||||
|
isFromFriend: Boolean(findItem.isFromFriend)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,30 +24,46 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
|
|
||||||
if (type === 'friends') {
|
if (type === 'friends') {
|
||||||
// Get accepted friendships where user is either sender or receiver
|
// Get accepted friendships where user is either sender or receiver
|
||||||
friendships = await db
|
const friendshipsRaw = await db
|
||||||
.select({
|
.select({
|
||||||
id: friendship.id,
|
id: friendship.id,
|
||||||
userId: friendship.userId,
|
userId: friendship.userId,
|
||||||
friendId: friendship.friendId,
|
friendId: friendship.friendId,
|
||||||
status: friendship.status,
|
status: friendship.status,
|
||||||
createdAt: friendship.createdAt,
|
createdAt: friendship.createdAt
|
||||||
friendUsername: user.username,
|
|
||||||
friendProfilePictureUrl: user.profilePictureUrl
|
|
||||||
})
|
})
|
||||||
.from(friendship)
|
.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(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(friendship.status, status),
|
eq(friendship.status, status),
|
||||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
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') {
|
} else if (type === 'sent') {
|
||||||
// Get friend requests sent by current user
|
// Get friend requests sent by current user
|
||||||
friendships = await db
|
friendships = await db
|
||||||
|
|||||||
83
src/routes/api/places/+server.ts
Normal file
83
src/routes/api/places/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -38,25 +38,22 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
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({
|
.select({
|
||||||
userId: friendship.userId,
|
userId: friendship.userId,
|
||||||
friendId: friendship.friendId,
|
friendId: friendship.friendId,
|
||||||
status: friendship.status
|
status: friendship.status
|
||||||
})
|
})
|
||||||
.from(friendship)
|
.from(friendship)
|
||||||
.where(
|
.where(or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id)));
|
||||||
or(
|
|
||||||
and(
|
// Filter to only include friendships with users in our search results
|
||||||
eq(friendship.userId, locals.user.id),
|
existingFriendships = allUserFriendships.filter((f) => {
|
||||||
or(...userIds.map((id) => eq(friendship.friendId, id)))
|
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||||
),
|
return userIds.includes(otherUserId);
|
||||||
and(
|
});
|
||||||
eq(friendship.friendId, locals.user.id),
|
|
||||||
or(...userIds.map((id) => eq(friendship.userId, id)))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a map of friendship statuses
|
// Create a map of friendship statuses
|
||||||
|
|||||||
@@ -134,6 +134,8 @@
|
|||||||
// Search users when query changes with debounce
|
// Search users when query changes with debounce
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Track searchQuery dependency explicitly
|
||||||
|
searchQuery;
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(searchUsers, 300);
|
searchTimeout = setTimeout(searchUsers, 300);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user