13 Commits

Author SHA1 Message Date
2efd4969e7 add:enhance comments list with scroll, limit, and styling
- Add maxComments, showCommentForm and isScrollable props to
CommentsList
to limit displayed comments, toggle the form, and enable a scrollable
comments container. Show a "+N more comments" indicator when limited.
- Adjust CommentForm and list styles (padding, background, flex
behavior, responsive heights). Pass currentUserId and new props from
FindPreview and FindCard where applicable.
2025-11-06 12:47:35 +01:00
b8c88d7a58 feat:comments
added comments feature.
- this makes sure the user has the ability to comment on a certain find.
it includes functionality to the api-sync layer so that multiple
components can stay in sync with the comments state.
- it also added the needed db migrations to the finds
- it adds some ui components needed to create, list and view comments
2025-11-06 12:38:32 +01:00
af49ed6237 logs:update logs 2025-11-04 12:25:21 +01:00
d3adac8acc add:ProfilePicture component and replace Avatar
Move avatar initials, fallback styling, and loading/loading-state UI
into ProfilePicture and update components to use it. Export
ProfilePicture from the lib index.
2025-11-04 12:23:08 +01:00
9800be0147 fix:Adjust media layout, sizing, and mobile sheet
Render media container only when media exists and remove the no-media
placeholder. Constrain images and videos with a max-height of 600px
(height:auto) and set images to display:block. In preview, use
object-fit: contain for media images and reduce mobile sheet height from
80vh to 50vh.
2025-11-04 12:09:05 +01:00
4c973c4e7d fix:some csp issues 2025-10-29 00:23:26 +01:00
d7fe9091ce fix:new child subscription
this fix makes sure that when the client initializes a new child that
needs to subscribe for updates, it first checks whether this item
already has a currrent state and uses this, so that a new child always
starts in sync with the global state.
2025-10-29 00:14:26 +01:00
6620cc6078 feat:implement a sync-service
this new feature make sure that serverside actions are all in sync with
client. when a client is multiple times subscribed to the same server
side action it needs to be synced on all children. this implementation
gives access to one client synced status that will handle all api
actions in a queue.
2025-10-29 00:09:01 +01:00
3b3ebc2873 fix:continuously watch location 2025-10-27 15:07:24 +01:00
fef7c160e2 feat:use GMaps places api for searching poi's 2025-10-27 14:57:54 +01:00
43afa6dacc update:logboek 2025-10-21 14:49:55 +02:00
aa9ed77499 UI:Refactor create find modal UI and update finds list/header layout
- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
2025-10-21 14:44:25 +02:00
e1c5846fa4 fix:friends filtering 2025-10-21 14:11:28 +02:00
34 changed files with 3778 additions and 706 deletions

View File

@@ -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"

View File

