6 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
22 changed files with 1717 additions and 410 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}
<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>
<!-- 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

@@ -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,6 +3,7 @@ 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';

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;

View File

@@ -59,9 +59,30 @@ export interface FindState {
}>;
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)}`;
@@ -93,6 +114,7 @@ class APISync {
this.initializeEntityStore('find');
this.initializeEntityStore('user');
this.initializeEntityStore('friendship');
this.initializeEntityStore('comment');
// Start processing queue
this.startQueueProcessor();
@@ -157,6 +179,33 @@ class APISync {
});
}
/**
* 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
*/
@@ -359,6 +408,23 @@ class APISync {
'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}`);
}
@@ -373,6 +439,10 @@ class APISync {
// 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 });
}
}
@@ -467,6 +537,163 @@ class APISync {
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)
*/
@@ -476,6 +703,13 @@ class APISync {
}
}
/**
* 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)
*/

View File

@@ -6,11 +6,20 @@
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>

View File

@@ -9,6 +9,7 @@
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 {
@@ -97,36 +98,6 @@
// Initialize API sync with server data on mount
onMount(async () => {
if (browser && data.finds && data.finds.length > 0) {
// Dynamically import the API sync to avoid SSR issues
const { apiSync } = await import('$lib/stores/api-sync');
// Define the FindState interface locally
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;
isFromFriend: boolean;
}
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
@@ -143,6 +114,7 @@
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
commentCount: 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
@@ -316,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}
<ProfilePicture
username={request.friendUsername}
profilePictureUrl={request.friendProfilePictureUrl}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<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}
<ProfilePicture
username={request.friendUsername}
profilePictureUrl={request.friendProfilePictureUrl}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<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'}