Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2efd4969e7
|
|||
|
b8c88d7a58
|
|||
|
af49ed6237
|
|||
|
d3adac8acc
|
|||
|
9800be0147
|
|||
|
4c973c4e7d
|
|||
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
|||
|
fef7c160e2
|
|||
|
43afa6dacc
|
|||
|
aa9ed77499
|
|||
|
e1c5846fa4
|
@@ -35,3 +35,6 @@ R2_ACCOUNT_ID=""
|
|||||||
R2_ACCESS_KEY_ID=""
|
R2_ACCESS_KEY_ID=""
|
||||||
R2_SECRET_ACCESS_KEY=""
|
R2_SECRET_ACCESS_KEY=""
|
||||||
R2_BUCKET_NAME=""
|
R2_BUCKET_NAME=""
|
||||||
|
|
||||||
|
# Google Maps API for Places search
|
||||||
|
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||||
|
|||||||
11
drizzle/0006_strange_firebird.sql
Normal file
11
drizzle/0006_strange_firebird.sql
Normal 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;
|
||||||
521
drizzle/meta/0006_snapshot.json
Normal file
521
drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1760631798851,
|
"when": 1760631798851,
|
||||||
"tag": "0005_rapid_warpath",
|
"tag": "0005_rapid_warpath",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762428302491,
|
||||||
|
"tag": "0006_strange_firebird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
132
logs/logboek.md
132
logs/logboek.md
@@ -2,6 +2,111 @@
|
|||||||
|
|
||||||
## Oktober 2025
|
## Oktober 2025
|
||||||
|
|
||||||
|
### 4 November 2025 - 1 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **UI Consistency & Media Layout Improvements**
|
||||||
|
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
|
||||||
|
- Media layout en sizing verbeteringen voor betere responsive design
|
||||||
|
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
|
||||||
|
- Component refactoring voor consistentere UI across applicatie
|
||||||
|
- Loading states en fallback styling geconsolideerd
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- d3adac8 - add:ProfilePicture component and replace Avatar
|
||||||
|
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
|
||||||
|
- Avatar componenten vervangen door ProfilePicture voor consistentie
|
||||||
|
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
|
||||||
|
- Max-height van 600px toegevoegd voor images en videos met height:auto
|
||||||
|
- Object-fit: contain toegepast voor media images in preview
|
||||||
|
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
|
||||||
|
- Loading states UI geconsolideerd in ProfilePicture component
|
||||||
|
- Component library verder uitgebreid met herbruikbare UI elementen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 27-29 Oktober 2025 - 10 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Phase 3: Google Places Integration & Sync Service**
|
||||||
|
- Complete implementatie van sync-service voor API data synchronisatie
|
||||||
|
- Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||||
|
- Location tracking optimalisaties met continuous watching
|
||||||
|
- CSP (Content Security Policy) fixes voor verbeterde security
|
||||||
|
- Child subscription fixes voor data consistency
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- 4c973c4 - fix:some csp issues
|
||||||
|
- d7fe909 - fix:new child subscription
|
||||||
|
- 6620cc6 - feat:implement a sync-service
|
||||||
|
- 3b3ebc2 - fix:continuously watch location
|
||||||
|
- fef7c16 - feat:use GMaps places api for searching poi's
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Complete sync-service architectuur voor real-time data synchronisatie
|
||||||
|
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
|
||||||
|
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
|
||||||
|
- Security verbeteringen door CSP issues op te lossen
|
||||||
|
- Data consistency verbeteringen met child subscription fixes
|
||||||
|
- Enhanced POI search capabilities met Google Maps integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 21 Oktober 2025 (Maandag) - 4 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **UI Refinement & Bug Fixes**
|
||||||
|
- Create Find Modal UI refactoring met verbeterde layout
|
||||||
|
- Finds list en header layout updates voor betere UX
|
||||||
|
- Friends filtering logica fixes
|
||||||
|
- Friends en users search functionaliteit verbeteringen
|
||||||
|
- Modal interface optimalisaties
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- aa9ed77 - UI:Refactor create find modal UI and update finds list/header layout
|
||||||
|
- e1c5846 - fix:friends filtering
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Verbeterde modal interface met consistente styling
|
||||||
|
- Fixed filtering logica voor vriendensysteem
|
||||||
|
- Enhanced search functionaliteit voor gebruikers en vrienden
|
||||||
|
- UI/UX verbeteringen voor betere gebruikerservaring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20 Oktober 2025 (Zondag) - 2 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Search Logic Improvements**
|
||||||
|
- Friends en users search logica geoptimaliseerd
|
||||||
|
- Filtering verbeteringen voor vriendschapssysteem
|
||||||
|
- Backend search algoritmes verfijnd
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- 634ce8a - fix:logic of friends and users search
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Verbeterde zoekalgoritmes voor gebruikers
|
||||||
|
- Geoptimaliseerde filtering voor vriendensysteem
|
||||||
|
- Backend logica verfijning voor betere performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 16 Oktober 2025 (Woensdag) - 6 uren
|
### 16 Oktober 2025 (Woensdag) - 6 uren
|
||||||
|
|
||||||
**Werk uitgevoerd:**
|
**Werk uitgevoerd:**
|
||||||
@@ -17,7 +122,14 @@
|
|||||||
- ProfilePanel uitgebreid met Friends navigatielink
|
- ProfilePanel uitgebreid met Friends navigatielink
|
||||||
- Type-safe implementatie met volledige error handling
|
- Type-safe implementatie met volledige error handling
|
||||||
|
|
||||||
**Commits:** Nog niet gecommit (staged changes)
|
**Commits:**
|
||||||
|
|
||||||
|
- f547ee5 - add:logs
|
||||||
|
- a01d183 - feat:friends
|
||||||
|
- fdbd495 - add:cache for r2 storage
|
||||||
|
- e54c4fb - feat:profile pictures
|
||||||
|
- bee03a5 - feat:use dynamic sheet for findpreview
|
||||||
|
- aea3249 - fix:likes;UI:find card&list
|
||||||
|
|
||||||
**Details:**
|
**Details:**
|
||||||
|
|
||||||
@@ -46,7 +158,9 @@
|
|||||||
- CSP headers bijgewerkt voor video support
|
- CSP headers bijgewerkt voor video support
|
||||||
- Volledige UI integratie in FindCard en FindPreview componenten
|
- Volledige UI integratie in FindCard en FindPreview componenten
|
||||||
|
|
||||||
**Commits:** Nog niet gecommit (staged changes)
|
**Commits:**
|
||||||
|
|
||||||
|
- 067e228 - feat:video player, like button, and media fallbacks
|
||||||
|
|
||||||
**Details:**
|
**Details:**
|
||||||
|
|
||||||
@@ -267,9 +381,9 @@
|
|||||||
|
|
||||||
## Totaal Overzicht
|
## Totaal Overzicht
|
||||||
|
|
||||||
**Totale geschatte uren:** 61 uren
|
**Totale geschatte uren:** 80 uren
|
||||||
**Werkdagen:** 10 dagen
|
**Werkdagen:** 14 dagen
|
||||||
**Gemiddelde uren per dag:** 6.1 uur
|
**Gemiddelde uren per dag:** 5.8 uur
|
||||||
|
|
||||||
### Project Milestones:
|
### Project Milestones:
|
||||||
|
|
||||||
@@ -283,6 +397,10 @@
|
|||||||
8. **13 Okt**: API architectuur verbetering
|
8. **13 Okt**: API architectuur verbetering
|
||||||
9. **14 Okt**: Modern media support en social interactions
|
9. **14 Okt**: Modern media support en social interactions
|
||||||
10. **16 Okt**: Friends & Privacy System implementatie
|
10. **16 Okt**: Friends & Privacy System implementatie
|
||||||
|
11. **20 Okt**: Search logic improvements
|
||||||
|
12. **21 Okt**: UI refinement en bug fixes
|
||||||
|
13. **27-29 Okt**: Google Places Integration & Sync Service
|
||||||
|
14. **4 Nov**: UI Consistency & Media Layout Improvements
|
||||||
|
|
||||||
### Hoofdfunctionaliteiten geïmplementeerd:
|
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||||
|
|
||||||
@@ -309,3 +427,7 @@
|
|||||||
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||||
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
|
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
|
||||||
- [x] Real-time find filtering op basis van privacy instellingen
|
- [x] Real-time find filtering op basis van privacy instellingen
|
||||||
|
- [x] Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||||
|
- [x] Sync-service voor API data synchronisatie
|
||||||
|
- [x] Continuous location watching voor nauwkeurige tracking
|
||||||
|
- [x] CSP security verbeteringen
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
"worker-src 'self' blob:; " +
|
"worker-src 'self' blob:; " +
|
||||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||||
"font-src 'self' fonts.gstatic.com; " +
|
"font-src 'self' fonts.gstatic.com; " +
|
||||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
|
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
"media-src 'self' *.r2.cloudflarestorage.com; " +
|
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
"connect-src 'self' *.openstreetmap.org; " +
|
"connect-src 'self' *.openstreetmap.org; " +
|
||||||
"frame-ancestors 'none'; " +
|
"frame-ancestors 'none'; " +
|
||||||
"base-uri 'self'; " +
|
"base-uri 'self'; " +
|
||||||
|
|||||||
141
src/lib/components/Comment.svelte
Normal file
141
src/lib/components/Comment.svelte
Normal 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>
|
||||||
119
src/lib/components/CommentForm.svelte
Normal file
119
src/lib/components/CommentForm.svelte
Normal 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>
|
||||||
243
src/lib/components/CommentsList.svelte
Normal file
243
src/lib/components/CommentsList.svelte
Normal 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>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||||
import Input from '$lib/components/Input.svelte';
|
import { Input } from '$lib/components/input';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import { Label } from '$lib/components/label';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import POISearch from './POISearch.svelte';
|
||||||
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,7 +15,6 @@
|
|||||||
|
|
||||||
let { isOpen, onClose, onFindCreated }: Props = $props();
|
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||||
|
|
||||||
// Form state
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
let latitude = $state('');
|
let latitude = $state('');
|
||||||
@@ -23,8 +25,8 @@
|
|||||||
let selectedFiles = $state<FileList | null>(null);
|
let selectedFiles = $state<FileList | null>(null);
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||||
|
let useManualLocation = $state(false);
|
||||||
|
|
||||||
// Categories
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'cafe', label: 'Café' },
|
{ value: 'cafe', label: 'Café' },
|
||||||
{ value: 'restaurant', label: 'Restaurant' },
|
{ value: 'restaurant', label: 'Restaurant' },
|
||||||
@@ -35,21 +37,40 @@
|
|||||||
{ value: 'other', label: 'Other' }
|
{ value: 'other', label: 'Other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Auto-fill location when modal opens
|
let showModal = $state(true);
|
||||||
|
let isMobile = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen && $coordinates) {
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile = window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!showModal) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showModal && $coordinates) {
|
||||||
latitude = $coordinates.latitude.toString();
|
latitude = $coordinates.latitude.toString();
|
||||||
longitude = $coordinates.longitude.toString();
|
longitude = $coordinates.longitude.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle file selection
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
selectedFiles = target.files;
|
selectedFiles = target.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload media files
|
|
||||||
async function uploadMedia(): Promise<void> {
|
async function uploadMedia(): Promise<void> {
|
||||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
@@ -71,7 +92,6 @@
|
|||||||
uploadedMedia = result.media;
|
uploadedMedia = result.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit form
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const lat = parseFloat(latitude);
|
const lat = parseFloat(latitude);
|
||||||
const lng = parseFloat(longitude);
|
const lng = parseFloat(longitude);
|
||||||
@@ -83,12 +103,10 @@
|
|||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload media first if any
|
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
await uploadMedia();
|
await uploadMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the find
|
|
||||||
const response = await fetch('/api/finds', {
|
const response = await fetch('/api/finds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -110,11 +128,8 @@
|
|||||||
throw new Error('Failed to create find');
|
throw new Error('Failed to create find');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form and close modal
|
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
showModal = false;
|
||||||
|
|
||||||
// Notify parent about new find creation
|
|
||||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating find:', error);
|
console.error('Error creating find:', error);
|
||||||
@@ -124,16 +139,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePlaceSelected(place: PlaceResult) {
|
||||||
|
locationName = place.name;
|
||||||
|
latitude = place.latitude.toString();
|
||||||
|
longitude = place.longitude.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocationMode() {
|
||||||
|
useManualLocation = !useManualLocation;
|
||||||
|
if (!useManualLocation && $coordinates) {
|
||||||
|
latitude = $coordinates.latitude.toString();
|
||||||
|
longitude = $coordinates.longitude.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
title = '';
|
title = '';
|
||||||
description = '';
|
description = '';
|
||||||
locationName = '';
|
locationName = '';
|
||||||
|
latitude = '';
|
||||||
|
longitude = '';
|
||||||
category = 'cafe';
|
category = 'cafe';
|
||||||
isPublic = true;
|
isPublic = true;
|
||||||
selectedFiles = null;
|
selectedFiles = null;
|
||||||
uploadedMedia = [];
|
uploadedMedia = [];
|
||||||
|
useManualLocation = false;
|
||||||
|
|
||||||
// Reset file input
|
|
||||||
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
@@ -142,291 +173,443 @@
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
showModal = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<Modal bind:showModal={isOpen} positioning="center">
|
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||||
{#snippet header()}
|
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||||
<h2>Create Find</h2>
|
<SheetHeader>
|
||||||
{/snippet}
|
<SheetTitle>Create Find</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div class="form-container">
|
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}}
|
}}
|
||||||
|
class="form"
|
||||||
>
|
>
|
||||||
<div class="form-group">
|
<div class="form-content">
|
||||||
<label for="title">Title *</label>
|
<div class="field">
|
||||||
<Input name="title" placeholder="What did you find?" required bind:value={title} />
|
<Label for="title">What did you find?</Label>
|
||||||
<span class="char-count">{title.length}/100</span>
|
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description</label>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
placeholder="Tell us about this place..."
|
|
||||||
maxlength="500"
|
|
||||||
bind:value={description}
|
|
||||||
></textarea>
|
|
||||||
<span class="char-count">{description.length}/500</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="location-name">Location Name</label>
|
|
||||||
<Input
|
|
||||||
name="location-name"
|
|
||||||
placeholder="e.g., Café Belga, Brussels"
|
|
||||||
bind:value={locationName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="latitude">Latitude *</label>
|
|
||||||
<Input name="latitude" type="text" required bind:value={latitude} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="longitude">Longitude *</label>
|
<div class="field">
|
||||||
<Input name="longitude" type="text" required bind:value={longitude} />
|
<Label for="description">Tell us about it</Label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
placeholder="The best cappuccino in town..."
|
||||||
|
maxlength="500"
|
||||||
|
bind:value={description}
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="location-section">
|
||||||
<label for="category">Category</label>
|
<div class="location-header">
|
||||||
<select name="category" bind:value={category}>
|
<Label>Location</Label>
|
||||||
{#each categories as cat (cat.value)}
|
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||||
<option value={cat.value}>{cat.label}</option>
|
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||||
{/each}
|
</button>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
{#if useManualLocation}
|
||||||
<label for="media-files">Photos/Videos (max 5)</label>
|
<div class="field">
|
||||||
<input
|
<Label for="location-name">Location name</Label>
|
||||||
id="media-files"
|
<Input
|
||||||
type="file"
|
name="location-name"
|
||||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
placeholder="Café Central, Brussels"
|
||||||
multiple
|
bind:value={locationName}
|
||||||
max="5"
|
/>
|
||||||
onchange={handleFileChange}
|
</div>
|
||||||
/>
|
{:else}
|
||||||
{#if selectedFiles && selectedFiles.length > 0}
|
<POISearch
|
||||||
<div class="file-preview">
|
onPlaceSelected={handlePlaceSelected}
|
||||||
{#each Array.from(selectedFiles) as file (file.name)}
|
placeholder="Search for cafés, restaurants, landmarks..."
|
||||||
<span class="file-name">{file.name}</span>
|
label=""
|
||||||
{/each}
|
showNearbyButton={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="category">Category</Label>
|
||||||
|
<select name="category" bind:value={category} class="select">
|
||||||
|
{#each categories as cat (cat.value)}
|
||||||
|
<option value={cat.value}>{cat.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Label>Privacy</Label>
|
||||||
|
<label class="privacy-toggle">
|
||||||
|
<input type="checkbox" bind:checked={isPublic} />
|
||||||
|
<span>{isPublic ? 'Public' : 'Private'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label for="media-files">Add photo or video</Label>
|
||||||
|
<div class="file-upload">
|
||||||
|
<input
|
||||||
|
id="media-files"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||||
|
onchange={handleFileChange}
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="file-content">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 4V10H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Click to upload</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if selectedFiles && selectedFiles.length > 0}
|
||||||
|
<div class="file-selected">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 4V10H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{selectedFiles[0].name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if useManualLocation || (!latitude && !longitude)}
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="latitude">Latitude</Label>
|
||||||
|
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Label for="longitude">Longitude</Label>
|
||||||
|
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if latitude && longitude}
|
||||||
|
<div class="coordinates-display">
|
||||||
|
<Label>Selected coordinates</Label>
|
||||||
|
<div class="coordinates-info">
|
||||||
|
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||||
|
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (useManualLocation = true)}
|
||||||
|
class="edit-coords-button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="actions">
|
||||||
<label class="privacy-toggle">
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
<input type="checkbox" bind:checked={isPublic} />
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
Make this find public
|
|
||||||
</label>
|
|
||||||
<p class="privacy-help">
|
|
||||||
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button secondary"
|
|
||||||
onclick={closeModal}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||||
{isSubmitting ? 'Creating...' : 'Create Find'}
|
{isSubmitting ? 'Creating...' : 'Create Find'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</SheetContent>
|
||||||
</Modal>
|
</Sheet>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.form-container {
|
:global(.create-find-sheet) {
|
||||||
padding: 24px;
|
padding: 0 !important;
|
||||||
max-height: 70vh;
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:global(.create-find-sheet) {
|
||||||
|
height: 90vh;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.field {
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.field-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
@media (max-width: 640px) {
|
||||||
display: block;
|
.field-group {
|
||||||
margin-bottom: 8px;
|
grid-template-columns: 1fr;
|
||||||
font-weight: 500;
|
}
|
||||||
color: #333;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: 'Washington', serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
min-height: 80px;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 0.75rem;
|
||||||
border: none;
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 2rem;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
outline: none;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea:focus {
|
|
||||||
background-color: #d5d5d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea::placeholder {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2rem;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
outline: none;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
select:focus {
|
|
||||||
background-color: #d5d5d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='file'] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px dashed #ccc;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #f9f9f9;
|
background: hsl(var(--background));
|
||||||
cursor: pointer;
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file']:hover {
|
textarea:focus {
|
||||||
border-color: #999;
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-count {
|
textarea::placeholder {
|
||||||
font-size: 0.8rem;
|
color: hsl(var(--muted-foreground));
|
||||||
color: #666;
|
|
||||||
text-align: right;
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-preview {
|
.select {
|
||||||
margin-top: 8px;
|
padding: 0.75rem;
|
||||||
display: flex;
|
border: 1px solid hsl(var(--border));
|
||||||
flex-wrap: wrap;
|
border-radius: 8px;
|
||||||
gap: 8px;
|
background: hsl(var(--background));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.select:focus {
|
||||||
background-color: #f0f0f0;
|
border-color: hsl(var(--primary));
|
||||||
padding: 4px 8px;
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle {
|
.privacy-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: normal;
|
font-size: 0.875rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-toggle:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle input[type='checkbox'] {
|
.privacy-toggle input[type='checkbox'] {
|
||||||
margin-right: 8px;
|
width: 16px;
|
||||||
width: 18px;
|
height: 16px;
|
||||||
height: 18px;
|
accent-color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-help {
|
.file-upload {
|
||||||
font-size: 0.8rem;
|
position: relative;
|
||||||
color: #666;
|
border: 2px dashed hsl(var(--border));
|
||||||
margin-top: 4px;
|
border-radius: 8px;
|
||||||
margin-left: 26px;
|
background: hsl(var(--muted) / 0.3);
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 32px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1.25rem 2rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 3.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.file-upload:hover {
|
||||||
opacity: 0.6;
|
border-color: hsl(var(--primary));
|
||||||
cursor: not-allowed;
|
background: hsl(var(--muted) / 0.5);
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled:hover {
|
.file-input {
|
||||||
transform: none;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.secondary {
|
.file-content {
|
||||||
background-color: #6c757d;
|
display: flex;
|
||||||
color: white;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.secondary:hover:not(:disabled) {
|
.file-content span {
|
||||||
background-color: #5a6268;
|
font-size: 0.875rem;
|
||||||
transform: translateY(-1px);
|
}
|
||||||
|
|
||||||
|
.file-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected svg {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions :global(button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.form-container {
|
.form-content {
|
||||||
padding: 16px;
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.actions {
|
||||||
grid-template-columns: 1fr;
|
padding: 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions :global(button) {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { Badge } from '$lib/components/badge';
|
import { Badge } from '$lib/components/badge';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
|
import CommentsList from '$lib/components/CommentsList.svelte';
|
||||||
|
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
||||||
|
|
||||||
interface FindCardProps {
|
interface FindCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
}>;
|
}>;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
|
commentCount?: number;
|
||||||
|
currentUserId?: string;
|
||||||
onExplore?: (id: string) => void;
|
onExplore?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,15 +39,19 @@
|
|||||||
media,
|
media,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
isLiked = false,
|
isLiked = false,
|
||||||
|
commentCount = 0,
|
||||||
|
currentUserId,
|
||||||
onExplore
|
onExplore
|
||||||
}: FindCardProps = $props();
|
}: FindCardProps = $props();
|
||||||
|
|
||||||
|
let showComments = $state(false);
|
||||||
|
|
||||||
function handleExplore() {
|
function handleExplore() {
|
||||||
onExplore?.(id);
|
onExplore?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserInitials(username: string): string {
|
function toggleComments() {
|
||||||
return username.slice(0, 2).toUpperCase();
|
showComments = !showComments;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -52,14 +59,11 @@
|
|||||||
<!-- Post Header -->
|
<!-- Post Header -->
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<Avatar class="avatar">
|
<ProfilePicture
|
||||||
{#if user.profilePictureUrl}
|
username={user.username}
|
||||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
profilePictureUrl={user.profilePictureUrl}
|
||||||
{/if}
|
class="avatar"
|
||||||
<AvatarFallback class="avatar-fallback">
|
/>
|
||||||
{getUserInitials(user.username)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<div class="username">@{user.username}</div>
|
<div class="username">@{user.username}</div>
|
||||||
{#if locationName}
|
{#if locationName}
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" class="more-button">
|
<Button variant="ghost" size="sm" class="more-button">
|
||||||
<MoreHorizontal size={16} />
|
<Ellipsis size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,8 +105,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div class="post-media">
|
{#if media && media.length > 0}
|
||||||
{#if media && media.length > 0}
|
<div class="post-media">
|
||||||
{#if media[0].type === 'photo'}
|
{#if media[0].type === 'photo'}
|
||||||
<img
|
<img
|
||||||
src={media[0].thumbnailUrl || media[0].url}
|
src={media[0].thumbnailUrl || media[0].url}
|
||||||
@@ -120,29 +124,16 @@
|
|||||||
class="media-video"
|
class="media-video"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<div class="no-media">
|
{/if}
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Actions -->
|
<!-- Post Actions -->
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
||||||
<Button variant="ghost" size="sm" class="action-button">
|
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
|
||||||
<MessageCircle size={16} />
|
<MessageCircle size={16} />
|
||||||
<span>comment</span>
|
<span>{commentCount || 'comment'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" class="action-button">
|
<Button variant="ghost" size="sm" class="action-button">
|
||||||
<Share size={16} />
|
<Share size={16} />
|
||||||
@@ -153,6 +144,19 @@
|
|||||||
explore
|
explore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
{#if showComments}
|
||||||
|
<div class="comments-section">
|
||||||
|
<CommentsList
|
||||||
|
findId={id}
|
||||||
|
{currentUserId}
|
||||||
|
collapsed={false}
|
||||||
|
maxComments={5}
|
||||||
|
showCommentForm={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -258,33 +262,23 @@
|
|||||||
/* Media */
|
/* Media */
|
||||||
.post-media {
|
.post-media {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16/10;
|
max-height: 600px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-image {
|
.media-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
|
max-height: 600px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.media-video) {
|
:global(.media-video) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
}
|
max-height: 600px;
|
||||||
|
|
||||||
.no-media {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Post Actions */
|
/* Post Actions */
|
||||||
@@ -317,6 +311,13 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Comments Section */
|
||||||
|
.comments-section {
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Mobile responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.post-actions {
|
.post-actions {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import CommentsList from '$lib/components/CommentsList.svelte';
|
||||||
|
|
||||||
interface Find {
|
interface Find {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,9 +31,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
find: Find | null;
|
find: Find | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
currentUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { find, onClose }: Props = $props();
|
let { find, onClose, currentUserId }: Props = $props();
|
||||||
|
|
||||||
let showModal = $state(true);
|
let showModal = $state(true);
|
||||||
let currentMediaIndex = $state(0);
|
let currentMediaIndex = $state(0);
|
||||||
@@ -112,14 +114,11 @@
|
|||||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||||
<SheetHeader class="sheet-header">
|
<SheetHeader class="sheet-header">
|
||||||
<div class="user-section">
|
<div class="user-section">
|
||||||
<Avatar class="user-avatar">
|
<ProfilePicture
|
||||||
{#if find.user.profilePictureUrl}
|
username={find.user.username}
|
||||||
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
|
profilePictureUrl={find.user.profilePictureUrl}
|
||||||
{/if}
|
class="user-avatar"
|
||||||
<AvatarFallback class="avatar-fallback">
|
/>
|
||||||
{find.user.username.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||||
<div class="find-meta">
|
<div class="find-meta">
|
||||||
@@ -254,6 +253,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<CommentsList
|
||||||
|
findId={find.id}
|
||||||
|
{currentUserId}
|
||||||
|
isScrollable={true}
|
||||||
|
showCommentForm={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -278,7 +286,7 @@
|
|||||||
/* Mobile styles (bottom sheet) */
|
/* Mobile styles (bottom sheet) */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
:global(.sheet-content) {
|
:global(.sheet-content) {
|
||||||
height: 80vh !important;
|
height: 50vh !important;
|
||||||
border-radius: 16px 16px 0 0 !important;
|
border-radius: 16px 16px 0 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,7 +422,7 @@
|
|||||||
.media-image {
|
.media-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.media-video) {
|
:global(.media-video) {
|
||||||
@@ -545,6 +553,30 @@
|
|||||||
background: hsl(var(--secondary) / 0.8);
|
background: hsl(var(--secondary) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop comments section */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.comments-section {
|
||||||
|
height: calc(100vh - 400px);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile comments section */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.comments-section {
|
||||||
|
height: calc(80vh - 350px);
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile specific adjustments */
|
/* Mobile specific adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
:global(.sheet-header) {
|
:global(.sheet-header) {
|
||||||
@@ -576,5 +608,9 @@
|
|||||||
.action-button {
|
.action-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
height: calc(80vh - 380px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
title?: string;
|
title?: string;
|
||||||
showEmpty?: boolean;
|
showEmpty?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
onFindExplore,
|
onFindExplore,
|
||||||
title = 'Finds',
|
title = 'Finds',
|
||||||
showEmpty = true,
|
showEmpty = true,
|
||||||
emptyMessage = 'No finds to display'
|
emptyMessage = 'No finds to display',
|
||||||
|
hideTitle = false
|
||||||
}: FindsListProps = $props();
|
}: FindsListProps = $props();
|
||||||
|
|
||||||
function handleFindExplore(id: string) {
|
function handleFindExplore(id: string) {
|
||||||
@@ -42,9 +44,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="finds-feed">
|
<section class="finds-feed">
|
||||||
<div class="feed-header">
|
{#if !hideTitle}
|
||||||
<h2 class="feed-title">{title}</h2>
|
<div class="feed-header">
|
||||||
</div>
|
<h2 class="feed-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if finds.length > 0}
|
{#if finds.length > 0}
|
||||||
<div class="feed-container">
|
<div class="feed-container">
|
||||||
@@ -98,6 +102,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.finds-feed {
|
.finds-feed {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-header {
|
.feed-header {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { Heart } from '@lucide/svelte';
|
import { Heart } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
findId: string;
|
findId: string;
|
||||||
@@ -19,59 +21,75 @@
|
|||||||
class: className = ''
|
class: className = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Track the source of truth - server state
|
// Local state stores for this like button - start with props but will be overridden by global state
|
||||||
let serverIsLiked = $state(isLiked);
|
const likeState = writable({
|
||||||
let serverLikeCount = $state(likeCount);
|
isLiked: isLiked,
|
||||||
|
likeCount: likeCount,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
// Track optimistic state during loading
|
let apiSync: any = null;
|
||||||
let isLoading = $state(false);
|
|
||||||
let optimisticIsLiked = $state(isLiked);
|
|
||||||
let optimisticLikeCount = $state(likeCount);
|
|
||||||
|
|
||||||
// Derived state for display
|
// Initialize API sync and subscribe to global state
|
||||||
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
|
onMount(async () => {
|
||||||
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
|
if (browser) {
|
||||||
|
try {
|
||||||
|
// Dynamically import the API sync
|
||||||
|
const module = await import('$lib/stores/api-sync');
|
||||||
|
apiSync = module.apiSync;
|
||||||
|
|
||||||
|
// Check if global state already exists for this find
|
||||||
|
const existingState = apiSync.getEntityState('find', findId);
|
||||||
|
|
||||||
|
if (existingState) {
|
||||||
|
// Use existing global state - it's more current than props
|
||||||
|
console.log(`Using existing global state for find ${findId}`, existingState);
|
||||||
|
} else {
|
||||||
|
// Initialize with minimal data only if no global state exists
|
||||||
|
console.log(`Initializing new minimal state for find ${findId}`);
|
||||||
|
apiSync.setEntityState('find', findId, {
|
||||||
|
id: findId,
|
||||||
|
isLikedByUser: isLiked,
|
||||||
|
likeCount: likeCount,
|
||||||
|
// Minimal data needed for like functionality
|
||||||
|
title: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
isPublic: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: '',
|
||||||
|
username: '',
|
||||||
|
isFromFriend: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to global state for this find
|
||||||
|
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||||
|
globalLikeState.subscribe((state: any) => {
|
||||||
|
likeState.set({
|
||||||
|
isLiked: state.isLiked,
|
||||||
|
likeCount: state.likeCount,
|
||||||
|
isLoading: state.isLoading
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize API sync:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function toggleLike() {
|
async function toggleLike() {
|
||||||
if (isLoading) return;
|
if (!apiSync || !browser) return;
|
||||||
|
|
||||||
// Set optimistic state
|
const currentState = likeState;
|
||||||
optimisticIsLiked = !serverIsLiked;
|
if (currentState && (currentState as any).isLoading) return;
|
||||||
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
|
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = optimisticIsLiked ? 'POST' : 'DELETE';
|
await apiSync.toggleLike(findId);
|
||||||
const response = await fetch(`/api/finds/${findId}/like`, {
|
} catch (error) {
|
||||||
method,
|
console.error('Failed to toggle like:', error);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Update server state with response
|
|
||||||
serverIsLiked = result.isLiked;
|
|
||||||
serverLikeCount = result.likeCount;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('Error updating like:', error);
|
|
||||||
toast.error('Failed to update like. Please try again.');
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update server state when props change (from parent component)
|
|
||||||
$effect(() => {
|
|
||||||
serverIsLiked = isLiked;
|
|
||||||
serverLikeCount = likeCount;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -79,22 +97,22 @@
|
|||||||
{size}
|
{size}
|
||||||
class="group gap-1.5 {className}"
|
class="group gap-1.5 {className}"
|
||||||
onclick={toggleLike}
|
onclick={toggleLike}
|
||||||
disabled={isLoading}
|
disabled={$likeState.isLoading}
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
class="h-4 w-4 transition-all duration-200 {displayIsLiked
|
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
|
||||||
? 'scale-110 fill-red-500 text-red-500'
|
? 'scale-110 fill-red-500 text-red-500'
|
||||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
|
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
|
||||||
? 'animate-pulse'
|
? 'animate-pulse'
|
||||||
: ''}"
|
: ''}"
|
||||||
/>
|
/>
|
||||||
{#if displayLikeCount > 0}
|
{#if $likeState.likeCount > 0}
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium transition-colors {displayIsLiked
|
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||||
? 'text-red-500'
|
? 'text-red-500'
|
||||||
: 'text-gray-500 group-hover:text-red-400'}"
|
: 'text-gray-500 group-hover:text-red-400'}"
|
||||||
>
|
>
|
||||||
{displayLikeCount}
|
{$likeState.likeCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
locationActions,
|
locationActions,
|
||||||
locationStatus,
|
locationStatus,
|
||||||
locationError,
|
locationError,
|
||||||
isLocationLoading
|
isLocationLoading,
|
||||||
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
|
|
||||||
@@ -22,26 +23,45 @@
|
|||||||
showLabel = true
|
showLabel = true
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
async function handleLocationClick() {
|
// Track if location watching is active from the derived store
|
||||||
const result = await locationActions.getCurrentLocation({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 15000,
|
|
||||||
maximumAge: 300000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result && $locationError) {
|
async function handleLocationClick() {
|
||||||
toast.error($locationError.message);
|
if ($isWatching) {
|
||||||
|
// Stop watching if currently active
|
||||||
|
locationActions.stopWatching();
|
||||||
|
toast.success('Location watching stopped');
|
||||||
|
} else {
|
||||||
|
// Try to get current location first, then start watching
|
||||||
|
const result = await locationActions.getCurrentLocation({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 300000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Start watching for continuous updates
|
||||||
|
locationActions.startWatching({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 60000 // Update every minute
|
||||||
|
});
|
||||||
|
toast.success('Location watching started');
|
||||||
|
} else if ($locationError) {
|
||||||
|
toast.error($locationError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonText = $derived(() => {
|
const buttonText = $derived(() => {
|
||||||
if ($isLocationLoading) return 'Finding location...';
|
if ($isLocationLoading) return 'Finding location...';
|
||||||
if ($locationStatus === 'success') return 'Update location';
|
if ($isWatching) return 'Stop watching location';
|
||||||
|
if ($locationStatus === 'success') return 'Watch location';
|
||||||
return 'Find my location';
|
return 'Find my location';
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconClass = $derived(() => {
|
const iconClass = $derived(() => {
|
||||||
if ($isLocationLoading) return 'loading';
|
if ($isLocationLoading) return 'loading';
|
||||||
|
if ($isWatching) return 'watching';
|
||||||
if ($locationStatus === 'success') return 'success';
|
if ($locationStatus === 'success') return 'success';
|
||||||
if ($locationStatus === 'error') return 'error';
|
if ($locationStatus === 'error') return 'error';
|
||||||
return 'default';
|
return 'default';
|
||||||
@@ -59,6 +79,22 @@
|
|||||||
<div class="loading-skeleton">
|
<div class="loading-skeleton">
|
||||||
<Skeleton class="h-5 w-5 rounded-full" />
|
<Skeleton class="h-5 w-5 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if $isWatching}
|
||||||
|
<svg viewBox="0 0 24 24" class="watching-icon">
|
||||||
|
<path
|
||||||
|
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="9"
|
||||||
|
r="15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.3"
|
||||||
|
class="pulse-ring"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{:else if $locationStatus === 'success'}
|
{:else if $locationStatus === 'success'}
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -201,6 +237,11 @@
|
|||||||
fill: #10b981;
|
fill: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon.watching svg {
|
||||||
|
color: #f59e0b;
|
||||||
|
fill: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.icon.error svg {
|
.icon.error svg {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
fill: #ef4444;
|
fill: #ef4444;
|
||||||
@@ -219,6 +260,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watching-icon .pulse-ring {
|
||||||
|
animation: pulse-ring 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/lib/components/LocationManager.svelte
Normal file
81
src/lib/components/LocationManager.svelte
Normal 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 -->
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
getMapCenter,
|
getMapCenter,
|
||||||
getMapZoom,
|
getMapZoom,
|
||||||
shouldZoomToLocation,
|
shouldZoomToLocation,
|
||||||
locationActions
|
locationActions,
|
||||||
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import LocationButton from './LocationButton.svelte';
|
import LocationButton from './LocationButton.svelte';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
@@ -139,11 +140,14 @@
|
|||||||
>
|
>
|
||||||
{#if $coordinates}
|
{#if $coordinates}
|
||||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||||
<div class="location-marker">
|
<div class="location-marker" class:watching={$isWatching}>
|
||||||
<div class="marker-pulse"></div>
|
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||||
<div class="marker-outer">
|
<div class="marker-outer" class:watching={$isWatching}>
|
||||||
<div class="marker-inner"></div>
|
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if $isWatching}
|
||||||
|
<div class="watching-ring"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -243,6 +247,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marker-outer.watching) {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.marker-inner) {
|
:global(.marker-inner) {
|
||||||
@@ -250,6 +261,12 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marker-inner.watching) {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.marker-pulse) {
|
:global(.marker-pulse) {
|
||||||
@@ -263,6 +280,22 @@
|
|||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.marker-pulse.watching) {
|
||||||
|
border-color: rgba(245, 158, 11, 0.6);
|
||||||
|
animation: pulse-watching 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.watching-ring) {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid rgba(245, 158, 11, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: expand-ring 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -274,6 +307,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-watching {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expand-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Find marker styles */
|
/* Find marker styles */
|
||||||
:global(.find-marker) {
|
:global(.find-marker) {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
403
src/lib/components/POISearch.svelte
Normal file
403
src/lib/components/POISearch.svelte
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '$lib/components/input';
|
||||||
|
import { Label } from '$lib/components/label';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onPlaceSelected: (place: PlaceResult) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
showNearbyButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
onPlaceSelected,
|
||||||
|
placeholder = 'Search for a place or address...',
|
||||||
|
label = 'Search location',
|
||||||
|
showNearbyButton = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
|
||||||
|
let nearbyPlaces = $state<PlaceResult[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
let showNearby = $state(false);
|
||||||
|
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
async function searchPlaces(query: string) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
suggestions = [];
|
||||||
|
showSuggestions = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'autocomplete',
|
||||||
|
query: query.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($coordinates) {
|
||||||
|
params.set('lat', $coordinates.latitude.toString());
|
||||||
|
params.set('lng', $coordinates.longitude.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
suggestions = await response.json();
|
||||||
|
showSuggestions = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching places:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPlace(placeId: string, description: string) {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'details',
|
||||||
|
placeId
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const place: PlaceResult = await response.json();
|
||||||
|
onPlaceSelected(place);
|
||||||
|
searchQuery = description;
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting place details:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findNearbyPlaces() {
|
||||||
|
if (!$coordinates) {
|
||||||
|
alert('Please enable location access first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
showNearby = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'nearby',
|
||||||
|
lat: $coordinates.latitude.toString(),
|
||||||
|
lng: $coordinates.longitude.toString(),
|
||||||
|
radius: '2000' // 2km radius
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
nearbyPlaces = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding nearby places:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
searchQuery = target.value;
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
searchPlaces(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNearbyPlace(place: PlaceResult) {
|
||||||
|
onPlaceSelected(place);
|
||||||
|
searchQuery = place.name;
|
||||||
|
showNearby = false;
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: Event) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.poi-search-container')) {
|
||||||
|
showSuggestions = false;
|
||||||
|
showNearby = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="poi-search-container">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="poi-search">{label}</Label>
|
||||||
|
<div class="search-input-container">
|
||||||
|
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
|
||||||
|
{#if showNearbyButton && $coordinates}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={findNearbyPlaces}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
Nearby
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
|
<div class="suggestions-dropdown">
|
||||||
|
<div class="suggestions-header">Search Results</div>
|
||||||
|
{#each suggestions as suggestion (suggestion.placeId)}
|
||||||
|
<button
|
||||||
|
class="suggestion-item"
|
||||||
|
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<div class="suggestion-content">
|
||||||
|
<span class="suggestion-name">{suggestion.description}</span>
|
||||||
|
<div class="suggestion-types">
|
||||||
|
{#each suggestion.types.slice(0, 2) as type}
|
||||||
|
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNearby && nearbyPlaces.length > 0}
|
||||||
|
<div class="suggestions-dropdown">
|
||||||
|
<div class="suggestions-header">Nearby Places</div>
|
||||||
|
{#each nearbyPlaces as place (place.placeId)}
|
||||||
|
<button
|
||||||
|
class="suggestion-item"
|
||||||
|
onclick={() => selectNearbyPlace(place)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<div class="suggestion-content">
|
||||||
|
<span class="suggestion-name">{place.name}</span>
|
||||||
|
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
|
||||||
|
{#if place.rating}
|
||||||
|
<div class="suggestion-rating">
|
||||||
|
<span class="rating-stars">★</span>
|
||||||
|
<span>{place.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="32"
|
||||||
|
stroke-dashoffset="32"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Searching...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.poi-search-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:hover:not(:disabled) {
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-address {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-types {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-type {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-stars {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-icon {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.search-input-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from './dropdown-menu';
|
} from './dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,9 +22,6 @@
|
|||||||
|
|
||||||
let showProfilePictureSheet = $state(false);
|
let showProfilePictureSheet = $state(false);
|
||||||
|
|
||||||
// Get the first letter of username for avatar
|
|
||||||
const initial = username.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
function openProfilePictureSheet() {
|
function openProfilePictureSheet() {
|
||||||
showProfilePictureSheet = true;
|
showProfilePictureSheet = true;
|
||||||
}
|
}
|
||||||
@@ -36,18 +33,7 @@
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger class="profile-trigger">
|
<DropdownMenuTrigger class="profile-trigger">
|
||||||
{#if loading}
|
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
{:else}
|
|
||||||
<Avatar class="profile-avatar">
|
|
||||||
{#if profilePictureUrl}
|
|
||||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
|
||||||
{/if}
|
|
||||||
<AvatarFallback class="profile-avatar-fallback">
|
|
||||||
{initial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
||||||
@@ -144,15 +130,6 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.profile-avatar-fallback) {
|
|
||||||
background: black;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.profile-dropdown-content) {
|
:global(.profile-dropdown-content) {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
|
|||||||
59
src/lib/components/ProfilePicture.svelte
Normal file
59
src/lib/components/ProfilePicture.svelte
Normal 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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -19,8 +19,6 @@
|
|||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
let showModal = $state(true);
|
let showModal = $state(true);
|
||||||
|
|
||||||
const initial = username.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
// Close modal when showModal changes to false
|
// Close modal when showModal changes to false
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!showModal && onClose) {
|
if (!showModal && onClose) {
|
||||||
@@ -118,14 +116,7 @@
|
|||||||
|
|
||||||
<div class="profile-picture-content">
|
<div class="profile-picture-content">
|
||||||
<div class="current-avatar">
|
<div class="current-avatar">
|
||||||
<Avatar class="large-avatar">
|
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
|
||||||
{#if profilePictureUrl}
|
|
||||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
|
||||||
{/if}
|
|
||||||
<AvatarFallback class="large-avatar-fallback">
|
|
||||||
{initial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
@@ -190,15 +181,6 @@
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.large-avatar-fallback) {
|
|
||||||
background: black;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 48px;
|
|
||||||
border: 4px solid #fff;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-section {
|
.upload-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ export { default as Input } from './components/Input.svelte';
|
|||||||
export { default as Button } from './components/Button.svelte';
|
export { default as Button } from './components/Button.svelte';
|
||||||
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
||||||
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
||||||
|
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
|
||||||
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
||||||
export { default as Header } from './components/Header.svelte';
|
export { default as Header } from './components/Header.svelte';
|
||||||
export { default as Modal } from './components/Modal.svelte';
|
export { default as Modal } from './components/Modal.svelte';
|
||||||
export { default as Map } from './components/Map.svelte';
|
export { default as Map } from './components/Map.svelte';
|
||||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||||
|
export { default as LocationManager } from './components/LocationManager.svelte';
|
||||||
export { default as FindCard } from './components/FindCard.svelte';
|
export { default as FindCard } from './components/FindCard.svelte';
|
||||||
export { default as FindsList } from './components/FindsList.svelte';
|
export { default as FindsList } from './components/FindsList.svelte';
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export {
|
|||||||
locationError,
|
locationError,
|
||||||
isLocationLoading,
|
isLocationLoading,
|
||||||
hasLocationAccess,
|
hasLocationAccess,
|
||||||
|
isWatching,
|
||||||
getMapCenter,
|
getMapCenter,
|
||||||
getMapZoom
|
getMapZoom
|
||||||
} from './stores/location';
|
} from './stores/location';
|
||||||
|
|||||||
@@ -75,13 +75,28 @@ export const friendship = pgTable('friendship', {
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const findComment = pgTable('find_comment', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
findId: text('find_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => find.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
// Type exports for the new tables
|
// Type exports for the new tables
|
||||||
export type Find = typeof find.$inferSelect;
|
export type Find = typeof find.$inferSelect;
|
||||||
export type FindMedia = typeof findMedia.$inferSelect;
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
export type FindLike = typeof findLike.$inferSelect;
|
export type FindLike = typeof findLike.$inferSelect;
|
||||||
|
export type FindComment = typeof findComment.$inferSelect;
|
||||||
export type Friendship = typeof friendship.$inferSelect;
|
export type Friendship = typeof friendship.$inferSelect;
|
||||||
|
|
||||||
export type FindInsert = typeof find.$inferInsert;
|
export type FindInsert = typeof find.$inferInsert;
|
||||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
|
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||||
|
|||||||
727
src/lib/stores/api-sync.ts
Normal file
727
src/lib/stores/api-sync.ts
Normal 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();
|
||||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
|||||||
locationStore,
|
locationStore,
|
||||||
($location) => $location.shouldZoomToLocation
|
($location) => $location.shouldZoomToLocation
|
||||||
);
|
);
|
||||||
|
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||||
|
|
||||||
// Location actions
|
// Location actions
|
||||||
export const locationActions = {
|
export const locationActions = {
|
||||||
|
|||||||
194
src/lib/utils/places.ts
Normal file
194
src/lib/utils/places.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export interface PlaceResult {
|
||||||
|
placeId: string;
|
||||||
|
name: string;
|
||||||
|
formattedAddress: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
types: string[];
|
||||||
|
vicinity?: string;
|
||||||
|
rating?: number;
|
||||||
|
priceLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GooglePlacesPrediction {
|
||||||
|
place_id: string;
|
||||||
|
description: string;
|
||||||
|
types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GooglePlacesResult {
|
||||||
|
place_id: string;
|
||||||
|
name: string;
|
||||||
|
formatted_address?: string;
|
||||||
|
vicinity?: string;
|
||||||
|
geometry: {
|
||||||
|
location: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
types?: string[];
|
||||||
|
rating?: number;
|
||||||
|
price_level?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaceSearchOptions {
|
||||||
|
query?: string;
|
||||||
|
location?: { lat: number; lng: number };
|
||||||
|
radius?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GooglePlacesService {
|
||||||
|
private apiKey: string;
|
||||||
|
private baseUrl = 'https://maps.googleapis.com/maps/api/place';
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for places using text query
|
||||||
|
*/
|
||||||
|
async searchPlaces(
|
||||||
|
query: string,
|
||||||
|
location?: { lat: number; lng: number }
|
||||||
|
): Promise<PlaceResult[]> {
|
||||||
|
const url = new URL(`${this.baseUrl}/textsearch/json`);
|
||||||
|
url.searchParams.set('query', query);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', '50000'); // 50km radius
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.results.map(this.formatPlaceResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching places:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get place autocomplete suggestions
|
||||||
|
*/
|
||||||
|
async getAutocompleteSuggestions(
|
||||||
|
input: string,
|
||||||
|
location?: { lat: number; lng: number }
|
||||||
|
): Promise<Array<{ placeId: string; description: string; types: string[] }>> {
|
||||||
|
const url = new URL(`${this.baseUrl}/autocomplete/json`);
|
||||||
|
url.searchParams.set('input', input);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', '50000');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.predictions?.map((prediction: GooglePlacesPrediction) => ({
|
||||||
|
placeId: prediction.place_id,
|
||||||
|
description: prediction.description,
|
||||||
|
types: prediction.types
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting autocomplete suggestions:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a place
|
||||||
|
*/
|
||||||
|
async getPlaceDetails(placeId: string): Promise<PlaceResult> {
|
||||||
|
const url = new URL(`${this.baseUrl}/details/json`);
|
||||||
|
url.searchParams.set('place_id', placeId);
|
||||||
|
url.searchParams.set(
|
||||||
|
'fields',
|
||||||
|
'place_id,name,formatted_address,geometry,types,vicinity,rating,price_level'
|
||||||
|
);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatPlaceResult(data.result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting place details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find nearby places
|
||||||
|
*/
|
||||||
|
async findNearbyPlaces(
|
||||||
|
location: { lat: number; lng: number },
|
||||||
|
radius: number = 5000,
|
||||||
|
type?: string
|
||||||
|
): Promise<PlaceResult[]> {
|
||||||
|
const url = new URL(`${this.baseUrl}/nearbysearch/json`);
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', radius.toString());
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
url.searchParams.set('type', type);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.results?.map(this.formatPlaceResult) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding nearby places:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPlaceResult = (place: GooglePlacesResult): PlaceResult => {
|
||||||
|
return {
|
||||||
|
placeId: place.place_id,
|
||||||
|
name: place.name,
|
||||||
|
formattedAddress: place.formatted_address || place.vicinity || '',
|
||||||
|
latitude: place.geometry.location.lat,
|
||||||
|
longitude: place.geometry.location.lng,
|
||||||
|
types: place.types || [],
|
||||||
|
vicinity: place.vicinity,
|
||||||
|
rating: place.rating,
|
||||||
|
priceLevel: place.price_level
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlacesService(apiKey: string): GooglePlacesService {
|
||||||
|
return new GooglePlacesService(apiKey);
|
||||||
|
}
|
||||||
@@ -5,11 +5,21 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Toaster } from '$lib/components/sonner/index.js';
|
import { Toaster } from '$lib/components/sonner/index.js';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
import LocationManager from '$lib/components/LocationManager.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||||
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
// Handle loading state only on client to prevent hydration mismatch
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
isLoading = !isLoginRoute && !data?.user && data !== null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -32,6 +42,11 @@
|
|||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
|
<!-- Auto-start location watching for authenticated users -->
|
||||||
|
{#if data?.user && !isLoginRoute}
|
||||||
|
<LocationManager autoStart={true} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showHeader && data.user}
|
{#if showHeader && data.user}
|
||||||
<Header user={data.user} />
|
<Header user={data.user} />
|
||||||
{:else if isLoading}
|
{:else if isLoading}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
// Server response type
|
// Server response type
|
||||||
interface ServerFind {
|
interface ServerFind {
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLikedByUser?: boolean;
|
isLikedByUser?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
media: Array<{
|
media: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
findId: string;
|
findId: string;
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
};
|
};
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -90,6 +95,33 @@
|
|||||||
let selectedFind: FindPreviewData | null = $state(null);
|
let selectedFind: FindPreviewData | null = $state(null);
|
||||||
let currentFilter = $state('all');
|
let currentFilter = $state('all');
|
||||||
|
|
||||||
|
// Initialize API sync with server data on mount
|
||||||
|
onMount(async () => {
|
||||||
|
if (browser && data.finds && data.finds.length > 0) {
|
||||||
|
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
||||||
|
id: serverFind.id,
|
||||||
|
title: serverFind.title,
|
||||||
|
description: serverFind.description,
|
||||||
|
latitude: serverFind.latitude,
|
||||||
|
longitude: serverFind.longitude,
|
||||||
|
locationName: serverFind.locationName,
|
||||||
|
category: serverFind.category,
|
||||||
|
isPublic: Boolean(serverFind.isPublic),
|
||||||
|
createdAt: new Date(serverFind.createdAt),
|
||||||
|
userId: serverFind.userId,
|
||||||
|
username: serverFind.username,
|
||||||
|
profilePictureUrl: serverFind.profilePictureUrl || undefined,
|
||||||
|
media: serverFind.media,
|
||||||
|
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
||||||
|
likeCount: serverFind.likeCount || 0,
|
||||||
|
commentCount: 0,
|
||||||
|
isFromFriend: Boolean(serverFind.isFromFriend)
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiSync.initializeFindData(findStates);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// All finds - convert server format to component format
|
// All finds - convert server format to component format
|
||||||
let allFinds = $derived(
|
let allFinds = $derived(
|
||||||
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
||||||
@@ -102,6 +134,7 @@
|
|||||||
},
|
},
|
||||||
likeCount: serverFind.likeCount,
|
likeCount: serverFind.likeCount,
|
||||||
isLiked: serverFind.isLikedByUser,
|
isLiked: serverFind.isLikedByUser,
|
||||||
|
isFromFriend: serverFind.isFromFriend,
|
||||||
media: serverFind.media?.map(
|
media: serverFind.media?.map(
|
||||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||||
type: m.type,
|
type: m.type,
|
||||||
@@ -120,7 +153,7 @@
|
|||||||
case 'public':
|
case 'public':
|
||||||
return allFinds.filter((find) => find.isPublic === 1);
|
return allFinds.filter((find) => find.isPublic === 1);
|
||||||
case 'friends':
|
case 'friends':
|
||||||
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
|
return allFinds.filter((find) => find.isFromFriend === true);
|
||||||
case 'mine':
|
case 'mine':
|
||||||
return allFinds.filter((find) => find.userId === data.user!.id);
|
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||||
case 'all':
|
case 'all':
|
||||||
@@ -217,19 +250,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="finds-section">
|
<div class="finds-section">
|
||||||
<div class="finds-header">
|
<div class="finds-sticky-header">
|
||||||
<div class="finds-title-section">
|
<div class="finds-header-content">
|
||||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
<div class="finds-title-section">
|
||||||
|
<h2 class="finds-title">Finds</h2>
|
||||||
|
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||||
|
</div>
|
||||||
|
<Button onclick={openCreateModal} class="create-find-button">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
Create Find
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FindsList {finds} onFindExplore={handleFindExplore} />
|
|
||||||
<Button onclick={openCreateModal} class="create-find-button">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
Create Find
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -252,7 +288,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedFind}
|
{#if selectedFind}
|
||||||
<FindPreview find={selectedFind} onClose={closeFindPreview} />
|
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -285,27 +321,44 @@
|
|||||||
.finds-section {
|
.finds-section {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-header {
|
.finds-sticky-header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
padding: 24px 24px 16px 24px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title-section {
|
.finds-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.create-find-button) {
|
:global(.create-find-button) {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
@@ -338,14 +391,25 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-section {
|
.finds-sticky-header {
|
||||||
padding: 16px;
|
padding: 16px 16px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title-section {
|
.finds-title-section {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
}
|
||||||
|
|
||||||
|
.finds-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.create-find-button) {
|
:global(.create-find-button) {
|
||||||
@@ -366,8 +430,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-section {
|
.finds-sticky-header {
|
||||||
padding: 12px;
|
padding: 12px 12px 8px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,7 +107,15 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
SELECT 1 FROM ${findLike}
|
SELECT 1 FROM ${findLike}
|
||||||
WHERE ${findLike.findId} = ${find.id}
|
WHERE ${findLike.findId} = ${find.id}
|
||||||
AND ${findLike.userId} = ${locals.user.id}
|
AND ${findLike.userId} = ${locals.user.id}
|
||||||
) THEN 1 ELSE 0 END`
|
) THEN 1 ELSE 0 END`,
|
||||||
|
isFromFriend: sql<boolean>`CASE WHEN ${
|
||||||
|
friendIds.length > 0
|
||||||
|
? sql`${find.userId} IN (${sql.join(
|
||||||
|
friendIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
: sql`FALSE`
|
||||||
|
} THEN 1 ELSE 0 END`
|
||||||
})
|
})
|
||||||
.from(find)
|
.from(find)
|
||||||
.innerJoin(user, eq(find.userId, user.id))
|
.innerJoin(user, eq(find.userId, user.id))
|
||||||
@@ -199,7 +207,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
...findItem,
|
...findItem,
|
||||||
profilePictureUrl: userProfilePictureUrl,
|
profilePictureUrl: userProfilePictureUrl,
|
||||||
media: mediaWithSignedUrls,
|
media: mediaWithSignedUrls,
|
||||||
isLikedByUser: Boolean(findItem.isLikedByUser)
|
isLikedByUser: Boolean(findItem.isLikedByUser),
|
||||||
|
isFromFriend: Boolean(findItem.isFromFriend)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
119
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
119
src/routes/api/finds/[findId]/comments/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
83
src/routes/api/places/+server.ts
Normal file
83
src/routes/api/places/+server.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { createPlacesService } from '$lib/utils/places';
|
||||||
|
|
||||||
|
const getPlacesService = () => {
|
||||||
|
const apiKey = env.GOOGLE_MAPS_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set');
|
||||||
|
}
|
||||||
|
return createPlacesService(apiKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = url.searchParams.get('action');
|
||||||
|
const query = url.searchParams.get('query');
|
||||||
|
const placeId = url.searchParams.get('placeId');
|
||||||
|
const lat = url.searchParams.get('lat');
|
||||||
|
const lng = url.searchParams.get('lng');
|
||||||
|
const radius = url.searchParams.get('radius');
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const placesService = getPlacesService();
|
||||||
|
|
||||||
|
let location;
|
||||||
|
if (lat && lng) {
|
||||||
|
location = { lat: parseFloat(lat), lng: parseFloat(lng) };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'search': {
|
||||||
|
if (!query) {
|
||||||
|
throw error(400, 'Query parameter is required for search');
|
||||||
|
}
|
||||||
|
const searchResults = await placesService.searchPlaces(query, location);
|
||||||
|
return json(searchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'autocomplete': {
|
||||||
|
if (!query) {
|
||||||
|
throw error(400, 'Query parameter is required for autocomplete');
|
||||||
|
}
|
||||||
|
const suggestions = await placesService.getAutocompleteSuggestions(query, location);
|
||||||
|
return json(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'details': {
|
||||||
|
if (!placeId) {
|
||||||
|
throw error(400, 'PlaceId parameter is required for details');
|
||||||
|
}
|
||||||
|
const placeDetails = await placesService.getPlaceDetails(placeId);
|
||||||
|
return json(placeDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nearby': {
|
||||||
|
if (!location) {
|
||||||
|
throw error(400, 'Location parameters (lat, lng) are required for nearby search');
|
||||||
|
}
|
||||||
|
const radiusNum = radius ? parseInt(radius) : 5000;
|
||||||
|
const nearbyResults = await placesService.findNearbyPlaces(
|
||||||
|
location,
|
||||||
|
radiusNum,
|
||||||
|
type || undefined
|
||||||
|
);
|
||||||
|
return json(nearbyResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw error(400, 'Invalid action parameter');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Places API error:', err);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw error(500, err.message);
|
||||||
|
}
|
||||||
|
throw error(500, 'Failed to fetch places data');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { Input } from '$lib/components/input';
|
import { Input } from '$lib/components/input';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
import { Badge } from '$lib/components/badge';
|
import { Badge } from '$lib/components/badge';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -184,10 +184,10 @@
|
|||||||
<div class="user-grid">
|
<div class="user-grid">
|
||||||
{#each friends as friend (friend.id)}
|
{#each friends as friend (friend.id)}
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<Avatar>
|
<ProfilePicture
|
||||||
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
|
username={friend.friendUsername}
|
||||||
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
|
profilePictureUrl={friend.friendProfilePictureUrl}
|
||||||
</Avatar>
|
/>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{friend.friendUsername}</span>
|
<span class="username">{friend.friendUsername}</span>
|
||||||
<Badge variant="secondary">Friend</Badge>
|
<Badge variant="secondary">Friend</Badge>
|
||||||
@@ -218,15 +218,10 @@
|
|||||||
<div class="user-grid">
|
<div class="user-grid">
|
||||||
{#each receivedRequests as request (request.id)}
|
{#each receivedRequests as request (request.id)}
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<Avatar>
|
<ProfilePicture
|
||||||
<AvatarImage
|
username={request.friendUsername}
|
||||||
src={request.friendProfilePictureUrl}
|
profilePictureUrl={request.friendProfilePictureUrl}
|
||||||
alt={request.friendUsername}
|
/>
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
|
||||||
>
|
|
||||||
</Avatar>
|
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{request.friendUsername}</span>
|
<span class="username">{request.friendUsername}</span>
|
||||||
<Badge variant="outline">Pending</Badge>
|
<Badge variant="outline">Pending</Badge>
|
||||||
@@ -264,15 +259,10 @@
|
|||||||
<div class="user-grid">
|
<div class="user-grid">
|
||||||
{#each sentRequests as request (request.id)}
|
{#each sentRequests as request (request.id)}
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<Avatar>
|
<ProfilePicture
|
||||||
<AvatarImage
|
username={request.friendUsername}
|
||||||
src={request.friendProfilePictureUrl}
|
profilePictureUrl={request.friendProfilePictureUrl}
|
||||||
alt={request.friendUsername}
|
/>
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
|
||||||
>
|
|
||||||
</Avatar>
|
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{request.friendUsername}</span>
|
<span class="username">{request.friendUsername}</span>
|
||||||
<Badge variant="secondary">Sent</Badge>
|
<Badge variant="secondary">Sent</Badge>
|
||||||
@@ -305,10 +295,10 @@
|
|||||||
<div class="user-grid">
|
<div class="user-grid">
|
||||||
{#each searchResults as user (user.id)}
|
{#each searchResults as user (user.id)}
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<Avatar>
|
<ProfilePicture
|
||||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
username={user.username}
|
||||||
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
|
profilePictureUrl={user.profilePictureUrl}
|
||||||
</Avatar>
|
/>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{user.username}</span>
|
<span class="username">{user.username}</span>
|
||||||
{#if user.friendshipStatus === 'accepted'}
|
{#if user.friendshipStatus === 'accepted'}
|
||||||
|
|||||||
Reference in New Issue
Block a user