@@ -0,0 +1,11 @@
CREATE TABLE "find_comment" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"user_id" text NOT NULL,
"content" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,521 @@
{
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_comment": {
"name": "find_comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_comment_find_id_find_id_fk": {
"name": "find_comment_find_id_find_id_fk",
"tableFrom": "find_comment",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_comment_user_id_user_id_fk": {
"name": "find_comment_user_id_user_id_fk",
"tableFrom": "find_comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -43,6 +43,13 @@
"when": 1760631798851, "when": 1760631798851,
"tag": "0005_rapid_warpath", "tag": "0005_rapid_warpath",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1762428302491,
"tag": "0006_strange_firebird",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,241 +0,0 @@
# Serengo Finds Feature Implementation Log
## Project Overview
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 & 2D Complete + UI Integration ✅
### What Serengo Currently Has:
- Complete finds creation with photo uploads and location data
- Interactive map with find markers and detailed previews
- Responsive design with map/list view toggle
- **NEW**: Modern WebP image processing with JPEG fallbacks
- **NEW**: Full video support with custom VideoPlayer component
- **NEW**: Like/unlike system with optimistic UI updates
- **NEW**: Complete friends & privacy system with friend requests and filtered feeds
- R2 storage integration with enhanced media processing
- Full database schema with proper relationships and social features
- Type-safe API endpoints and error handling
- Mobile-optimized UI with floating action button
### Production Ready Features:
- Create/view finds with photos, descriptions, and categories
- Location-based filtering and discovery
- Media carousel with navigation (supports images and videos)
- **NEW**: Video playback with custom controls and fullscreen support
- **NEW**: Like/unlike finds with real-time count updates
- **NEW**: Animated like buttons with heart animations
- **NEW**: Friend request system with send/accept/decline functionality
- **NEW**: Friends management page with user search and relationship status
- **NEW**: Privacy-aware find feeds with friend-specific visibility filters
- Share functionality with clipboard copy
- Real-time map markers with click-to-preview
- Grid layout for find browsing
---
## Phase 2 Implementation Plan (Updated October 14, 2025)
### Technical Requirements & Standards:
- **Media Formats**: Use modern WebP for images, WebM/AV1 for videos
- **UI Components**: Leverage existing SHADCN components for consistency
- **Code Quality**: Follow Svelte 5 best practices with clean, reusable components
- **POI Search**: Integrate Google Maps Places API for location search
- **Type Safety**: Maintain strict TypeScript throughout
### Phase 2A: Modern Media Support ✅ COMPLETE
**Goal**: Upgrade to modern file formats and video support
**Completed Tasks:**
- [x] Update media processor to output WebP images (with JPEG fallback)
- [x] Implement MP4 video processing with thumbnail generation
- [x] Create reusable VideoPlayer component using SHADCN
- [x] Enhanced database schema with fallback URL support
- [x] Optimize compression settings for web delivery
**Implementation Details:**
- Updated `media-processor.ts` to generate both WebP and JPEG versions
- Enhanced `findMedia` table with `fallbackUrl` and `fallbackThumbnailUrl` fields
- Created `VideoPlayer.svelte` with custom controls, progress bar, and fullscreen support
- Added video placeholder SVG for consistent UI
- Maintained backward compatibility with existing media
**Actual Effort**: ~12 hours
### Phase 2B: Enhanced Location & POI Search (Priority: High)
**Goal**: Google Maps integration for better location discovery
**Tasks:**
- [ ] Integrate Google Maps Places API for POI search
- [ ] Create location search component with autocomplete
- [ ] Add "Search nearby" functionality in CreateFindModal
- [ ] Implement reverse geocoding for address display
- [ ] Add place details (hours, ratings, etc.) from Google Places
**Estimated Effort**: 12-15 hours
### Phase 2C: Social Interactions ✅ COMPLETE
**Goal**: Like system and user engagement
**Completed Tasks:**
- [x] Implement like/unlike API using existing findLike table
- [x] Create reusable LikeButton component with animations
- [x] Add like counts and user's liked status to find queries
- [x] Add optimistic UI updates for instant feedback
- [x] **COMPLETED**: Full UI integration into FindCard and FindPreview components
- [x] **COMPLETED**: Updated all data interfaces to support like information
- [x] **COMPLETED**: Enhanced media carousel with VideoPlayer component integration
**Implementation Details:**
- Created `/api/finds/[findId]/like` endpoints for POST (like) and DELETE (unlike)
- Built `LikeButton.svelte` with optimistic UI updates and heart animations
- Enhanced find queries to include like counts and user's liked status via SQL aggregation
- Integrated LikeButton into both FindCard (list view) and FindPreview (modal view)
- Updated VideoPlayer usage throughout the application for consistent video playback
- Maintained type safety across all interfaces and data flows
**Future Task:**
- [ ] Build "My Liked Finds" collection page (moved to Phase 2G)
**Actual Effort**: ~15 hours (including UI integration)
### Phase 2D: Friends & Privacy System ✅ COMPLETE
**Goal**: Social connections and privacy controls
**Completed Tasks:**
- [x] Build friend request system (send/accept/decline)
- [x] Create Friends management page using SHADCN components
- [x] Implement friend search with user suggestions
- [x] Update find privacy logic to respect friendships
- [x] Add friend-specific find visibility filters
**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)
**Goal**: Better find discovery and organization
**Tasks:**
- [ ] Create FilterPanel component with category/distance/date filters
- [ ] Implement text search through find titles/descriptions
- [ ] Add sort options (recent, popular, nearest)
- [ ] Build infinite scroll for find feeds
- [ ] Add "Similar finds nearby" recommendations
**Estimated Effort**: 15-18 hours
### Phase 2F: Enhanced Sharing & Individual Pages (Priority: Medium)
**Goal**: Better sharing and find discoverability
**Tasks:**
- [ ] Create individual find detail pages (`/finds/[id]`)
- [ ] Add social media sharing with OpenGraph meta tags
- [ ] Implement "Get Directions" integration with map apps
- [ ] Build shareable find links with previews
- [ ] Add "Copy link" functionality
**Estimated Effort**: 12-15 hours
---
## Development Standards
### Component Architecture:
- Use composition pattern with reusable SHADCN components
- Implement proper TypeScript interfaces for all props
- Follow Svelte 5 runes pattern ($props, $derived, $effect)
- Create clean separation between UI and business logic
### Code Quality:
- Maintain existing formatting (tabs, single quotes, 100 char width)
- Use descriptive variable names and function signatures
- Implement proper error boundaries and loading states
- Add accessibility attributes (ARIA labels, keyboard navigation)
### Performance:
- Lazy load media content and heavy components
- Implement proper caching strategies for API calls
- Use virtual scrolling for long lists when needed
- Optimize images/videos for web delivery
**Total Phase 2 Estimated Effort**: 82-105 hours
**Total Phase 2 Completed Effort**: ~49 hours (Phases 2A: 12h + 2C: 15h + 2D: 22h)
**Expected Timeline**: 8-10 weeks (part-time development)
## Next Steps:
1.**Completed**: Phase 2A (Modern Media Support) for immediate impact
2. **Next Priority**: Phase 2B (Google Maps POI) for better UX
3.**Completed**: Phase 2C (Social Interactions) for user engagement
4.**Completed**: Phase 2D (Friends & Privacy System) for social connections
5. **Continue with**: Phase 2E (Advanced Filtering) or 2F (Enhanced Sharing) based on user feedback
## Production Ready Features Summary:
**Core Functionality:**
- Create/view finds with photos, videos, descriptions, and categories
- Location-based filtering and discovery with interactive map
- Media carousel with navigation (WebP images and MP4 videos)
- Real-time map markers with click-to-preview functionality
**Social Features:**
- Like/unlike finds with real-time count updates and animations
- Friend request system (send, accept, decline, remove)
- Friends management page with user search capabilities
- Privacy-aware find feeds with customizable visibility filters
**Technical Excellence:**
- Modern media formats (WebP, MP4) with fallback support
- Type-safe API endpoints with comprehensive error handling
- Mobile-optimized responsive design
- Performance-optimized with proper caching and lazy loading

View File

@@ -2,6 +2,111 @@
## Oktober 2025 ## Oktober 2025
### 4 November 2025 - 1 uren
**Werk uitgevoerd:**
- **UI Consistency & Media Layout Improvements**
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
- Media layout en sizing verbeteringen voor betere responsive design
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
- Component refactoring voor consistentere UI across applicatie
- Loading states en fallback styling geconsolideerd
**Commits:**
- d3adac8 - add:ProfilePicture component and replace Avatar
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
**Details:**
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
- Avatar componenten vervangen door ProfilePicture voor consistentie
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
- Max-height van 600px toegevoegd voor images en videos met height:auto
- Object-fit: contain toegepast voor media images in preview
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
- Loading states UI geconsolideerd in ProfilePicture component
- Component library verder uitgebreid met herbruikbare UI elementen
---
### 27-29 Oktober 2025 - 10 uren
**Werk uitgevoerd:**
- **Phase 3: Google Places Integration & Sync Service**
- Complete implementatie van sync-service voor API data synchronisatie
- Google Maps Places API integratie voor POI zoekfunctionaliteit
- Location tracking optimalisaties met continuous watching
- CSP (Content Security Policy) fixes voor verbeterde security
- Child subscription fixes voor data consistency
**Commits:**
- 4c973c4 - fix:some csp issues
- d7fe909 - fix:new child subscription
- 6620cc6 - feat:implement a sync-service
- 3b3ebc2 - fix:continuously watch location
- fef7c16 - feat:use GMaps places api for searching poi's
**Details:**
- Complete sync-service architectuur voor real-time data synchronisatie
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
- Security verbeteringen door CSP issues op te lossen
- Data consistency verbeteringen met child subscription fixes
- Enhanced POI search capabilities met Google Maps integration
---
### 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 ### 16 Oktober 2025 (Woensdag) - 6 uren
**Werk uitgevoerd:** **Werk uitgevoerd:**
@@ -17,7 +122,14 @@
- ProfilePanel uitgebreid met Friends navigatielink - ProfilePanel uitgebreid met Friends navigatielink
- Type-safe implementatie met volledige error handling - Type-safe implementatie met volledige error handling
**Commits:** Nog niet gecommit (staged changes) **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:** **Details:**
@@ -46,7 +158,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:**
@@ -267,9 +381,9 @@
## Totaal Overzicht ## Totaal Overzicht
**Totale geschatte uren:** 61 uren **Totale geschatte uren:** 80 uren
**Werkdagen:** 10 dagen **Werkdagen:** 14 dagen
**Gemiddelde uren per dag:** 6.1 uur **Gemiddelde uren per dag:** 5.8 uur
### Project Milestones: ### Project Milestones:
@@ -283,6 +397,10 @@
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 10. **16 Okt**: Friends & Privacy System implementatie
11. **20 Okt**: Search logic improvements
12. **21 Okt**: UI refinement en bug fixes
13. **27-29 Okt**: Google Places Integration & Sync Service
14. **4 Nov**: UI Consistency & Media Layout Improvements
### Hoofdfunctionaliteiten geïmplementeerd: ### Hoofdfunctionaliteiten geïmplementeerd:
@@ -309,3 +427,7 @@
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid - [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- [x] Friends management pagina met gebruikerszoekfunctionaliteit - [x] Friends management pagina met gebruikerszoekfunctionaliteit
- [x] Real-time find filtering op basis van privacy instellingen - [x] Real-time find filtering op basis van privacy instellingen
- [x] Google Maps Places API integratie voor POI zoekfunctionaliteit
- [x] Sync-service voor API data synchronisatie
- [x] Continuous location watching voor nauwkeurige tracking
- [x] CSP security verbeteringen

View File

@@ -50,8 +50,8 @@ export const handle: Handle = async ({ event, resolve }) => {
"worker-src 'self' blob:; " + "worker-src 'self' blob:; " +
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
"font-src 'self' fonts.gstatic.com; " + "font-src 'self' fonts.gstatic.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " + "img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
"media-src 'self' *.r2.cloudflarestorage.com; " + "media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
"connect-src 'self' *.openstreetmap.org; " + "connect-src 'self' *.openstreetmap.org; " +
"frame-ancestors 'none'; " + "frame-ancestors 'none'; " +
"base-uri 'self'; " + "base-uri 'self'; " +

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import { Trash2 } from '@lucide/svelte';
import type { CommentState } from '$lib/stores/api-sync';
interface CommentProps {
comment: CommentState;
showDeleteButton?: boolean;
onDelete?: (commentId: string) => void;
}
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
function handleDelete() {
if (onDelete) {
onDelete(comment.id);
}
}
function formatDate(date: Date | string): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - dateObj.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) {
return 'just now';
} else if (minutes < 60) {
return `${minutes}m`;
} else if (hours < 24) {
return `${hours}h`;
} else if (days < 7) {
return `${days}d`;
} else {
return dateObj.toLocaleDateString();
}
}
</script>
<div class="comment">
<div class="comment-avatar">
<ProfilePicture
username={comment.user.username}
profilePictureUrl={comment.user.profilePictureUrl}
class="avatar-small"
/>
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-username">@{comment.user.username}</span>
<span class="comment-time">{formatDate(comment.createdAt)}</span>
</div>
<div class="comment-text">
{comment.content}
</div>
</div>
{#if showDeleteButton}
<div class="comment-actions">
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
<Trash2 size={14} />
</Button>
</div>
{/if}
</div>
<style>
.comment {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid hsl(var(--border));
}
.comment:last-child {
border-bottom: none;
}
.comment-avatar {
flex-shrink: 0;
}
:global(.avatar-small) {
width: 32px;
height: 32px;
}
.comment-content {
flex: 1;
min-width: 0;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.comment-username {
font-weight: 600;
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.comment-time {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.comment-text {
font-size: 0.875rem;
line-height: 1.4;
color: hsl(var(--foreground));
word-wrap: break-word;
}
.comment-actions {
flex-shrink: 0;
display: flex;
align-items: flex-start;
padding-top: 0.25rem;
}
:global(.delete-button) {
color: hsl(var(--muted-foreground));
padding: 0.25rem;
height: auto;
min-height: 0;
}
:global(.delete-button:hover) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.1);
}
</style>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input';
import { Send } from '@lucide/svelte';
interface CommentFormProps {
onSubmit: (content: string) => void;
placeholder?: string;
disabled?: boolean;
}
let { onSubmit, placeholder = 'Add a comment...', disabled = false }: CommentFormProps = $props();
let content = $state('');
let isSubmitting = $state(false);
function handleSubmit(event: Event) {
event.preventDefault();
const trimmedContent = content.trim();
if (!trimmedContent || isSubmitting || disabled) {
return;
}
if (trimmedContent.length > 500) {
return;
}
isSubmitting = true;
try {
onSubmit(trimmedContent);
content = '';
} catch (error) {
console.error('Error submitting comment:', error);
} finally {
isSubmitting = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
}
}
const canSubmit = $derived(
content.trim().length > 0 && content.length <= 500 && !isSubmitting && !disabled
);
</script>
<form class="comment-form" onsubmit={handleSubmit}>
<div class="input-container">
<Input
bind:value={content}
{placeholder}
disabled={disabled || isSubmitting}
class="comment-input"
onkeydown={handleKeyDown}
/>
<Button type="submit" variant="ghost" size="sm" disabled={!canSubmit} class="submit-button">
<Send size={16} />
</Button>
</div>
{#if content.length > 450}
<div class="character-count" class:warning={content.length > 500}>
{content.length}/500
</div>
{/if}
</form>
<style>
.comment-form {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
flex-shrink: 0;
}
.input-container {
display: flex;
gap: 0.5rem;
align-items: center;
}
:global(.comment-input) {
flex: 1;
min-height: 40px;
resize: none;
}
:global(.submit-button) {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
padding: 0.5rem;
}
:global(.submit-button:not(:disabled)) {
color: hsl(var(--primary));
}
:global(.submit-button:not(:disabled):hover) {
background: hsl(var(--primary) / 0.1);
}
.character-count {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-align: right;
margin-top: 0.25rem;
}
.character-count.warning {
color: hsl(var(--destructive));
}
</style>

View File

@@ -0,0 +1,243 @@
<script lang="ts">
import Comment from '$lib/components/Comment.svelte';
import CommentForm from '$lib/components/CommentForm.svelte';
import { Skeleton } from '$lib/components/skeleton';
import { apiSync } from '$lib/stores/api-sync';
import { onMount } from 'svelte';
interface CommentsListProps {
findId: string;
currentUserId?: string;
collapsed?: boolean;
maxComments?: number;
showCommentForm?: boolean;
isScrollable?: boolean;
}
let {
findId,
currentUserId,
collapsed = true,
maxComments,
showCommentForm = true,
isScrollable = false
}: CommentsListProps = $props();
let isExpanded = $state(!collapsed);
let hasLoadedComments = $state(false);
const commentsState = apiSync.subscribeFindComments(findId);
onMount(() => {
if (isExpanded && !hasLoadedComments) {
loadComments();
}
});
async function loadComments() {
if (hasLoadedComments) return;
hasLoadedComments = true;
await apiSync.loadComments(findId);
}
function toggleExpanded() {
isExpanded = !isExpanded;
if (isExpanded && !hasLoadedComments) {
loadComments();
}
}
async function handleAddComment(content: string) {
await apiSync.addComment(findId, content);
}
async function handleDeleteComment(commentId: string) {
await apiSync.deleteComment(commentId, findId);
}
function canDeleteComment(comment: any): boolean {
return Boolean(
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
);
}
</script>
{#snippet loadingSkeleton()}
<div class="loading-skeleton">
{#each Array(3) as _}
<div class="comment-skeleton">
<Skeleton class="avatar-skeleton" />
<div class="content-skeleton">
<Skeleton class="header-skeleton" />
<Skeleton class="text-skeleton" />
</div>
</div>
{/each}
</div>
{/snippet}
<div class="comments-list">
{#if collapsed}
<button class="toggle-button" onclick={toggleExpanded}>
{#if isExpanded}
Hide comments ({$commentsState.commentCount})
{:else if $commentsState.commentCount > 0}
View comments ({$commentsState.commentCount})
{:else}
Add comment
{/if}
</button>
{/if}
{#if isExpanded}
<div class="comments-container">
{#if $commentsState.isLoading && !hasLoadedComments}
{@render loadingSkeleton()}
{:else if $commentsState.error}
<div class="error-message">
Failed to load comments.
<button class="retry-button" onclick={loadComments}> Try again </button>
</div>
{:else}
<div class="comments" class:scrollable={isScrollable}>
{#each maxComments ? $commentsState.comments.slice(0, maxComments) : $commentsState.comments as comment (comment.id)}
<Comment
{comment}
showDeleteButton={canDeleteComment(comment)}
onDelete={handleDeleteComment}
/>
{:else}
<div class="no-comments">No comments yet. Be the first to comment!</div>
{/each}
{#if maxComments && $commentsState.comments.length > maxComments}
<div class="see-more">
<div class="see-more-text">
+{$commentsState.comments.length - maxComments} more comments
</div>
</div>
{/if}
</div>
{/if}
{#if showCommentForm}
<CommentForm onSubmit={handleAddComment} />
{/if}
</div>
{/if}
</div>
<style>
.comments-list {
width: 100%;
}
.toggle-button {
background: none;
border: none;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0;
transition: color 0.2s ease;
}
.toggle-button:hover {
color: hsl(var(--foreground));
}
.comments-container {
border-top: 1px solid hsl(var(--border));
margin-top: 0.5rem;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.comments {
padding: 0.75rem 0;
}
.comments.scrollable {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
min-height: 0;
}
.see-more {
padding: 0.5rem 0;
border-top: 1px solid hsl(var(--border));
margin-top: 0.5rem;
}
.see-more-text {
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-style: italic;
}
.no-comments {
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
padding: 1.5rem 0;
font-style: italic;
}
.error-message {
text-align: center;
color: hsl(var(--destructive));
font-size: 0.875rem;
padding: 1rem 0;
}
.retry-button {
background: none;
border: none;
color: hsl(var(--primary));
text-decoration: underline;
cursor: pointer;
font-size: inherit;
}
.retry-button:hover {
color: hsl(var(--primary) / 0.8);
}
.loading-skeleton {
padding: 0.75rem 0;
}
.comment-skeleton {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
}
:global(.avatar-skeleton) {
width: 32px;
height: 32px;
border-radius: 50%;
}
.content-skeleton {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(.header-skeleton) {
width: 120px;
height: 16px;
}
:global(.text-skeleton) {
width: 80%;
height: 14px;
}
</style>

View File

@@ -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 class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div> </div>
<div class="form-group">
<label for="longitude">Longitude *</label> <div class="field">
<Input name="longitude" type="text" required bind:value={longitude} /> <Label for="description">Tell us about it</Label>
<textarea
name="description"
placeholder="The best cappuccino in town..."
maxlength="500"
bind:value={description}
></textarea>
</div> </div>
</div>
<div class="form-group"> <div class="location-section">
<label for="category">Category</label> <div class="location-header">
<select name="category" bind:value={category}> <Label>Location</Label>
{#each categories as cat (cat.value)} <button type="button" onclick={toggleLocationMode} class="toggle-button">
<option value={cat.value}>{cat.label}</option> {useManualLocation ? 'Use Search' : 'Manual Entry'}
{/each} </button>
</select> </div>
</div>
<div class="form-group"> {#if useManualLocation}
<label for="media-files">Photos/Videos (max 5)</label> <div class="field">
<input <Label for="location-name">Location name</Label>
id="media-files" <Input
type="file" name="location-name"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime" placeholder="Café Central, Brussels"
multiple bind:value={locationName}
max="5" />
onchange={handleFileChange} </div>
/> {:else}
{#if selectedFiles && selectedFiles.length > 0} <POISearch
<div class="file-preview"> onPlaceSelected={handlePlaceSelected}
{#each Array.from(selectedFiles) as file (file.name)} placeholder="Search for cafés, restaurants, landmarks..."
<span class="file-name">{file.name}</span> label=""
{/each} showNearbyButton={true}
/>
{/if}
</div>
<div class="field-group">
<div class="field">
<Label for="category">Category</Label>
<select name="category" bind:value={category} class="select">
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="field">
<Label>Privacy</Label>
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span>{isPublic ? 'Public' : 'Private'}</span>
</label>
</div>
</div>
<div class="field">
<Label for="media-files">Add photo or video</Label>
<div class="file-upload">
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
onchange={handleFileChange}
class="file-input"
/>
<div class="file-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Click to upload</span>
</div>
</div>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-selected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{selectedFiles[0].name}</span>
</div>
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>
<div class="form-group"> <div class="actions">
<label class="privacy-toggle"> <Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
Cancel Cancel
</button> </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>

View File

@@ -3,8 +3,9 @@
import { Badge } from '$lib/components/badge'; import { Badge } from '$lib/components/badge';
import LikeButton from '$lib/components/LikeButton.svelte'; import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar'; import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte'; import CommentsList from '$lib/components/CommentsList.svelte';
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
interface FindCardProps { interface FindCardProps {
id: string; id: string;
@@ -23,6 +24,8 @@
}>; }>;
likeCount?: number; likeCount?: number;
isLiked?: boolean; isLiked?: boolean;
commentCount?: number;
currentUserId?: string;
onExplore?: (id: string) => void; onExplore?: (id: string) => void;
} }
@@ -36,15 +39,19 @@
media, media,
likeCount = 0, likeCount = 0,
isLiked = false, isLiked = false,
commentCount = 0,
currentUserId,
onExplore onExplore
}: FindCardProps = $props(); }: FindCardProps = $props();
let showComments = $state(false);
function handleExplore() { function handleExplore() {
onExplore?.(id); onExplore?.(id);
} }
function getUserInitials(username: string): string { function toggleComments() {
return username.slice(0, 2).toUpperCase(); showComments = !showComments;
} }
</script> </script>
@@ -52,14 +59,11 @@
<!-- Post Header --> <!-- Post Header -->
<div class="post-header"> <div class="post-header">
<div class="user-info"> <div class="user-info">
<Avatar class="avatar"> <ProfilePicture
{#if user.profilePictureUrl} username={user.username}
<AvatarImage src={user.profilePictureUrl} alt={user.username} /> profilePictureUrl={user.profilePictureUrl}
{/if} class="avatar"
<AvatarFallback class="avatar-fallback"> />
{getUserInitials(user.username)}
</AvatarFallback>
</Avatar>
<div class="user-details"> <div class="user-details">
<div class="username">@{user.username}</div> <div class="username">@{user.username}</div>
{#if locationName} {#if locationName}
@@ -80,7 +84,7 @@
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" class="more-button"> <Button variant="ghost" size="sm" class="more-button">
<MoreHorizontal size={16} /> <Ellipsis size={16} />
</Button> </Button>
</div> </div>
@@ -101,8 +105,8 @@
</div> </div>
<!-- Media --> <!-- Media -->
<div class="post-media"> {#if media && media.length > 0}
{#if media && media.length > 0} <div class="post-media">
{#if media[0].type === 'photo'} {#if media[0].type === 'photo'}
<img <img
src={media[0].thumbnailUrl || media[0].url} src={media[0].thumbnailUrl || media[0].url}
@@ -120,29 +124,16 @@
class="media-video" class="media-video"
/> />
{/if} {/if}
{:else} </div>
<div class="no-media"> {/if}
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 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>
</div>
{/if}
</div>
<!-- Post Actions --> <!-- Post Actions -->
<div class="post-actions"> <div class="post-actions">
<div class="action-buttons"> <div class="action-buttons">
<LikeButton findId={id} {isLiked} {likeCount} size="sm" /> <LikeButton findId={id} {isLiked} {likeCount} size="sm" />
<Button variant="ghost" size="sm" class="action-button"> <Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
<MessageCircle size={16} /> <MessageCircle size={16} />
<span>comment</span> <span>{commentCount || 'comment'}</span>
</Button> </Button>
<Button variant="ghost" size="sm" class="action-button"> <Button variant="ghost" size="sm" class="action-button">
<Share size={16} /> <Share size={16} />
@@ -153,6 +144,19 @@
explore explore
</Button> </Button>
</div> </div>
<!-- Comments Section -->
{#if showComments}
<div class="comments-section">
<CommentsList
findId={id}
{currentUserId}
collapsed={false}
maxComments={5}
showCommentForm={true}
/>
</div>
{/if}
</div> </div>
<style> <style>
@@ -258,33 +262,23 @@
/* Media */ /* Media */
.post-media { .post-media {
width: 100%; width: 100%;
aspect-ratio: 16/10; max-height: 600px;
overflow: hidden; overflow: hidden;
background: hsl(var(--muted)); background: hsl(var(--muted));
display: flex;
align-items: center;
justify-content: center;
} }
.media-image { .media-image {
width: 100%; width: 100%;
height: 100%; height: auto;
max-height: 600px;
object-fit: cover; object-fit: cover;
display: block;
} }
:global(.media-video) { :global(.media-video) {
width: 100%; width: 100%;
height: 100%; height: auto;
} max-height: 600px;
.no-media {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0.5;
} }
/* Post Actions */ /* Post Actions */
@@ -317,6 +311,13 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Comments Section */
.comments-section {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
/* Mobile responsive */ /* Mobile responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.post-actions { .post-actions {

View File

@@ -2,7 +2,8 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import LikeButton from '$lib/components/LikeButton.svelte'; import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar'; import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import CommentsList from '$lib/components/CommentsList.svelte';
interface Find { interface Find {
id: string; id: string;
@@ -30,9 +31,10 @@
interface Props { interface Props {
find: Find | null; find: Find | null;
onClose: () => void; onClose: () => void;
currentUserId?: string;
} }
let { find, onClose }: Props = $props(); let { find, onClose, currentUserId }: Props = $props();
let showModal = $state(true); let showModal = $state(true);
let currentMediaIndex = $state(0); let currentMediaIndex = $state(0);
@@ -112,14 +114,11 @@
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content"> <SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
<SheetHeader class="sheet-header"> <SheetHeader class="sheet-header">
<div class="user-section"> <div class="user-section">
<Avatar class="user-avatar"> <ProfilePicture
{#if find.user.profilePictureUrl} username={find.user.username}
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} /> profilePictureUrl={find.user.profilePictureUrl}
{/if} class="user-avatar"
<AvatarFallback class="avatar-fallback"> />
{find.user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div class="user-info"> <div class="user-info">
<SheetTitle class="find-title">{find.title}</SheetTitle> <SheetTitle class="find-title">{find.title}</SheetTitle>
<div class="find-meta"> <div class="find-meta">
@@ -254,6 +253,15 @@
</button> </button>
</div> </div>
</div> </div>
<div class="comments-section">
<CommentsList
findId={find.id}
{currentUserId}
isScrollable={true}
showCommentForm={true}
/>
</div>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -278,7 +286,7 @@
/* Mobile styles (bottom sheet) */ /* Mobile styles (bottom sheet) */
@media (max-width: 767px) { @media (max-width: 767px) {
:global(.sheet-content) { :global(.sheet-content) {
height: 80vh !important; height: 50vh !important;
border-radius: 16px 16px 0 0 !important; border-radius: 16px 16px 0 0 !important;
} }
} }
@@ -414,7 +422,7 @@
.media-image { .media-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
} }
:global(.media-video) { :global(.media-video) {
@@ -545,6 +553,30 @@
background: hsl(var(--secondary) / 0.8); background: hsl(var(--secondary) / 0.8);
} }
.comments-section {
flex: 1;
min-height: 0;
border-top: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
}
/* Desktop comments section */
@media (min-width: 768px) {
.comments-section {
height: calc(100vh - 400px);
min-height: 200px;
}
}
/* Mobile comments section */
@media (max-width: 767px) {
.comments-section {
height: calc(80vh - 350px);
min-height: 150px;
}
}
/* Mobile specific adjustments */ /* Mobile specific adjustments */
@media (max-width: 640px) { @media (max-width: 640px) {
:global(.sheet-header) { :global(.sheet-header) {
@@ -576,5 +608,9 @@
.action-button { .action-button {
width: 100%; width: 100%;
} }
.comments-section {
height: calc(80vh - 380px);
}
} }
</style> </style>

View File

@@ -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">
<div class="feed-header"> {#if !hideTitle}
<h2 class="feed-title">{title}</h2> <div class="feed-header">
</div> <h2 class="feed-title">{title}</h2>
</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 {

View File

@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { Heart } from '@lucide/svelte'; import { Heart } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
interface Props { interface Props {
findId: string; findId: string;
@@ -19,59 +21,75 @@
class: className = '' class: className = ''
}: Props = $props(); }: Props = $props();
// Track the source of truth - server state // Local state stores for this like button - start with props but will be overridden by global state
let serverIsLiked = $state(isLiked); const likeState = writable({
let serverLikeCount = $state(likeCount); isLiked: isLiked,
likeCount: likeCount,
isLoading: false
});
// Track optimistic state during loading let apiSync: any = null;
let isLoading = $state(false);
let optimisticIsLiked = $state(isLiked);
let optimisticLikeCount = $state(likeCount);
// Derived state for display // Initialize API sync and subscribe to global state
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked); onMount(async () => {
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount); if (browser) {
try {
// Dynamically import the API sync
const module = await import('$lib/stores/api-sync');
apiSync = module.apiSync;
// Check if global state already exists for this find
const existingState = apiSync.getEntityState('find', findId);
if (existingState) {
// Use existing global state - it's more current than props
console.log(`Using existing global state for find ${findId}`, existingState);
} else {
// Initialize with minimal data only if no global state exists
console.log(`Initializing new minimal state for find ${findId}`);
apiSync.setEntityState('find', findId, {
id: findId,
isLikedByUser: isLiked,
likeCount: likeCount,
// Minimal data needed for like functionality
title: '',
latitude: '',
longitude: '',
isPublic: true,
createdAt: new Date(),
userId: '',
username: '',
isFromFriend: false
});
}
// Subscribe to global state for this find
const globalLikeState = apiSync.subscribeFindLikes(findId);
globalLikeState.subscribe((state: any) => {
likeState.set({
isLiked: state.isLiked,
likeCount: state.likeCount,
isLoading: state.isLoading
});
});
} catch (error) {
console.error('Failed to initialize API sync:', error);
}
}
});
async function toggleLike() { async function toggleLike() {
if (isLoading) return; if (!apiSync || !browser) return;
// Set optimistic state const currentState = likeState;
optimisticIsLiked = !serverIsLiked; if (currentState && (currentState as any).isLoading) return;
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
isLoading = true;
try { try {
const method = optimisticIsLiked ? 'POST' : 'DELETE'; await apiSync.toggleLike(findId);
const response = await fetch(`/api/finds/${findId}/like`, { } catch (error) {
method, console.error('Failed to toggle like:', error);
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update server state with response
serverIsLiked = result.isLiked;
serverLikeCount = result.likeCount;
} catch (error: unknown) {
console.error('Error updating like:', error);
toast.error('Failed to update like. Please try again.');
} finally {
isLoading = false;
} }
} }
// Update server state when props change (from parent component)
$effect(() => {
serverIsLiked = isLiked;
serverLikeCount = likeCount;
});
</script> </script>
<Button <Button
@@ -79,22 +97,22 @@
{size} {size}
class="group gap-1.5 {className}" class="group gap-1.5 {className}"
onclick={toggleLike} onclick={toggleLike}
disabled={isLoading} disabled={$likeState.isLoading}
> >
<Heart <Heart
class="h-4 w-4 transition-all duration-200 {displayIsLiked class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
? 'scale-110 fill-red-500 text-red-500' ? 'scale-110 fill-red-500 text-red-500'
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading : 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
? 'animate-pulse' ? 'animate-pulse'
: ''}" : ''}"
/> />
{#if displayLikeCount > 0} {#if $likeState.likeCount > 0}
<span <span
class="text-sm font-medium transition-colors {displayIsLiked class="text-sm font-medium transition-colors {$likeState.isLiked
? 'text-red-500' ? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}" : 'text-gray-500 group-hover:text-red-400'}"
> >
{displayLikeCount} {$likeState.likeCount}
</span> </span>
{/if} {/if}
</Button> </Button>

View File

@@ -4,7 +4,8 @@
locationActions, locationActions,
locationStatus, locationStatus,
locationError, locationError,
isLocationLoading isLocationLoading,
isWatching
} from '$lib/stores/location'; } from '$lib/stores/location';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';
@@ -22,26 +23,45 @@
showLabel = true showLabel = true
}: Props = $props(); }: Props = $props();
async function handleLocationClick() { // Track if location watching is active from the derived store
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
if (!result && $locationError) { async function handleLocationClick() {
toast.error($locationError.message); if ($isWatching) {
// Stop watching if currently active
locationActions.stopWatching();
toast.success('Location watching stopped');
} else {
// Try to get current location first, then start watching
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
if (result) {
// Start watching for continuous updates
locationActions.startWatching({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 60000 // Update every minute
});
toast.success('Location watching started');
} else if ($locationError) {
toast.error($locationError.message);
}
} }
} }
const buttonText = $derived(() => { const buttonText = $derived(() => {
if ($isLocationLoading) return 'Finding location...'; if ($isLocationLoading) return 'Finding location...';
if ($locationStatus === 'success') return 'Update location'; if ($isWatching) return 'Stop watching location';
if ($locationStatus === 'success') return 'Watch location';
return 'Find my location'; return 'Find my location';
}); });
const iconClass = $derived(() => { const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading'; if ($isLocationLoading) return 'loading';
if ($isWatching) return 'watching';
if ($locationStatus === 'success') return 'success'; if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error'; if ($locationStatus === 'error') return 'error';
return 'default'; return 'default';
@@ -59,6 +79,22 @@
<div class="loading-skeleton"> <div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" /> <Skeleton class="h-5 w-5 rounded-full" />
</div> </div>
{:else if $isWatching}
<svg viewBox="0 0 24 24" class="watching-icon">
<path
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
/>
<circle
cx="12"
cy="9"
r="15"
stroke="currentColor"
stroke-width="1"
fill="none"
opacity="0.3"
class="pulse-ring"
/>
</svg>
{:else if $locationStatus === 'success'} {:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path <path
@@ -201,6 +237,11 @@
fill: #10b981; fill: #10b981;
} }
.icon.watching svg {
color: #f59e0b;
fill: #f59e0b;
}
.icon.error svg { .icon.error svg {
color: #ef4444; color: #ef4444;
fill: #ef4444; fill: #ef4444;
@@ -219,6 +260,25 @@
} }
} }
.watching-icon .pulse-ring {
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.5);
opacity: 0.5;
}
50% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.label { .label {
white-space: nowrap; white-space: nowrap;
} }

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
interface Props {
autoStart?: boolean;
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}
let {
autoStart = true,
enableHighAccuracy = true,
timeout = 15000,
maximumAge = 60000
}: Props = $props();
// Location watching options
const watchOptions = {
enableHighAccuracy,
timeout,
maximumAge
};
onMount(() => {
if (!browser || !autoStart) return;
// Check if geolocation is supported
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by this browser');
return;
}
// Check if we already have coordinates and aren't watching
if ($coordinates && !$isWatching) {
// Start watching immediately if we have previous coordinates
startLocationWatching();
return;
}
// If no coordinates, try to get current location first
if (!$coordinates) {
getCurrentLocationThenWatch();
}
});
async function getCurrentLocationThenWatch() {
try {
const result = await locationActions.getCurrentLocation(watchOptions);
if (result) {
// Successfully got location, now start watching
startLocationWatching();
}
} catch {
// If we can't get location due to permissions, don't auto-start watching
console.log('Could not get initial location, location watching not started automatically');
}
}
function startLocationWatching() {
if (!$isWatching) {
locationActions.startWatching(watchOptions);
}
}
// Cleanup function to stop watching when component is destroyed
function cleanup() {
if ($isWatching) {
locationActions.stopWatching();
}
}
// Stop watching when the component is destroyed
onMount(() => {
return cleanup;
});
</script>
<!-- This component doesn't render anything, it just manages location watching -->

View File

@@ -6,7 +6,8 @@
getMapCenter, getMapCenter,
getMapZoom, getMapZoom,
shouldZoomToLocation, shouldZoomToLocation,
locationActions locationActions,
isWatching
} from '$lib/stores/location'; } from '$lib/stores/location';
import LocationButton from './LocationButton.svelte'; import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton'; import { Skeleton } from '$lib/components/skeleton';
@@ -139,11 +140,14 @@
> >
{#if $coordinates} {#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}> <Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker"> <div class="location-marker" class:watching={$isWatching}>
<div class="marker-pulse"></div> <div class="marker-pulse" class:watching={$isWatching}></div>
<div class="marker-outer"> <div class="marker-outer" class:watching={$isWatching}>
<div class="marker-inner"></div> <div class="marker-inner" class:watching={$isWatching}></div>
</div> </div>
{#if $isWatching}
<div class="watching-ring"></div>
{/if}
</div> </div>
</Marker> </Marker>
{/if} {/if}
@@ -243,6 +247,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
}
:global(.marker-outer.watching) {
background: rgba(245, 158, 11, 0.2);
border-color: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
} }
:global(.marker-inner) { :global(.marker-inner) {
@@ -250,6 +261,12 @@
height: 8px; height: 8px;
background: #2563eb; background: #2563eb;
border-radius: 50%; border-radius: 50%;
transition: all 0.3s ease;
}
:global(.marker-inner.watching) {
background: #f59e0b;
animation: pulse-glow 2s infinite;
} }
:global(.marker-pulse) { :global(.marker-pulse) {
@@ -263,6 +280,22 @@
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
:global(.marker-pulse.watching) {
border-color: rgba(245, 158, 11, 0.6);
animation: pulse-watching 1.5s infinite;
}
:global(.watching-ring) {
position: absolute;
top: -8px;
left: -8px;
width: 36px;
height: 36px;
border: 2px solid rgba(245, 158, 11, 0.4);
border-radius: 50%;
animation: expand-ring 3s infinite;
}
@keyframes pulse { @keyframes pulse {
0% { 0% {
transform: scale(1); transform: scale(1);
@@ -274,6 +307,48 @@
} }
} }
@keyframes pulse-watching {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.5);
opacity: 0.4;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse-glow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes expand-ring {
0% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 0.3;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
/* Find marker styles */ /* Find marker styles */
:global(.find-marker) { :global(.find-marker) {
width: 40px; width: 40px;

View File

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

View File

@@ -7,8 +7,8 @@
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from './dropdown-menu'; } from './dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';
import ProfilePicture from './ProfilePicture.svelte';
import ProfilePictureSheet from './ProfilePictureSheet.svelte'; import ProfilePictureSheet from './ProfilePictureSheet.svelte';
interface Props { interface Props {
@@ -22,9 +22,6 @@
let showProfilePictureSheet = $state(false); let showProfilePictureSheet = $state(false);
// Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase();
function openProfilePictureSheet() { function openProfilePictureSheet() {
showProfilePictureSheet = true; showProfilePictureSheet = true;
} }
@@ -36,18 +33,7 @@
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="profile-trigger"> <DropdownMenuTrigger class="profile-trigger">
{#if loading} <ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
<Skeleton class="h-10 w-10 rounded-full" />
{:else}
<Avatar class="profile-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
{/if}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" class="profile-dropdown-content"> <DropdownMenuContent align="end" class="profile-dropdown-content">
@@ -144,15 +130,6 @@
height: 40px; height: 40px;
} }
:global(.profile-avatar-fallback) {
background: black;
color: white;
font-weight: 600;
font-size: 16px;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.profile-dropdown-content) { :global(.profile-dropdown-content) {
min-width: 200px; min-width: 200px;
max-width: 240px; max-width: 240px;

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { cn } from '$lib/utils';
interface Props {
username: string;
profilePictureUrl?: string | null;
size?: 'sm' | 'md' | 'lg' | 'xl';
class?: string;
loading?: boolean;
}
let {
username,
profilePictureUrl,
size = 'md',
class: className,
loading = false
}: Props = $props();
const initial = username.charAt(0).toUpperCase();
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-16 w-16',
xl: 'h-24 w-24'
};
const fallbackSizeClasses = {
sm: 'text-xs',
md: 'text-base',
lg: 'text-xl',
xl: 'text-3xl'
};
</script>
{#if loading}
<div class={cn('animate-pulse rounded-full bg-gray-200', sizeClasses[size], className)}></div>
{:else}
<Avatar class={cn(sizeClasses[size], className)}>
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class={cn('profile-picture-fallback', fallbackSizeClasses[size])}>
{initial}
</AvatarFallback>
</Avatar>
{/if}
<style>
:global(.profile-picture-fallback) {
background: black;
color: white;
font-weight: 600;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -2,7 +2,7 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Button } from './button'; import { Button } from './button';
import { Avatar, AvatarFallback, AvatarImage } from './avatar'; import ProfilePicture from './ProfilePicture.svelte';
interface Props { interface Props {
userId: string; userId: string;
@@ -19,8 +19,6 @@
let isDeleting = $state(false); let isDeleting = $state(false);
let showModal = $state(true); let showModal = $state(true);
const initial = username.charAt(0).toUpperCase();
// Close modal when showModal changes to false // Close modal when showModal changes to false
$effect(() => { $effect(() => {
if (!showModal && onClose) { if (!showModal && onClose) {
@@ -118,14 +116,7 @@
<div class="profile-picture-content"> <div class="profile-picture-content">
<div class="current-avatar"> <div class="current-avatar">
<Avatar class="large-avatar"> <ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="large-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
</div> </div>
<div class="upload-section"> <div class="upload-section">
@@ -190,15 +181,6 @@
height: 120px; height: 120px;
} }
:global(.large-avatar-fallback) {
background: black;
color: white;
font-weight: 600;
font-size: 48px;
border: 4px solid #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.upload-section { .upload-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -3,11 +3,13 @@ export { default as Input } from './components/Input.svelte';
export { default as Button } from './components/Button.svelte'; export { default as Button } from './components/Button.svelte';
export { default as ErrorMessage } from './components/ErrorMessage.svelte'; export { default as ErrorMessage } from './components/ErrorMessage.svelte';
export { default as ProfilePanel } from './components/ProfilePanel.svelte'; export { default as ProfilePanel } from './components/ProfilePanel.svelte';
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte'; export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
export { default as Header } from './components/Header.svelte'; export { default as Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte'; export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.svelte'; export { default as Map } from './components/Map.svelte';
export { default as LocationButton } from './components/LocationButton.svelte'; export { default as LocationButton } from './components/LocationButton.svelte';
export { default as LocationManager } from './components/LocationManager.svelte';
export { default as FindCard } from './components/FindCard.svelte'; export { default as FindCard } from './components/FindCard.svelte';
export { default as FindsList } from './components/FindsList.svelte'; export { default as FindsList } from './components/FindsList.svelte';
@@ -24,6 +26,7 @@ export {
locationError, locationError,
isLocationLoading, isLocationLoading,
hasLocationAccess, hasLocationAccess,
isWatching,
getMapCenter, getMapCenter,
getMapZoom getMapZoom
} from './stores/location'; } from './stores/location';

View File

@@ -75,13 +75,28 @@ export const friendship = pgTable('friendship', {
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull() createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
}); });
export const findComment = pgTable('find_comment', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Type exports for the new tables // Type exports for the new tables
export type Find = typeof find.$inferSelect; export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect; export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect; export type FindLike = typeof findLike.$inferSelect;
export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect; export type Friendship = typeof friendship.$inferSelect;
export type FindInsert = typeof find.$inferInsert; export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert; export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert; export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindCommentInsert = typeof findComment.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert; export type FriendshipInsert = typeof friendship.$inferInsert;

727
src/lib/stores/api-sync.ts Normal file
View File

@@ -0,0 +1,727 @@
import { writable, derived, type Readable, type Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
// Core types for the API sync system
export interface EntityState<T = unknown> {
data: T;
isLoading: boolean;
error: string | null;
lastUpdated: Date;
}
export interface QueuedOperation {
id: string;
entityType: string;
entityId: string;
operation: 'create' | 'update' | 'delete';
action?: string;
data?: unknown;
retry: number;
maxRetries: number;
timestamp: Date;
}
export interface APIResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
[key: string]: unknown;
}
// Specific entity state types
export interface FindLikeState {
isLiked: boolean;
likeCount: number;
isLoading: boolean;
error: string | null;
}
export interface FindState {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: boolean;
createdAt: Date;
userId: string;
username: string;
profilePictureUrl?: string;
media?: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
isLikedByUser: boolean;
likeCount: number;
commentCount: number;
isFromFriend: boolean;
}
export interface CommentState {
id: string;
findId: string;
content: string;
createdAt: Date;
updatedAt: Date;
user: {
id: string;
username: string;
profilePictureUrl?: string;
};
}
export interface FindCommentsState {
comments: CommentState[];
commentCount: number;
isLoading: boolean;
error: string | null;
}
// Generate unique operation IDs
function generateOperationId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Create operation key for deduplication
function createOperationKey(
entityType: string,
entityId: string,
operation: string,
action?: string
): string {
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
}
class APISync {
// Entity stores - each entity type has its own store
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
// Operation queue for API calls
private operationQueue = new Map<string, QueuedOperation>();
private processingQueue = false;
// Cleanup tracking for memory management
private subscriptions = new Map<string, Set<() => void>>();
constructor() {
// Initialize core entity stores
this.initializeEntityStore('find');
this.initializeEntityStore('user');
this.initializeEntityStore('friendship');
this.initializeEntityStore('comment');
// Start processing queue
this.startQueueProcessor();
}
private initializeEntityStore(entityType: string): void {
if (!this.entityStores.has(entityType)) {
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
}
}
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
this.initializeEntityStore(entityType);
return this.entityStores.get(entityType)!;
}
/**
* Subscribe to a specific entity's state
*/
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
const store = this.getEntityStore(entityType);
return derived(store, ($entities) => {
const entity = $entities.get(entityId);
if (!entity) {
// Return default state if entity doesn't exist
return {
data: null as T,
isLoading: false,
error: null,
lastUpdated: new Date()
};
}
return entity as EntityState<T>;
});
}
/**
* Subscribe specifically to find like state
*/
subscribeFindLikes(findId: string): Readable<FindLikeState> {
const store = this.getEntityStore('find');
return derived(store, ($entities) => {
const entity = $entities.get(findId);
if (!entity || !entity.data) {
return {
isLiked: false,
likeCount: 0,
isLoading: false,
error: null
};
}
const findData = entity.data as FindState;
return {
isLiked: findData.isLikedByUser,
likeCount: findData.likeCount,
isLoading: entity.isLoading,
error: entity.error
};
});
}
/**
* Subscribe to find comments state
*/
subscribeFindComments(findId: string): Readable<FindCommentsState> {
const store = this.getEntityStore('comment');
return derived(store, ($entities) => {
const entity = $entities.get(findId);
if (!entity || !entity.data) {
return {
comments: [],
commentCount: 0,
isLoading: false,
error: null
};
}
const commentsData = entity.data as CommentState[];
return {
comments: commentsData,
commentCount: commentsData.length,
isLoading: entity.isLoading,
error: entity.error
};
});
}
/**
* Check if entity state exists
*/
hasEntityState(entityType: string, entityId: string): boolean {
const store = this.getEntityStore(entityType);
let exists = false;
const unsubscribe = store.subscribe(($entities) => {
exists = $entities.has(entityId);
});
unsubscribe();
return exists;
}
/**
* Get current entity state
*/
getEntityState<T>(entityType: string, entityId: string): T | null {
const store = this.getEntityStore(entityType);
let currentState: T | null = null;
const unsubscribe = store.subscribe(($entities) => {
const entity = $entities.get(entityId);
if (entity?.data) {
currentState = entity.data as T;
}
});
unsubscribe();
return currentState;
}
/**
* Initialize entity state with server data (only if no existing state)
*/
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
newEntities.set(entityId, {
data,
isLoading,
error: null,
lastUpdated: new Date()
});
return newEntities;
});
}
/**
* Initialize entity state only if it doesn't exist yet
*/
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
if (!this.hasEntityState(entityType, entityId)) {
this.setEntityState(entityType, entityId, data, isLoading);
}
}
/**
* Update entity loading state
*/
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading
});
}
return newEntities;
});
}
/**
* Update entity error state
*/
private setEntityError(entityType: string, entityId: string, error: string): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading: false,
error
});
}
return newEntities;
});
}
/**
* Queue an operation for processing
*/
async queueOperation(
entityType: string,
entityId: string,
operation: 'create' | 'update' | 'delete',
action?: string,
data?: Record<string, unknown>
): Promise<void> {
const operationKey = createOperationKey(entityType, entityId, operation, action);
// Check if same operation is already queued
if (this.operationQueue.has(operationKey)) {
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
return;
}
const queuedOperation: QueuedOperation = {
id: generateOperationId(),
entityType,
entityId,
operation,
action,
data,
retry: 0,
maxRetries: 3,
timestamp: new Date()
};
this.operationQueue.set(operationKey, queuedOperation);
// Set entity to loading state
this.setEntityLoading(entityType, entityId, true);
// Process queue if not already processing
if (!this.processingQueue) {
this.processQueue();
}
}
/**
* Process the operation queue
*/
private async processQueue(): Promise<void> {
if (this.processingQueue || this.operationQueue.size === 0) {
return;
}
this.processingQueue = true;
const operations = Array.from(this.operationQueue.entries());
for (const [operationKey, operation] of operations) {
try {
await this.executeOperation(operation);
this.operationQueue.delete(operationKey);
} catch (error) {
console.error(`Operation ${operationKey} failed:`, error);
if (operation.retry < operation.maxRetries) {
operation.retry++;
console.log(
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
);
} else {
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
this.operationQueue.delete(operationKey);
this.setEntityError(
operation.entityType,
operation.entityId,
'Operation failed after multiple retries'
);
toast.error('Failed to sync changes. Please try again.');
}
}
}
this.processingQueue = false;
// If more operations were added while processing, process again
if (this.operationQueue.size > 0) {
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
}
}
/**
* Execute a specific operation
*/
private async executeOperation(operation: QueuedOperation): Promise<void> {
const { entityType, entityId, operation: op, action, data } = operation;
let response: Response;
if (entityType === 'find' && action === 'like') {
// Handle like operations
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
response = await fetch(`/api/finds/${entityId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
} else if (entityType === 'comment' && op === 'create') {
// Handle comment creation
response = await fetch(`/api/finds/${entityId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
} else if (entityType === 'comment' && op === 'delete') {
// Handle comment deletion
response = await fetch(`/api/finds/comments/${entityId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
} else {
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update entity state with successful result
if (entityType === 'find' && action === 'like') {
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
} else if (entityType === 'comment' && op === 'create') {
this.addCommentToState(result.data.findId, result.data);
} else if (entityType === 'comment' && op === 'delete') {
this.removeCommentFromState(entityId, data as { findId: string });
}
}
/**
* Update find like state after successful API call
*/
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
const store = this.getEntityStore('find');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: isLiked,
likeCount: likeCount
},
isLoading: false,
error: null,
lastUpdated: new Date()
});
}
return newEntities;
});
}
/**
* Start the queue processor
*/
private startQueueProcessor(): void {
// Process queue every 100ms
setInterval(() => {
if (this.operationQueue.size > 0 && !this.processingQueue) {
this.processQueue();
}
}, 100);
}
/**
* Toggle like for a find
*/
async toggleLike(findId: string): Promise<void> {
// Get current state for optimistic update
const store = this.getEntityStore('find');
let currentState: FindState | null = null;
const unsubscribe = store.subscribe(($entities) => {
const entity = $entities.get(findId);
if (entity?.data) {
currentState = entity.data as FindState;
}
});
unsubscribe();
if (!currentState) {
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
return;
}
// Optimistic update
const findState = currentState as FindState;
const newIsLiked = !findState.isLikedByUser;
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
// Update state optimistically
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: newIsLiked,
likeCount: newLikeCount
}
});
}
return newEntities;
});
// Queue the operation
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
}
/**
* Add comment to find comments state
*/
private addCommentToState(findId: string, comment: CommentState): void {
const store = this.getEntityStore('comment');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
const comments = existing?.data ? (existing.data as CommentState[]) : [];
// If this is a real comment from server, remove any temporary comment with the same content
let updatedComments = comments;
if (!comment.id.startsWith('temp-')) {
updatedComments = comments.filter(
(c) => !(c.id.startsWith('temp-') && c.content === comment.content)
);
}
updatedComments = [comment, ...updatedComments];
newEntities.set(findId, {
data: updatedComments,
isLoading: false,
error: null,
lastUpdated: new Date()
});
return newEntities;
});
// Update find comment count only for temp comments (optimistic updates)
if (comment.id.startsWith('temp-')) {
this.updateFindCommentCount(findId, 1);
}
}
/**
* Remove comment from find comments state
*/
private removeCommentFromState(commentId: string, data: { findId: string }): void {
const store = this.getEntityStore('comment');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(data.findId);
if (existing?.data) {
const comments = existing.data as CommentState[];
const updatedComments = comments.filter((c) => c.id !== commentId);
newEntities.set(data.findId, {
...existing,
data: updatedComments,
isLoading: false,
error: null,
lastUpdated: new Date()
});
}
return newEntities;
});
// Update find comment count
this.updateFindCommentCount(data.findId, -1);
}
/**
* Update find comment count
*/
private updateFindCommentCount(findId: string, delta: number): void {
const store = this.getEntityStore('find');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
commentCount: Math.max(0, findData.commentCount + delta)
},
lastUpdated: new Date()
});
}
return newEntities;
});
}
/**
* Load comments for a find
*/
async loadComments(findId: string): Promise<void> {
if (this.hasEntityState('comment', findId)) {
return;
}
this.setEntityLoading('comment', findId, true);
try {
const response = await fetch(`/api/finds/${findId}/comments`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.setEntityState('comment', findId, result.data, false);
} else {
throw new Error(result.error || 'Failed to load comments');
}
} catch (error) {
console.error('Error loading comments:', error);
this.setEntityError('comment', findId, 'Failed to load comments');
}
}
/**
* Add a comment to a find
*/
async addComment(findId: string, content: string): Promise<void> {
// Optimistic update: add temporary comment
const tempComment: CommentState = {
id: `temp-${Date.now()}`,
findId,
content,
createdAt: new Date(),
updatedAt: new Date(),
user: {
id: 'current-user',
username: 'You',
profilePictureUrl: undefined
}
};
this.addCommentToState(findId, tempComment);
// Queue the operation
await this.queueOperation('comment', findId, 'create', undefined, { content });
}
/**
* Delete a comment
*/
async deleteComment(commentId: string, findId: string): Promise<void> {
// Optimistic update: remove comment
this.removeCommentFromState(commentId, { findId });
// Queue the operation
await this.queueOperation('comment', commentId, 'delete', undefined, { findId });
}
/**
* Initialize find data from server (only if no existing state)
*/
initializeFindData(finds: FindState[]): void {
for (const find of finds) {
this.initializeEntityState('find', find.id, find);
}
}
/**
* Initialize comments data for a find
*/
initializeCommentsData(findId: string, comments: CommentState[]): void {
this.initializeEntityState('comment', findId, comments);
}
/**
* Cleanup unused subscriptions (call this when components unmount)
*/
cleanup(entityType: string, entityId: string): void {
const key = `${entityType}:${entityId}`;
const subscriptions = this.subscriptions.get(key);
if (subscriptions) {
subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions.delete(key);
}
}
}
// Create singleton instance
export const apiSync = new APISync();

View File

@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
locationStore, locationStore,
($location) => $location.shouldZoomToLocation ($location) => $location.shouldZoomToLocation
); );
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
// Location actions // Location actions
export const locationActions = { export const locationActions = {

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

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

View File

@@ -5,11 +5,21 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js'; import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton'; import { Skeleton } from '$lib/components/skeleton';
import LocationManager from '$lib/components/LocationManager.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let { children, data } = $props(); let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login')); let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
let showHeader = $derived(!isLoginRoute && data?.user); let showHeader = $derived(!isLoginRoute && data?.user);
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null); let isLoading = $state(false);
// Handle loading state only on client to prevent hydration mismatch
onMount(() => {
if (browser) {
isLoading = !isLoginRoute && !data?.user && data !== null;
}
});
</script> </script>
<svelte:head> <svelte:head>
@@ -32,6 +42,11 @@
<Toaster /> <Toaster />
<!-- Auto-start location watching for authenticated users -->
{#if data?.user && !isLoginRoute}
<LocationManager autoStart={true} />
{/if}
{#if showHeader && data.user} {#if showHeader && data.user}
<Header user={data.user} /> <Header user={data.user} />
{:else if isLoading} {:else if isLoading}

View File

@@ -7,6 +7,9 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location'; import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { apiSync, type FindState } from '$lib/stores/api-sync';
// Server response type // Server response type
interface ServerFind { interface ServerFind {
@@ -24,6 +27,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 +57,7 @@
}; };
likeCount?: number; likeCount?: number;
isLiked?: boolean; isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{ media?: Array<{
type: string; type: string;
url: string; url: string;
@@ -90,6 +95,33 @@
let selectedFind: FindPreviewData | null = $state(null); let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all'); let currentFilter = $state('all');
// Initialize API sync with server data on mount
onMount(async () => {
if (browser && data.finds && data.finds.length > 0) {
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
description: serverFind.description,
latitude: serverFind.latitude,
longitude: serverFind.longitude,
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
commentCount: 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// All finds - convert server format to component format // All finds - convert server format to component format
let allFinds = $derived( let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({ (data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
@@ -102,6 +134,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 +153,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,19 +250,22 @@
</div> </div>
<div class="finds-section"> <div class="finds-section">
<div class="finds-header"> <div class="finds-sticky-header">
<div class="finds-title-section"> <div class="finds-header-content">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} /> <div class="finds-title-section">
<h2 class="finds-title">Finds</h2>
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div> </div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div> </div>
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div> </div>
</main> </main>
@@ -252,7 +288,7 @@
{/if} {/if}
{#if selectedFind} {#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} /> <FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if} {/if}
<style> <style>
@@ -285,27 +321,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 +391,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 +430,8 @@
padding: 12px; padding: 12px;
} }
.finds-section { .finds-sticky-header {
padding: 12px; padding: 12px 12px 8px 12px;
} }
} }
</style> </style>

View File

@@ -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)
}; };
}) })
); );

View File

@@ -0,0 +1,119 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findComment, user } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, locals }) => {
const session = locals.session;
if (!session) {
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const findId = params.findId;
if (!findId) {
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
}
try {
const comments = await db
.select({
id: findComment.id,
findId: findComment.findId,
content: findComment.content,
createdAt: findComment.createdAt,
updatedAt: findComment.updatedAt,
user: {
id: user.id,
username: user.username,
profilePictureUrl: user.profilePictureUrl
}
})
.from(findComment)
.innerJoin(user, eq(findComment.userId, user.id))
.where(eq(findComment.findId, findId))
.orderBy(desc(findComment.createdAt));
return json({
success: true,
data: comments,
count: comments.length
});
} catch (error) {
console.error('Error fetching comments:', error);
return json({ success: false, error: 'Failed to fetch comments' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ params, locals, request }) => {
const session = locals.session;
if (!session) {
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const findId = params.findId;
if (!findId) {
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
}
try {
const body = await request.json();
const { content } = body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return json({ success: false, error: 'Comment content is required' }, { status: 400 });
}
if (content.length > 500) {
return json(
{ success: false, error: 'Comment too long (max 500 characters)' },
{ status: 400 }
);
}
const commentId = crypto.randomUUID();
const now = new Date();
const [newComment] = await db
.insert(findComment)
.values({
id: commentId,
findId,
userId: session.userId,
content: content.trim(),
createdAt: now,
updatedAt: now
})
.returning();
const commentWithUser = await db
.select({
id: findComment.id,
findId: findComment.findId,
content: findComment.content,
createdAt: findComment.createdAt,
updatedAt: findComment.updatedAt,
user: {
id: user.id,
username: user.username,
profilePictureUrl: user.profilePictureUrl
}
})
.from(findComment)
.innerJoin(user, eq(findComment.userId, user.id))
.where(eq(findComment.id, commentId))
.limit(1);
if (commentWithUser.length === 0) {
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
}
return json({
success: true,
data: commentWithUser[0]
});
} catch (error) {
console.error('Error creating comment:', error);
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
}
};

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findComment, user } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, locals }) => {
const session = locals.session;
if (!session) {
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const commentId = params.commentId;
if (!commentId) {
return json({ success: false, error: 'Comment ID is required' }, { status: 400 });
}
try {
const existingComment = await db
.select()
.from(findComment)
.where(eq(findComment.id, commentId))
.limit(1);
if (existingComment.length === 0) {
return json({ success: false, error: 'Comment not found' }, { status: 404 });
}
if (existingComment[0].userId !== session.userId) {
return json(
{ success: false, error: 'Not authorized to delete this comment' },
{ status: 403 }
);
}
await db.delete(findComment).where(eq(findComment.id, commentId));
return json({
success: true,
data: { id: commentId }
});
} catch (error) {
console.error('Error deleting comment:', error);
return json({ success: false, error: 'Failed to delete comment' }, { status: 500 });
}
};

View File

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

View File

@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input'; import { Input } from '$lib/components/input';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar'; import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import { Badge } from '$lib/components/badge'; import { Badge } from '$lib/components/badge';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -184,10 +184,10 @@
<div class="user-grid"> <div class="user-grid">
{#each friends as friend (friend.id)} {#each friends as friend (friend.id)}
<div class="user-card"> <div class="user-card">
<Avatar> <ProfilePicture
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} /> username={friend.friendUsername}
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback> profilePictureUrl={friend.friendProfilePictureUrl}
</Avatar> />
<div class="user-info"> <div class="user-info">
<span class="username">{friend.friendUsername}</span> <span class="username">{friend.friendUsername}</span>
<Badge variant="secondary">Friend</Badge> <Badge variant="secondary">Friend</Badge>
@@ -218,15 +218,10 @@
<div class="user-grid"> <div class="user-grid">
{#each receivedRequests as request (request.id)} {#each receivedRequests as request (request.id)}
<div class="user-card"> <div class="user-card">
<Avatar> <ProfilePicture
<AvatarImage username={request.friendUsername}
src={request.friendProfilePictureUrl} profilePictureUrl={request.friendProfilePictureUrl}
alt={request.friendUsername} />
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info"> <div class="user-info">
<span class="username">{request.friendUsername}</span> <span class="username">{request.friendUsername}</span>
<Badge variant="outline">Pending</Badge> <Badge variant="outline">Pending</Badge>
@@ -264,15 +259,10 @@
<div class="user-grid"> <div class="user-grid">
{#each sentRequests as request (request.id)} {#each sentRequests as request (request.id)}
<div class="user-card"> <div class="user-card">
<Avatar> <ProfilePicture
<AvatarImage username={request.friendUsername}
src={request.friendProfilePictureUrl} profilePictureUrl={request.friendProfilePictureUrl}
alt={request.friendUsername} />
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info"> <div class="user-info">
<span class="username">{request.friendUsername}</span> <span class="username">{request.friendUsername}</span>
<Badge variant="secondary">Sent</Badge> <Badge variant="secondary">Sent</Badge>
@@ -305,10 +295,10 @@
<div class="user-grid"> <div class="user-grid">
{#each searchResults as user (user.id)} {#each searchResults as user (user.id)}
<div class="user-card"> <div class="user-card">
<Avatar> <ProfilePicture
<AvatarImage src={user.profilePictureUrl} alt={user.username} /> username={user.username}
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback> profilePictureUrl={user.profilePictureUrl}
</Avatar> />
<div class="user-info"> <div class="user-info">
<span class="username">{user.username}</span> <span class="username">{user.username}</span>
{#if user.friendshipStatus === 'accepted'} {#if user.friendshipStatus === 'accepted'}