9 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
27 changed files with 2547 additions and 446 deletions

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,
"tag": "0005_rapid_warpath",
"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,65 @@
## 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:**
@@ -322,9 +381,9 @@
## Totaal Overzicht
**Totale geschatte uren:** 67 uren
**Werkdagen:** 12 dagen
**Gemiddelde uren per dag:** 5.6 uur
**Totale geschatte uren:** 80 uren
**Werkdagen:** 14 dagen
**Gemiddelde uren per dag:** 5.8 uur
### Project Milestones:
@@ -340,6 +399,8 @@
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:
@@ -366,3 +427,7 @@
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
- [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:; " +
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
"font-src 'self' fonts.gstatic.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
"media-src 'self' *.r2.cloudflarestorage.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
"connect-src 'self' *.openstreetmap.org; " +
"frame-ancestors 'none'; " +
"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

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

View File

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

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { Button } from '$lib/components/button';
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 {
findId: string;
@@ -19,59 +21,75 @@
class: className = ''
}: Props = $props();
// Track the source of truth - server state
let serverIsLiked = $state(isLiked);
let serverLikeCount = $state(likeCount);
// Local state stores for this like button - start with props but will be overridden by global state
const likeState = writable({
isLiked: isLiked,
likeCount: likeCount,
isLoading: false
});
// Track optimistic state during loading
let isLoading = $state(false);
let optimisticIsLiked = $state(isLiked);
let optimisticLikeCount = $state(likeCount);
let apiSync: any = null;
// Derived state for display
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
// Initialize API sync and subscribe to global state
onMount(async () => {
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() {
if (isLoading) return;
if (!apiSync || !browser) return;
// Set optimistic state
optimisticIsLiked = !serverIsLiked;
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
isLoading = true;
const currentState = likeState;
if (currentState && (currentState as any).isLoading) return;
try {
const method = optimisticIsLiked ? 'POST' : 'DELETE';
const response = await fetch(`/api/finds/${findId}/like`, {
method,
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;
await apiSync.toggleLike(findId);
} catch (error) {
console.error('Failed to toggle like:', error);
}
}
// Update server state when props change (from parent component)
$effect(() => {
serverIsLiked = isLiked;
serverLikeCount = likeCount;
});
</script>
<Button
@@ -79,22 +97,22 @@
{size}
class="group gap-1.5 {className}"
onclick={toggleLike}
disabled={isLoading}
disabled={$likeState.isLoading}
>
<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'
: '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'
: ''}"
/>
{#if displayLikeCount > 0}
{#if $likeState.likeCount > 0}
<span
class="text-sm font-medium transition-colors {displayIsLiked
class="text-sm font-medium transition-colors {$likeState.isLiked
? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}"
>
{displayLikeCount}
{$likeState.likeCount}
</span>
{/if}
</Button>

View File

@@ -4,7 +4,8 @@
locationActions,
locationStatus,
locationError,
isLocationLoading
isLocationLoading,
isWatching
} from '$lib/stores/location';
import { Skeleton } from './skeleton';
@@ -22,26 +23,45 @@
showLabel = true
}: Props = $props();
async function handleLocationClick() {
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
// Track if location watching is active from the derived store
if (!result && $locationError) {
toast.error($locationError.message);
async function handleLocationClick() {
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(() => {
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';
});
const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading';
if ($isWatching) return 'watching';
if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error';
return 'default';
@@ -59,6 +79,22 @@
<div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" />
</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'}
<svg viewBox="0 0 24 24">
<path
@@ -201,6 +237,11 @@
fill: #10b981;
}
.icon.watching svg {
color: #f59e0b;
fill: #f59e0b;
}
.icon.error svg {
color: #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 {
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,
getMapZoom,
shouldZoomToLocation,
locationActions
locationActions,
isWatching
} from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
@@ -139,11 +140,14 @@
>
{#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker">
<div class="marker-pulse"></div>
<div class="marker-outer">
<div class="marker-inner"></div>
<div class="location-marker" class:watching={$isWatching}>
<div class="marker-pulse" class:watching={$isWatching}></div>
<div class="marker-outer" class:watching={$isWatching}>
<div class="marker-inner" class:watching={$isWatching}></div>
</div>
{#if $isWatching}
<div class="watching-ring"></div>
{/if}
</div>
</Marker>
{/if}
@@ -243,6 +247,13 @@
display: flex;
align-items: 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) {
@@ -250,6 +261,12 @@
height: 8px;
background: #2563eb;
border-radius: 50%;
transition: all 0.3s ease;
}
:global(.marker-inner.watching) {
background: #f59e0b;
animation: pulse-glow 2s infinite;
}
:global(.marker-pulse) {
@@ -263,6 +280,22 @@
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 {
0% {
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 */
:global(.find-marker) {
width: 40px;

View File

@@ -7,8 +7,8 @@
DropdownMenuSeparator,
DropdownMenuTrigger
} from './dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Skeleton } from './skeleton';
import ProfilePicture from './ProfilePicture.svelte';
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
interface Props {
@@ -22,9 +22,6 @@
let showProfilePictureSheet = $state(false);
// Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase();
function openProfilePictureSheet() {
showProfilePictureSheet = true;
}
@@ -36,18 +33,7 @@
<DropdownMenu>
<DropdownMenuTrigger class="profile-trigger">
{#if loading}
<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}
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="profile-dropdown-content">
@@ -144,15 +130,6 @@
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) {
min-width: 200px;
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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Button } from './button';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import ProfilePicture from './ProfilePicture.svelte';
interface Props {
userId: string;
@@ -19,8 +19,6 @@
let isDeleting = $state(false);
let showModal = $state(true);
const initial = username.charAt(0).toUpperCase();
// Close modal when showModal changes to false
$effect(() => {
if (!showModal && onClose) {
@@ -118,14 +116,7 @@
<div class="profile-picture-content">
<div class="current-avatar">
<Avatar class="large-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="large-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
</div>
<div class="upload-section">
@@ -190,15 +181,6 @@
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 {
display: flex;
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 ErrorMessage } from './components/ErrorMessage.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 Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.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 FindsList } from './components/FindsList.svelte';
@@ -24,6 +26,7 @@ export {
locationError,
isLocationLoading,
hasLocationAccess,
isWatching,
getMapCenter,
getMapZoom
} from './stores/location';

View File

@@ -75,13 +75,28 @@ export const friendship = pgTable('friendship', {
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
export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect;
export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect;
export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindCommentInsert = typeof findComment.$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,
($location) => $location.shouldZoomToLocation
);
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
// Location actions
export const locationActions = {

View File

@@ -5,11 +5,21 @@
import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js';
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 isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
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>
<svelte:head>
@@ -32,6 +42,11 @@
<Toaster />
<!-- Auto-start location watching for authenticated users -->
{#if data?.user && !isLoginRoute}
<LocationManager autoStart={true} />
{/if}
{#if showHeader && data.user}
<Header user={data.user} />
{:else if isLoading}

View File

@@ -7,6 +7,9 @@
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
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
interface ServerFind {
@@ -92,6 +95,33 @@
let selectedFind: FindPreviewData | null = $state(null);
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
let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
@@ -258,7 +288,7 @@
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} />
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}
<style>

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

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