22 Commits

Author SHA1 Message Date
fef7c160e2 feat:use GMaps places api for searching poi's 2025-10-27 14:57:54 +01:00
43afa6dacc update:logboek 2025-10-21 14:49:55 +02:00
aa9ed77499 UI:Refactor create find modal UI and update finds list/header layout
- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
2025-10-21 14:44:25 +02:00
e1c5846fa4 fix:friends filtering 2025-10-21 14:11:28 +02:00
634ce8adf8 fix:logic of friends and users search 2025-10-20 11:35:44 +02:00
f547ee5a84 add:logs 2025-10-16 19:01:43 +02:00
a01d183072 feat:friends 2025-10-16 18:53:21 +02:00
fdbd495bdd add:cache for r2 storage 2025-10-16 18:17:14 +02:00
e54c4fb98e feat:profile pictures 2025-10-16 18:08:03 +02:00
bee03a57ec feat:use dynamic sheet for findpreview 2025-10-16 17:41:00 +02:00
aea324988d fix:likes;UI:find card&list 2025-10-16 17:29:05 +02:00
067e228393 feat:video player, like button, and media fallbacks
Add VideoPlayer and LikeButton components with optimistic UI and /server
endpoints for likes. Update media processor to emit WebP and JPEG
fallbacks, store fallback URLs in the DB (migration + snapshot), add
video placeholder asset, and relax CSP media-src for R2.
2025-10-14 18:31:55 +02:00
b4515d1d6a fix:unsigned urls when storing in db 2025-10-14 16:05:39 +02:00
bf542e6b76 update:logboek 2025-10-14 09:14:43 +02:00
88ed74fde2 fix:remove api logic from page server and move to api 2025-10-13 12:35:44 +02:00
b95c7dad7b feat:finds on homepage 2025-10-13 12:24:20 +02:00
1d858e40e1 fix:use signed R2 URLs for uploaded media
- uploadToR2 now returns storage path instead of a: full URL. - Generate
signed R2 URLs (24h expiration) for images, thumbnails, and videos in
media processor and when loading finds. - Update CSP to allow
*.r2.cloudflarestorage.com for img-src
2025-10-10 13:38:08 +02:00
e0f5595e88 feat:add Finds feature with media upload and R2 support 2025-10-10 13:31:40 +02:00
c454b66b39 add:overscroll behavior and update logboek 2025-10-10 12:16:09 +02:00
407e1d37b5 update:logboek 2025-10-07 14:52:54 +02:00
c8bae0c53c add:lighthouse logs 2025-10-07 14:50:33 +02:00
b2d14574d3 fix:naming 2025-10-07 14:45:03 +02:00
61 changed files with 7612 additions and 39 deletions

View File

@@ -29,3 +29,12 @@ STACK_SECRET_SERVER_KEY=***********************
# Google Oauth for google login # Google Oauth for google login
GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET="" GOOGLE_CLIENT_SECRET=""
# Cloudflare R2 Storage for Finds media
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""
# Google Maps API for Places search
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"

View File

@@ -0,0 +1,45 @@
CREATE TABLE "find" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"description" text,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"location_name" text,
"category" text,
"is_public" integer DEFAULT 1,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "find_like" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "find_media" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"type" text NOT NULL,
"url" text NOT NULL,
"thumbnail_url" text,
"order_index" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "friendship" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"friend_id" text NOT NULL,
"status" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD CONSTRAINT "find_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find_like" ADD CONSTRAINT "find_like_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_like" ADD CONSTRAINT "find_like_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find_media" ADD CONSTRAINT "find_media_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_friend_id_user_id_fk" FOREIGN KEY ("friend_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "find_media" ADD COLUMN "fallback_url" text;--> statement-breakpoint
ALTER TABLE "find_media" ADD COLUMN "fallback_thumbnail_url" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "profile_picture_url" text;

View File

@@ -0,0 +1,425 @@
{
"id": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
"prevId": "277e5a0d-b5f7-40e3-aaf9-c6cd5fe3d0a0",
"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_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
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,437 @@
{
"id": "eaa0fec3-527f-4569-9c01-a4802700b646",
"prevId": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
"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_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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,443 @@
{
"id": "e0be0091-df6b-48be-9d64-8b4108d91651",
"prevId": "eaa0fec3-527f-4569-9c01-a4802700b646",
"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_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -22,6 +22,27 @@
"when": 1759502119139, "when": 1759502119139,
"tag": "0002_robust_firedrake", "tag": "0002_robust_firedrake",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1760092217884,
"tag": "0003_woozy_lily_hollister",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1760456880877,
"tag": "0004_large_doctor_strange",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1760631798851,
"tag": "0005_rapid_warpath",
"breakpoints": true
} }
] ]
} }

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,241 @@
# 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

BIN
logs/20251014/login.pdf Normal file

Binary file not shown.

View File

@@ -2,23 +2,180 @@
## Oktober 2025 ## Oktober 2025
### 7 Oktober 2025 (Maandag) - 4 uren ### 21 Oktober 2025 (Maandag) - 4 uren
**Werk uitgevoerd:**
- **UI Refinement & Bug Fixes**
- Create Find Modal UI refactoring met verbeterde layout
- Finds list en header layout updates voor betere UX
- Friends filtering logica fixes
- Friends en users search functionaliteit verbeteringen
- Modal interface optimalisaties
**Commits:**
- aa9ed77 - UI:Refactor create find modal UI and update finds list/header layout
- e1c5846 - fix:friends filtering
**Details:**
- Verbeterde modal interface met consistente styling
- Fixed filtering logica voor vriendensysteem
- Enhanced search functionaliteit voor gebruikers en vrienden
- UI/UX verbeteringen voor betere gebruikerservaring
---
### 20 Oktober 2025 (Zondag) - 2 uren
**Werk uitgevoerd:**
- **Search Logic Improvements**
- Friends en users search logica geoptimaliseerd
- Filtering verbeteringen voor vriendschapssysteem
- Backend search algoritmes verfijnd
**Commits:**
- 634ce8a - fix:logic of friends and users search
**Details:**
- Verbeterde zoekalgoritmes voor gebruikers
- Geoptimaliseerde filtering voor vriendensysteem
- Backend logica verfijning voor betere performance
---
### 16 Oktober 2025 (Woensdag) - 6 uren
**Werk uitgevoerd:**
- **Phase 2D: Friends & Privacy System - Volledige implementatie**
- Complete vriendschapssysteem geïmplementeerd (verzenden/accepteren/weigeren/verwijderen)
- Friends management pagina ontwikkeld met gebruikerszoekfunctionaliteit
- Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- API endpoints voor vriendschapsbeheer (/api/friends, /api/friends/[friendshipId])
- Gebruikerszoek API met vriendschapsstatus integratie (/api/users)
- FindsFilter component met 4 filteropties (All/Public/Friends/Mine)
- Hoofdpagina uitgebreid met geïntegreerde filteringfunctionaliteit
- ProfilePanel uitgebreid met Friends navigatielink
- Type-safe implementatie met volledige error handling
**Commits:**
- f547ee5 - add:logs
- a01d183 - feat:friends
- fdbd495 - add:cache for r2 storage
- e54c4fb - feat:profile pictures
- bee03a5 - feat:use dynamic sheet for findpreview
- aea3249 - fix:likes;UI:find card&list
**Details:**
- Complete sociale verbindingssysteem voor gebruikers
- Real-time filtering van finds op basis van privacy instellingen
- SHADCN componenten gebruikt voor consistente UI (Cards, Badges, Avatars, Dropdowns)
- Svelte 5 patterns toegepast met $state, $derived, en $props runes
- Bestaande friendship table schema optimaal benut zonder wijzigingen
- Comprehensive authentication en authorization op alle endpoints
- Mobile-responsive design met aangepaste styling voor kleinere schermen
---
### 14 Oktober 2025 (Maandag) - 8 uren
**Werk uitgevoerd:**
- **Phase 2A & 2C: Modern Media + Social Features**
- Video support met custom VideoPlayer component geïmplementeerd
- WebP image processing met JPEG fallbacks toegevoegd
- Like/unlike systeem met optimistic UI updates
- Database schema uitgebreid met fallback media URLs
- LikeButton component met animaties ontwikkeld
- API endpoints voor like functionality (/api/finds/[findId]/like)
- Media processor uitgebreid voor moderne formaten
- CSP headers bijgewerkt voor video support
- Volledige UI integratie in FindCard en FindPreview componenten
**Commits:**
- 067e228 - feat:video player, like button, and media fallbacks
**Details:**
- Complete video playback systeem met custom controls
- Modern WebP/JPEG image processing pipeline
- Social interaction systeem met real-time like counts
- Enhanced media carousel met video support
- Type-safe interfaces voor alle nieuwe functionaliteit
- Backward compatibility behouden voor bestaande media
---
### 13 Oktober 2025 (Zondag) - 4 uren
**Werk uitgevoerd:**
- **API architectuur verbetering**
- Finds weergave op homepage geïmplementeerd
- API logic verplaatst van page servers naar dedicated API routes
- Code organisatie en separation of concerns verbeterd
- Homepage uitgebreid met Finds functionaliteit
**Commits:** 2 commits (b95c7da, 88ed74f)
**Details:**
- Betere API structuur volgens SvelteKit best practices
- Finds integratie op hoofdpagina
- Code cleanup en organisatie
- Improved separation tussen frontend en API logic
---
### 10 Oktober 2025 (Donderdag) - 6 uren
**Werk uitgevoerd:**
- **Finds feature implementatie**
- Media upload functionaliteit met Cloudflare R2 storage
- Signed URLs voor veilige media toegang
- R2 bucket configuratie en integratie
- Overscroll behavior verbeteringen
- Code refactoring en cleanup
**Commits:** 3 commits (c454b66, e0f5595, 1d858e4)
**Details:**
- Complete media upload systeem met R2
- Veilige URL signing voor uploaded bestanden
- Finds feature als kernfunctionaliteit
- Storage optimalisaties
---
### 7 Oktober 2025 (Maandag) - 5 uren
**Werk uitgevoerd:** **Werk uitgevoerd:**
- **Grote SEO, PWA en performance optimalisaties** - **Grote SEO, PWA en performance optimalisaties**
- Logo padding gefixed - Logo padding gefixed
- Favicon bestanden opgeruimd (verwijderd oude favicon bestanden) - Favicon bestanden opgeruimd (verwijderd oude favicon bestanden)
- Manifest.json geoptimaliseerd - Manifest.json geoptimaliseerd en naming fixes
- Sitemap.xml automatisch gegenereerd - Sitemap.xml automatisch gegenereerd
- Service worker uitgebreid voor caching - Service worker uitgebreid voor caching
- Meta tags en Open Graph voor SEO - Meta tags en Open Graph voor SEO
- Background afbeelding gecomprimeerd (50% kleiner) - Background afbeelding gecomprimeerd (50% kleiner)
- Performance logs/PDFs opgeslagen voor vergelijking - Performance logs/PDFs opgeslagen (voor én na optimalisaties)
- Vite config optimalisaties - Vite config optimalisaties
- Build issues opgelost - Build issues opgelost
- CSP (Content Security Policy) issues gefixed
- Lighthouse performance logs toegevoegd
**Commits:** 3 commits (8d3922e, 716c05c, 5f0cae6) **Commits:** 6 commits (c8bae0c, b2d1457, 63f7e0c, a806664, 8d3922e, 716c05c, 5f0cae6)
**Details:** **Details:**
@@ -27,6 +184,8 @@
- Performance optimalisaties door image compression - Performance optimalisaties door image compression
- Automatische sitemap generatie - Automatische sitemap generatie
- Service worker caching voor offline functionaliteit - Service worker caching voor offline functionaliteit
- CSP security verbeteringen
- Performance monitoring met Lighthouse logs (voor/na vergelijking)
--- ---
@@ -163,9 +322,9 @@
## Totaal Overzicht ## Totaal Overzicht
**Totale geschatte uren:** 36 uren **Totale geschatte uren:** 67 uren
**Werkdagen:** 6 dagen **Werkdagen:** 12 dagen
**Gemiddelde uren per dag:** 6 uur **Gemiddelde uren per dag:** 5.6 uur
### Project Milestones: ### Project Milestones:
@@ -175,17 +334,35 @@
4. **29 Sept**: Component architectuur verbetering 4. **29 Sept**: Component architectuur verbetering
5. **2-3 Okt**: Maps en location features 5. **2-3 Okt**: Maps en location features
6. **7 Okt**: SEO, PWA en performance optimalisaties 6. **7 Okt**: SEO, PWA en performance optimalisaties
7. **10 Okt**: Finds feature en media upload systeem
8. **13 Okt**: API architectuur verbetering
9. **14 Okt**: Modern media support en social interactions
10. **16 Okt**: Friends & Privacy System implementatie
11. **20 Okt**: Search logic improvements
12. **21 Okt**: UI refinement en bug fixes
### Hoofdfunctionaliteiten geïmplementeerd: ### Hoofdfunctionaliteiten geïmplementeerd:
- Gebruikersauthenticatie (Lucia + Google OAuth) - [x] Gebruikersauthenticatie (Lucia + Google OAuth)
- Responsive UI met custom componenten - [x] Responsive UI met custom componenten
- Real-time locatie tracking - [x] Real-time locatie tracking
- Interactive maps (MapLibre GL JS) - [x] Interactive maps (MapLibre GL JS)
- PWA functionaliteit - [x] PWA functionaliteit
- Docker deployment - [x] Docker deployment
- Database (PostgreSQL + Drizzle ORM) - [x] Database (PostgreSQL + Drizzle ORM)
- Toast notifications - [x] Toast notifications
- Loading states en error handling - [x] Loading states en error handling
- SEO optimalisatie (meta tags, Open Graph, sitemap) - [x] SEO optimalisatie (meta tags, Open Graph, sitemap)
- Performance optimalisaties (image compression, caching) - [x] Performance optimalisaties (image compression, caching)
- [x] Finds feature met media upload
- [x] Cloudflare R2 storage integratie
- [x] Signed URLs voor veilige media toegang
- [x] API architectuur verbetering
- [x] Video support met custom VideoPlayer component
- [x] WebP image processing met JPEG fallbacks
- [x] Like/unlike systeem met real-time updates
- [x] Social interactions en animated UI components
- [x] Friends & Privacy System met vriendschapsverzoeken
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
- [x] Real-time find filtering op basis van privacy instellingen

View File

@@ -54,10 +54,13 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.907.0",
"@aws-sdk/s3-request-presigner": "^3.907.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@sveltejs/adapter-vercel": "^5.10.2", "@sveltejs/adapter-vercel": "^5.10.2",
"arctic": "^3.7.0", "arctic": "^3.7.0",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"sharp": "^0.34.4",
"svelte-maplibre": "^1.2.1" "svelte-maplibre": "^1.2.1"
}, },
"pnpm": { "pnpm": {

View File

@@ -104,6 +104,7 @@
background-color: #f8f8f8; background-color: #f8f8f8;
color: #333; color: #333;
margin: 0 auto; margin: 0 auto;
overscroll-behavior: contain;
} }
h1 { h1 {

View File

@@ -50,7 +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; " + "img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
"media-src 'self' *.r2.cloudflarestorage.com; " +
"connect-src 'self' *.openstreetmap.org; " + "connect-src 'self' *.openstreetmap.org; " +
"frame-ancestors 'none'; " + "frame-ancestors 'none'; " +
"base-uri 'self'; " + "base-uri 'self'; " +

View File

@@ -0,0 +1,615 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
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 POISearch from './POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
onClose: () => void;
onFindCreated: (event: CustomEvent) => void;
}
let { isOpen, onClose, onFindCreated }: Props = $props();
let title = $state('');
let description = $state('');
let latitude = $state('');
let longitude = $state('');
let locationName = $state('');
let category = $state('cafe');
let isPublic = $state(true);
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
const categories = [
{ value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' },
{ value: 'park', label: 'Park' },
{ value: 'landmark', label: 'Landmark' },
{ value: 'shop', label: 'Shop' },
{ value: 'museum', label: 'Museum' },
{ value: 'other', label: 'Other' }
];
let showModal = $state(true);
let isMobile = $state(false);
$effect(() => {
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();
longitude = $coordinates.longitude.toString();
}
});
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return;
const formData = new FormData();
Array.from(selectedFiles).forEach((file) => {
formData.append('files', file);
});
const response = await fetch('/api/finds/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload media');
}
const result = await response.json();
uploadedMedia = result.media;
}
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
const response = await fetch('/api/finds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title.trim(),
description: description.trim() || null,
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null,
category,
isPublic,
media: uploadedMedia
})
});
if (!response.ok) {
throw new Error('Failed to create find');
}
resetForm();
showModal = false;
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
} catch (error) {
console.error('Error creating find:', error);
alert('Failed to create find. Please try again.');
} finally {
isSubmitting = false;
}
}
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() {
title = '';
description = '';
locationName = '';
latitude = '';
longitude = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
useManualLocation = false;
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
function closeModal() {
resetForm();
showModal = false;
}
</script>
{#if isOpen}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
<SheetHeader>
<SheetTitle>Create Find</SheetTitle>
</SheetHeader>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="form-content">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="field">
<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 class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if useManualLocation}
<div class="field">
<Label for="location-name">Location name</Label>
<Input
name="location-name"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{:else}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for cafés, restaurants, landmarks..."
label=""
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>
{/if}
</div>
<div class="actions">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
{/if}
<style>
:global(.create-find-sheet) {
padding: 0 !important;
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;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.field-group {
grid-template-columns: 1fr;
}
}
textarea {
min-height: 80px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s ease;
}
textarea:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.select {
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
}
.select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
.privacy-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
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'] {
width: 16px;
height: 16px;
accent-color: hsl(var(--primary));
}
.file-upload {
position: relative;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.3);
transition: all 0.2s ease;
cursor: pointer;
}
.file-upload:hover {
border-color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
}
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
}
.file-content span {
font-size: 0.875rem;
}
.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) {
.form-content {
padding: 1rem;
gap: 1rem;
}
.actions {
padding: 1rem;
flex-direction: column;
}
.actions :global(button) {
flex: none;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Badge } from '$lib/components/badge';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
interface FindCardProps {
id: string;
title: string;
description?: string;
category?: string;
locationName?: string;
user: {
username: string;
profilePictureUrl?: string | null;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
likeCount?: number;
isLiked?: boolean;
onExplore?: (id: string) => void;
}
let {
id,
title,
description,
category,
locationName,
user,
media,
likeCount = 0,
isLiked = false,
onExplore
}: FindCardProps = $props();
function handleExplore() {
onExplore?.(id);
}
function getUserInitials(username: string): string {
return username.slice(0, 2).toUpperCase();
}
</script>
<div class="find-card">
<!-- Post Header -->
<div class="post-header">
<div class="user-info">
<Avatar class="avatar">
{#if user.profilePictureUrl}
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
{/if}
<AvatarFallback class="avatar-fallback">
{getUserInitials(user.username)}
</AvatarFallback>
</Avatar>
<div class="user-details">
<div class="username">@{user.username}</div>
{#if locationName}
<div class="location">
<svg width="12" height="12" 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>
<span>{locationName}</span>
</div>
{/if}
</div>
</div>
<Button variant="ghost" size="sm" class="more-button">
<MoreHorizontal size={16} />
</Button>
</div>
<!-- Post Content -->
<div class="post-content">
<div class="content-header">
<h3 class="post-title">{title}</h3>
{#if category}
<Badge variant="secondary" class="category-badge">
{category}
</Badge>
{/if}
</div>
{#if description}
<p class="post-description">{description}</p>
{/if}
</div>
<!-- Media -->
<div class="post-media">
{#if media && media.length > 0}
{#if media[0].type === 'photo'}
<img
src={media[0].thumbnailUrl || media[0].url}
alt={title}
loading="lazy"
class="media-image"
/>
{:else}
<VideoPlayer
src={media[0].url}
poster={media[0].thumbnailUrl}
muted={true}
autoplay={false}
controls={true}
class="media-video"
/>
{/if}
{:else}
<div class="no-media">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</div>
{/if}
</div>
<!-- Post Actions -->
<div class="post-actions">
<div class="action-buttons">
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
<Button variant="ghost" size="sm" class="action-button">
<MessageCircle size={16} />
<span>comment</span>
</Button>
<Button variant="ghost" size="sm" class="action-button">
<Share size={16} />
<span>share</span>
</Button>
</div>
<Button variant="outline" size="sm" onclick={handleExplore} class="explore-button">
explore
</Button>
</div>
</div>
<style>
.find-card {
background: white;
border: 1px solid hsl(var(--border));
border-radius: 12px;
overflow: hidden;
margin-bottom: 1rem;
transition: box-shadow 0.2s ease;
}
.find-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Post Header */
.post-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.75rem 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
:global(.avatar) {
width: 40px;
height: 40px;
}
:global(.avatar-fallback) {
background: hsl(var(--primary));
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.username {
font-weight: 600;
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.location {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
:global(.more-button) {
color: hsl(var(--muted-foreground));
}
/* Post Content */
.post-content {
padding: 0 1rem 0.75rem 1rem;
}
.content-header {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.post-title {
font-family: 'Washington', serif;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
color: hsl(var(--foreground));
margin: 0;
flex: 1;
}
:global(.category-badge) {
font-size: 0.75rem;
height: 1.5rem;
flex-shrink: 0;
}
.post-description {
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--muted-foreground));
margin: 0;
}
/* Media */
.post-media {
width: 100%;
aspect-ratio: 16/10;
overflow: hidden;
background: hsl(var(--muted));
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.no-media {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0.5;
}
/* Post Actions */
.post-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem 1rem 1rem;
border-top: 1px solid hsl(var(--border));
}
.action-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(.action-button) {
gap: 0.375rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
:global(.action-button:hover) {
color: hsl(var(--foreground));
}
:global(.explore-button) {
font-weight: 500;
font-size: 0.875rem;
}
/* Mobile responsive */
@media (max-width: 768px) {
.post-actions {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.action-buttons {
justify-content: space-around;
}
:global(.explore-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,580 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
interface Find {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
likeCount?: number;
isLiked?: boolean;
}
interface Props {
find: Find | null;
onClose: () => void;
}
let { find, onClose }: Props = $props();
let showModal = $state(true);
let currentMediaIndex = $state(0);
let isMobile = $state(false);
// Detect screen size
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
// Close modal when showModal changes to false
$effect(() => {
if (!showModal) {
onClose();
}
});
function nextMedia() {
if (!find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
}
function prevMedia() {
if (!find?.media) return;
currentMediaIndex = currentMediaIndex === 0 ? find.media.length - 1 : currentMediaIndex - 1;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getDirections() {
if (!find) return;
const lat = parseFloat(find.latitude);
const lng = parseFloat(find.longitude);
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
}
function shareFindUrl() {
if (!find) return;
const url = `${window.location.origin}/finds/${find.id}`;
if (navigator.share) {
navigator.share({
title: find.title,
text: find.description || `Check out this find: ${find.title}`,
url: url
});
} else {
navigator.clipboard.writeText(url);
alert('Find URL copied to clipboard!');
}
}
</script>
{#if find}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
<SheetHeader class="sheet-header">
<div class="user-section">
<Avatar class="user-avatar">
{#if find.user.profilePictureUrl}
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
{/if}
<AvatarFallback class="avatar-fallback">
{find.user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div class="user-info">
<SheetTitle class="find-title">{find.title}</SheetTitle>
<div class="find-meta">
<span class="username">@{find.user.username}</span>
<span class="separator"></span>
<span class="date">{formatDate(find.createdAt)}</span>
{#if find.category}
<span class="separator"></span>
<span class="category">{find.category}</span>
{/if}
</div>
</div>
</div>
</SheetHeader>
<div class="sheet-body">
{#if find.media && find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
{#if find.media[currentMediaIndex].type === 'photo'}
<img src={find.media[currentMediaIndex].url} alt={find.title} class="media-image" />
{:else}
<VideoPlayer
src={find.media[currentMediaIndex].url}
poster={find.media[currentMediaIndex].thumbnailUrl}
class="media-video"
/>
{/if}
{#if find.media.length > 1}
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M15 18L9 12L15 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
{#if find.media.length > 1}
<div class="media-indicators">
{#each find.media, index (index)}
<button
class="indicator"
class:active={index === currentMediaIndex}
onclick={() => (currentMediaIndex = index)}
aria-label={`View media ${index + 1}`}
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content-section">
{#if find.description}
<p class="description">{find.description}</p>
{/if}
{#if find.locationName}
<div class="location-info">
<svg width="16" height="16" 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>
<span>{find.locationName}</span>
</div>
{/if}
<div class="actions">
<LikeButton
findId={find.id}
isLiked={find.isLiked || false}
likeCount={find.likeCount || 0}
size="default"
class="like-action"
/>
<button class="action-button primary" onclick={getDirections}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M3 11L22 2L13 21L11 13L3 11Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Directions
</button>
<button class="action-button secondary" onclick={shareFindUrl}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="16,6 12,2 8,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Share
</button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
{/if}
<style>
/* Base styles for sheet content */
:global(.sheet-content) {
padding: 0 !important;
}
/* Desktop styles (side sheet) */
@media (min-width: 768px) {
:global(.sheet-content) {
width: 80vw !important;
max-width: 600px !important;
height: 100vh !important;
border-radius: 0 !important;
}
}
/* Mobile styles (bottom sheet) */
@media (max-width: 767px) {
:global(.sheet-content) {
height: 80vh !important;
border-radius: 16px 16px 0 0 !important;
}
}
:global(.sheet-header) {
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.user-section {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
}
:global(.user-avatar) {
width: 48px;
height: 48px;
flex-shrink: 0;
}
:global(.avatar-fallback) {
background: hsl(var(--primary));
color: white;
font-size: 1rem;
font-weight: 600;
}
.user-info {
flex: 1;
min-width: 0;
}
:global(.find-title) {
font-family: 'Washington', serif !important;
font-size: 1.25rem !important;
font-weight: 600 !important;
margin: 0 !important;
color: hsl(var(--foreground)) !important;
line-height: 1.3 !important;
}
.find-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
flex-wrap: wrap;
}
.username {
font-weight: 500;
color: hsl(var(--primary));
}
.separator {
color: hsl(var(--muted-foreground));
opacity: 0.5;
}
.category {
background: hsl(var(--muted));
padding: 0.125rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
text-transform: capitalize;
}
.sheet-body {
flex: 1;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.media-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.media-viewer {
position: relative;
width: 100%;
background: hsl(var(--muted));
overflow: hidden;
flex: 1;
min-height: 0;
}
/* Desktop media viewer - maximize available space */
@media (min-width: 768px) {
.sheet-body {
height: calc(100vh - 140px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 160px;
max-height: 300px;
}
}
/* Mobile media viewer - maximize available space */
@media (max-width: 767px) {
.sheet-body {
height: calc(80vh - 140px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 140px;
max-height: 250px;
}
}
.media-image {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.media-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
z-index: 10;
}
.media-nav:hover {
background: rgba(0, 0, 0, 0.8);
}
.media-nav.prev {
left: 1rem;
}
.media-nav.next {
right: 1rem;
}
.media-indicators {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem 0;
flex-shrink: 0;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: hsl(var(--muted-foreground));
opacity: 0.3;
cursor: pointer;
transition: all 0.2s ease;
}
.indicator.active {
background: hsl(var(--primary));
opacity: 1;
transform: scale(1.2);
}
.content-section {
padding: 1rem 1.5rem 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.description {
font-size: 1rem;
line-height: 1.6;
color: hsl(var(--foreground));
margin: 0 0 1.25rem 0;
}
.location-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: auto;
padding-top: 1rem;
}
:global(.like-action) {
flex-shrink: 0;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.action-button.primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.action-button.primary:hover {
background: hsl(var(--primary) / 0.9);
}
.action-button.secondary {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.action-button.secondary:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 640px) {
:global(.sheet-header) {
padding: 1rem;
}
.user-section {
gap: 0.5rem;
}
:global(.user-avatar) {
width: 40px;
height: 40px;
}
:global(.find-title) {
font-size: 1.125rem !important;
}
.content-section {
padding: 1rem;
}
.actions {
flex-direction: column;
gap: 0.5rem;
}
.action-button {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '$lib/components/dropdown-menu';
import { Badge } from '$lib/components/badge';
interface Props {
currentFilter: string;
onFilterChange: (filter: string) => void;
}
let { currentFilter, onFilterChange }: Props = $props();
const filterOptions = [
{ value: 'all', label: 'All Finds', description: 'Public, friends, and your finds' },
{ value: 'public', label: 'Public Only', description: 'Publicly visible finds' },
{ value: 'friends', label: 'Friends Only', description: 'Finds from your friends' },
{ value: 'mine', label: 'My Finds', description: 'Only your finds' }
];
const currentOption = $derived(
filterOptions.find((option) => option.value === currentFilter) || filterOptions[0]
);
</script>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" class="filter-trigger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<path
d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v2.586a1 1 0 0 1-.293.707l-6.414 6.414a1 1 0 0 0-.293.707V17l-4 4v-6.586a1 1 0 0 0-.293-.707L3.293 7.293A1 1 0 0 1 3 6.586V4z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{currentOption.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="filter-dropdown">
{#each filterOptions as option (option.value)}
<DropdownMenuItem
class="filter-option"
onclick={() => onFilterChange(option.value)}
data-selected={currentFilter === option.value}
>
<div class="option-content">
<div class="option-header">
<span class="option-label">{option.label}</span>
{#if currentFilter === option.value}
<Badge variant="default" class="selected-badge">Selected</Badge>
{/if}
</div>
<span class="option-description">{option.description}</span>
</div>
</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu>
<style>
:global(.filter-trigger) {
gap: 8px;
min-width: 120px;
justify-content: flex-start;
}
:global(.filter-dropdown) {
min-width: 250px;
padding: 4px;
}
:global(.filter-option) {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 2px;
}
:global(.filter-option:hover) {
background: #f5f5f5;
}
:global(.filter-option[data-selected='true']) {
background: #f0f9ff;
border: 1px solid #e0f2fe;
}
.option-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.option-label {
font-weight: 500;
color: #1a1a1a;
font-size: 14px;
}
.option-description {
font-size: 12px;
color: #6b7280;
line-height: 1.4;
}
:global(.selected-badge) {
font-size: 10px;
padding: 2px 6px;
height: auto;
}
</style>

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import FindCard from './FindCard.svelte';
interface Find {
id: string;
title: string;
description?: string;
category?: string;
locationName?: string;
user: {
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
interface FindsListProps {
finds: Find[];
onFindExplore?: (id: string) => void;
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
finds,
onFindExplore,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display',
hideTitle = false
}: FindsListProps = $props();
function handleFindExplore(id: string) {
onFindExplore?.(id);
}
</script>
<section class="finds-feed">
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if finds.length > 0}
<div class="feed-container">
{#each finds as find (find.id)}
<FindCard
id={find.id}
title={find.title}
description={find.description}
category={find.category}
locationName={find.locationName}
user={find.user}
media={find.media}
likeCount={find.likeCount}
isLiked={find.isLiked}
onExplore={handleFindExplore}
/>
{/each}
</div>
{:else if showEmpty}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No finds discovered yet</h3>
<p class="empty-message">{emptyMessage}</p>
<div class="empty-action">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Start exploring to discover finds</span>
</div>
</div>
{/if}
</section>
<style>
.finds-feed {
width: 100%;
padding: 0 24px 24px 24px;
}
.feed-header {
margin-bottom: 1.5rem;
padding: 0 0.5rem;
}
.feed-title {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
color: hsl(var(--foreground));
}
.feed-container {
display: flex;
flex-direction: column;
gap: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: hsl(var(--card));
border-radius: 12px;
border: 1px solid hsl(var(--border));
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0 0 1.5rem 0;
line-height: 1.5;
}
.empty-action {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--primary));
font-size: 0.875rem;
font-weight: 500;
}
/* Mobile responsive */
@media (max-width: 768px) {
.feed-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0;
}
.feed-title {
font-size: 1.5rem;
}
.empty-state {
padding: 3rem 1.5rem;
}
}
/* Smooth scrolling for feed */
.feed-container {
scroll-behavior: smooth;
}
/* Add subtle animation for new posts */
:global(.find-card) {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -4,6 +4,7 @@
type User = { type User = {
id: string; id: string;
username: string; username: string;
profilePictureUrl?: string | null;
}; };
let { user }: { user: User } = $props(); let { user }: { user: User } = $props();
@@ -11,9 +12,13 @@
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<h1 class="app-title">Serengo</h1> <h1 class="app-title"><a href="/">Serengo</a></h1>
<div class="profile-container"> <div class="profile-container">
<ProfilePanel username={user.username} id={user.id} /> <ProfilePanel
username={user.username}
id={user.id}
profilePictureUrl={user.profilePictureUrl}
/>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Heart } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
interface Props {
findId: string;
isLiked?: boolean;
likeCount?: number;
size?: 'sm' | 'default' | 'lg';
class?: string;
}
let {
findId,
isLiked = false,
likeCount = 0,
size = 'default',
class: className = ''
}: Props = $props();
// Track the source of truth - server state
let serverIsLiked = $state(isLiked);
let serverLikeCount = $state(likeCount);
// Track optimistic state during loading
let isLoading = $state(false);
let optimisticIsLiked = $state(isLiked);
let optimisticLikeCount = $state(likeCount);
// Derived state for display
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
async function toggleLike() {
if (isLoading) return;
// Set optimistic state
optimisticIsLiked = !serverIsLiked;
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
isLoading = true;
try {
const method = optimisticIsLiked ? 'POST' : 'DELETE';
const response = await fetch(`/api/finds/${findId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update server state with response
serverIsLiked = result.isLiked;
serverLikeCount = result.likeCount;
} catch (error: unknown) {
console.error('Error updating like:', error);
toast.error('Failed to update like. Please try again.');
} finally {
isLoading = false;
}
}
// Update server state when props change (from parent component)
$effect(() => {
serverIsLiked = isLiked;
serverLikeCount = likeCount;
});
</script>
<Button
variant="ghost"
{size}
class="group gap-1.5 {className}"
onclick={toggleLike}
disabled={isLoading}
>
<Heart
class="h-4 w-4 transition-all duration-200 {displayIsLiked
? 'scale-110 fill-red-500 text-red-500'
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading
? 'animate-pulse'
: ''}"
/>
{#if displayLikeCount > 0}
<span
class="text-sm font-medium transition-colors {displayIsLiked
? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}"
>
{displayLikeCount}
</span>
{/if}
</Button>

View File

@@ -11,6 +11,28 @@
import LocationButton from './LocationButton.svelte'; import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton'; import { Skeleton } from '$lib/components/skeleton';
interface Find {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
interface Props { interface Props {
style?: StyleSpecification; style?: StyleSpecification;
center?: [number, number]; center?: [number, number];
@@ -18,6 +40,8 @@
class?: string; class?: string;
showLocationButton?: boolean; showLocationButton?: boolean;
autoCenter?: boolean; autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
} }
let { let {
@@ -43,7 +67,9 @@
zoom, zoom,
class: className = '', class: className = '',
showLocationButton = true, showLocationButton = true,
autoCenter = true autoCenter = true,
finds = [],
onFindClick
}: Props = $props(); }: Props = $props();
let mapLoaded = $state(false); let mapLoaded = $state(false);
@@ -121,6 +147,33 @@
</div> </div>
</Marker> </Marker>
{/if} {/if}
{#each finds as find (find.id)}
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="find-marker"
role="button"
tabindex="0"
onclick={() => onFindClick?.(find)}
title={find.title}
>
<div class="find-marker-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
fill="currentColor"
/>
</svg>
</div>
{#if find.media && find.media.length > 0}
<div class="find-marker-preview">
<img src={find.media[0].thumbnailUrl} alt={find.title} />
</div>
{/if}
</div>
</Marker>
{/each}
</MapLibre> </MapLibre>
{#if showLocationButton} {#if showLocationButton}
@@ -221,6 +274,55 @@
} }
} }
/* Find marker styles */
:global(.find-marker) {
width: 40px;
height: 40px;
cursor: pointer;
position: relative;
transform: translate(-50%, -50%);
transition: all 0.2s ease;
}
:global(.find-marker:hover) {
transform: translate(-50%, -50%) scale(1.1);
z-index: 100;
}
:global(.find-marker-icon) {
width: 32px;
height: 32px;
background: #ff6b35;
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 2;
}
:global(.find-marker-preview) {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 3;
}
:global(.find-marker-preview img) {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { .map-container {
height: 300px; height: 300px;

View File

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

View File

@@ -7,19 +7,31 @@
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from './dropdown-menu'; } from './dropdown-menu';
import { Avatar, AvatarFallback } from './avatar'; import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
interface Props { interface Props {
username: string; username: string;
id: string; id: string;
profilePictureUrl?: string | null;
loading?: boolean; loading?: boolean;
} }
let { username, id, loading = false }: Props = $props(); let { username, id, profilePictureUrl, loading = false }: Props = $props();
let showProfilePictureSheet = $state(false);
// Get the first letter of username for avatar // Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase(); const initial = username.charAt(0).toUpperCase();
function openProfilePictureSheet() {
showProfilePictureSheet = true;
}
function closeProfilePictureSheet() {
showProfilePictureSheet = false;
}
</script> </script>
<DropdownMenu> <DropdownMenu>
@@ -28,6 +40,9 @@
<Skeleton class="h-10 w-10 rounded-full" /> <Skeleton class="h-10 w-10 rounded-full" />
{:else} {:else}
<Avatar class="profile-avatar"> <Avatar class="profile-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="profile-avatar-fallback"> <AvatarFallback class="profile-avatar-fallback">
{initial} {initial}
</AvatarFallback> </AvatarFallback>
@@ -65,6 +80,16 @@
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem class="profile-picture-item" onclick={openProfilePictureSheet}>
Profile Picture
</DropdownMenuItem>
<DropdownMenuItem class="friends-item">
<a href="/friends" class="friends-link">Friends</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="user-info-item"> <div class="user-info-item">
<span class="info-label">Username</span> <span class="info-label">Username</span>
<span class="info-value">{username}</span> <span class="info-value">{username}</span>
@@ -86,6 +111,15 @@
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{#if showProfilePictureSheet}
<ProfilePictureSheet
userId={id}
{username}
{profilePictureUrl}
onClose={closeProfilePictureSheet}
/>
{/if}
<style> <style>
:global(.profile-trigger) { :global(.profile-trigger) {
background: none; background: none;
@@ -156,6 +190,35 @@
word-break: break-all; word-break: break-all;
} }
:global(.profile-picture-item) {
cursor: pointer;
font-weight: 500;
color: #333;
}
:global(.profile-picture-item:hover) {
background: #f5f5f5;
}
:global(.friends-item) {
cursor: pointer;
font-weight: 500;
color: #333;
padding: 0;
}
:global(.friends-item:hover) {
background: #f5f5f5;
}
.friends-link {
display: block;
width: 100%;
padding: 6px 8px;
text-decoration: none;
color: inherit;
}
:global(.logout-item) { :global(.logout-item) {
padding: 0; padding: 0;
margin: 4px; margin: 4px;

View File

@@ -0,0 +1,273 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Button } from './button';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
interface Props {
userId: string;
username: string;
profilePictureUrl?: string | null;
onClose?: () => void;
}
let { userId, username, profilePictureUrl, onClose }: Props = $props();
let fileInput: HTMLInputElement;
let selectedFile = $state<File | null>(null);
let isUploading = $state(false);
let isDeleting = $state(false);
let showModal = $state(true);
const initial = username.charAt(0).toUpperCase();
// Close modal when showModal changes to false
$effect(() => {
if (!showModal && onClose) {
onClose();
}
});
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
return;
}
selectedFile = file;
}
}
async function handleUpload() {
if (!selectedFile) return;
isUploading = true;
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('userId', userId);
const response = await fetch('/api/profile-picture/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
selectedFile = null;
if (fileInput) fileInput.value = '';
await invalidateAll();
showModal = false;
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload profile picture');
} finally {
isUploading = false;
}
}
async function handleDelete() {
if (!profilePictureUrl) return;
isDeleting = true;
try {
const response = await fetch('/api/profile-picture/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId })
});
if (!response.ok) {
throw new Error('Delete failed');
}
await invalidateAll();
showModal = false;
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete profile picture');
} finally {
isDeleting = false;
}
}
</script>
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side="bottom" class="profile-picture-sheet">
<SheetHeader>
<SheetTitle>Profile Picture</SheetTitle>
<SheetDescription>Upload, edit, or delete your profile picture</SheetDescription>
</SheetHeader>
<div class="profile-picture-content">
<div class="current-avatar">
<Avatar class="large-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="large-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
</div>
<div class="upload-section">
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileSelect}
class="file-input"
/>
{#if selectedFile}
<div class="selected-file">
<p class="file-name">{selectedFile.name}</p>
<p class="file-size">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
{/if}
<div class="action-buttons">
{#if selectedFile}
<Button onclick={handleUpload} disabled={isUploading} class="upload-button">
{isUploading ? 'Uploading...' : 'Upload Picture'}
</Button>
{/if}
{#if profilePictureUrl}
<Button
variant="destructive"
onclick={handleDelete}
disabled={isDeleting}
class="delete-button"
>
{isDeleting ? 'Deleting...' : 'Delete Picture'}
</Button>
{/if}
</div>
</div>
</div>
</SheetContent>
</Sheet>
<style>
:global(.profile-picture-sheet) {
max-height: 80vh;
}
.profile-picture-content {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px 0;
}
.current-avatar {
display: flex;
justify-content: center;
align-items: center;
}
:global(.large-avatar) {
width: 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 {
display: flex;
flex-direction: column;
gap: 16px;
}
.file-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: white;
}
.selected-file {
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.file-name {
font-weight: 500;
margin: 0 0 4px 0;
word-break: break-word;
}
.file-size {
font-size: 12px;
color: #666;
margin: 0;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
:global(.upload-button) {
background: black;
color: white;
padding: 12px 24px;
font-weight: 500;
}
:global(.upload-button:hover) {
background: #333;
}
:global(.delete-button) {
padding: 12px 24px;
font-weight: 500;
}
@media (min-width: 640px) {
:global(.profile-picture-sheet) {
max-width: 500px;
margin: 0 auto;
}
.action-buttons {
flex-direction: row;
justify-content: center;
}
:global(.upload-button),
:global(.delete-button) {
min-width: 140px;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Play, Pause, Volume2, VolumeX, Maximize } from '@lucide/svelte';
interface Props {
src: string;
poster?: string;
class?: string;
autoplay?: boolean;
muted?: boolean;
controls?: boolean;
}
let {
src,
poster,
class: className = '',
autoplay = false,
muted = false,
controls = true
}: Props = $props();
let videoElement: HTMLVideoElement;
let isPlaying = $state(false);
let isMuted = $state(muted);
let currentTime = $state(0);
let duration = $state(0);
let isLoading = $state(true);
let showControls = $state(true);
let controlsTimeout: ReturnType<typeof setTimeout>;
function togglePlayPause() {
if (isPlaying) {
videoElement.pause();
} else {
videoElement.play();
}
}
function toggleMute() {
videoElement.muted = !videoElement.muted;
isMuted = videoElement.muted;
}
function handleTimeUpdate() {
currentTime = videoElement.currentTime;
}
function handleLoadedMetadata() {
duration = videoElement.duration;
isLoading = false;
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function handleSeek(event: Event) {
const target = event.target as HTMLInputElement;
const time = (parseFloat(target.value) / 100) * duration;
videoElement.currentTime = time;
}
function handleMouseMove() {
if (!controls) return;
showControls = true;
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
function toggleFullscreen() {
if (videoElement.requestFullscreen) {
videoElement.requestFullscreen();
}
}
function formatTime(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<div
class="group relative overflow-hidden rounded-lg bg-black {className}"
role="application"
onmousemove={handleMouseMove}
onmouseleave={() => {
if (isPlaying && controls) showControls = false;
}}
>
<video
bind:this={videoElement}
class="h-full w-full object-cover"
{src}
{poster}
{autoplay}
muted={isMuted}
preload="metadata"
onplay={handlePlay}
onpause={handlePause}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
onclick={togglePlayPause}
>
<track kind="captions" />
</video>
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</div>
{/if}
{#if controls && showControls}
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
<!-- Center play/pause button -->
<div class="absolute inset-0 flex items-center justify-center">
<Button
variant="ghost"
size="lg"
class="h-16 w-16 rounded-full bg-black/30 text-white hover:bg-black/50"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-8 w-8" />
{:else}
<Play class="ml-1 h-8 w-8" />
{/if}
</Button>
</div>
<!-- Bottom controls -->
<div class="absolute right-0 bottom-0 left-0 p-4">
<!-- Progress bar -->
<div class="mb-2">
<input
type="range"
min="0"
max="100"
value={duration > 0 ? (currentTime / duration) * 100 : 0}
oninput={handleSeek}
class="slider h-1 w-full cursor-pointer appearance-none rounded-lg bg-white/30"
/>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between text-white">
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={togglePlayPause}
>
{#if isPlaying}
<Pause class="h-4 w-4" />
{:else}
<Play class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleMute}
>
{#if isMuted}
<VolumeX class="h-4 w-4" />
{:else}
<Volume2 class="h-4 w-4" />
{/if}
</Button>
<span class="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<Button
variant="ghost"
size="sm"
class="text-white hover:bg-white/20"
onclick={toggleFullscreen}
>
<Maximize class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/if}
</div>
<style>
.slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
.slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
interface Props {
variant?: BadgeVariant;
class?: string;
children?: Snippet;
}
let { variant = 'default', class: className, children }: Props = $props();
const badgeVariants = {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground'
};
</script>
<div
class={cn(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
badgeVariants[variant],
className
)}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,9 @@
import Root from './badge.svelte';
export {
Root,
//
Root as Badge
};
export type { BadgeVariant } from './badge.svelte';

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from 'bits-ui';
import Trigger from './sheet-trigger.svelte';
import Close from './sheet-close.svelte';
import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
import Header from './sheet-header.svelte';
import Footer from './sheet-footer.svelte';
import Title from './sheet-title.svelte';
import Description from './sheet-description.svelte';
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,60 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
}
},
defaultVariants: {
side: 'right'
}
});
export type Side = VariantProps<typeof sheetVariants>['side'];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
side = 'right',
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn('flex flex-col gap-1.5 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn('font-semibold text-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -3,10 +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 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 FindCard } from './components/FindCard.svelte';
export { default as FindsList } from './components/FindsList.svelte';
// Skeleton Loading Components // Skeleton Loading Components
export { Skeleton, SkeletonVariants } from './components/skeleton'; export { Skeleton, SkeletonVariants } from './components/skeleton';

View File

@@ -4,6 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24; const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -31,7 +32,11 @@ export async function validateSessionToken(token: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username }, user: {
id: table.user.id,
username: table.user.username,
profilePictureUrl: table.user.profilePictureUrl
},
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)
@@ -58,7 +63,25 @@ export async function validateSessionToken(token: string) {
.where(eq(table.session.id, session.id)); .where(eq(table.session.id, session.id));
} }
return { session, user }; // Generate signed URL for profile picture if it exists
let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return {
session,
user: {
...user,
profilePictureUrl
}
};
} }
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>; export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
@@ -95,7 +118,8 @@ export async function createUser(googleId: string, name: string) {
username: name, username: name,
googleId, googleId,
passwordHash: null, passwordHash: null,
age: null age: null,
profilePictureUrl: null
}; };
await db.insert(table.user).values(user); await db.insert(table.user).values(user);
return user; return user;

View File

@@ -5,7 +5,8 @@ export const user = pgTable('user', {
age: integer('age'), age: integer('age'),
username: text('username').notNull().unique(), username: text('username').notNull().unique(),
passwordHash: text('password_hash'), passwordHash: text('password_hash'),
googleId: text('google_id').unique() googleId: text('google_id').unique(),
profilePictureUrl: text('profile_picture_url')
}); });
export const session = pgTable('session', { export const session = pgTable('session', {
@@ -19,3 +20,68 @@ export const session = pgTable('session', {
export type Session = typeof session.$inferSelect; export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
// Finds feature tables
export const find = pgTable('find', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const findMedia = pgTable('find_media', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'photo' or 'video'
url: text('url').notNull(),
thumbnailUrl: text('thumbnail_url'),
fallbackUrl: text('fallback_url'), // JPEG fallback for WebP images
fallbackThumbnailUrl: text('fallback_thumbnail_url'), // JPEG fallback for WebP thumbnails
orderIndex: integer('order_index').default(0),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const findLike = pgTable('find_like', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const friendship = pgTable('friendship', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
friendId: text('friend_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
status: text('status').notNull(), // 'pending', 'accepted', 'blocked'
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Type exports for the new tables
export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect;
export type Friendship = typeof friendship.$inferSelect;
export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert;

View File

@@ -0,0 +1,207 @@
import sharp from 'sharp';
import { uploadToR2 } from './r2';
const THUMBNAIL_SIZE = 400;
const MAX_IMAGE_SIZE = 1920;
export async function processAndUploadImage(
file: File,
findId: string,
index: number
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const filename = `finds/${findId}/image-${index}-${timestamp}`;
// Process full-size image in WebP format (with JPEG fallback)
const processedWebP = await sharp(buffer)
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: 85, effort: 4 })
.toBuffer();
// Generate JPEG fallback for older browsers
const processedJPEG = await sharp(buffer)
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate thumbnail in WebP format
const thumbnailWebP = await sharp(buffer)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
// Generate JPEG thumbnail fallback
const thumbnailJPEG = await sharp(buffer)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 80 })
.toBuffer();
// Upload all variants to R2
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
type: 'image/webp'
});
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
type: 'image/jpeg'
});
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
type: 'image/webp'
});
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
type: 'image/jpeg'
});
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
return {
url: webpPath,
thumbnailUrl: thumbWebPPath,
fallbackUrl: jpegPath,
fallbackThumbnailUrl: thumbJPEGPath
};
}
export async function processAndUploadVideo(
file: File,
findId: string,
index: number
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const timestamp = Date.now();
const baseFilename = `finds/${findId}/video-${index}-${timestamp}`;
// Convert to MP4 if needed for better compatibility
let videoPath: string;
if (file.type === 'video/mp4') {
// Upload MP4 directly
videoPath = await uploadToR2(file, `${baseFilename}.mp4`, 'video/mp4');
} else {
// For other formats, upload as-is for now (future: convert with ffmpeg)
const extension = file.type === 'video/quicktime' ? '.mov' : '.mp4';
videoPath = await uploadToR2(file, `${baseFilename}${extension}`, file.type);
}
// Create a simple thumbnail using a static placeholder for now
// TODO: Implement proper video thumbnail extraction with ffmpeg or client-side canvas
const thumbnailUrl = '/video-placeholder.svg';
return {
url: videoPath,
thumbnailUrl,
// For videos, we can return the same URL as fallback since MP4 has broad support
fallbackUrl: videoPath,
fallbackThumbnailUrl: thumbnailUrl
};
}
export async function processAndUploadProfilePicture(
file: File,
userId: string
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 15);
const filename = `users/${userId}/profile-${timestamp}-${randomId}`;
// Process full-size image in WebP format (with JPEG fallback)
const processedWebP = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 85, effort: 4 })
.toBuffer();
// Generate JPEG fallback for older browsers
const processedJPEG = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate smaller thumbnail in WebP format
const thumbnailWebP = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
// Generate JPEG thumbnail fallback
const thumbnailJPEG = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 80 })
.toBuffer();
// Upload all variants to R2
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
type: 'image/webp'
});
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
type: 'image/jpeg'
});
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
type: 'image/webp'
});
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
type: 'image/jpeg'
});
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
return {
url: webpPath,
thumbnailUrl: thumbWebPPath,
fallbackUrl: jpegPath,
fallbackThumbnailUrl: thumbJPEGPath
};
}

74
src/lib/server/r2.ts Normal file
View File

@@ -0,0 +1,74 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '$env/dynamic/private';
// Environment variables with validation
const R2_ACCOUNT_ID = env.R2_ACCOUNT_ID;
const R2_ACCESS_KEY_ID = env.R2_ACCESS_KEY_ID;
const R2_SECRET_ACCESS_KEY = env.R2_SECRET_ACCESS_KEY;
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
// Validate required environment variables
function validateR2Config() {
if (!R2_ACCOUNT_ID) throw new Error('R2_ACCOUNT_ID environment variable is required');
if (!R2_ACCESS_KEY_ID) throw new Error('R2_ACCESS_KEY_ID environment variable is required');
if (!R2_SECRET_ACCESS_KEY)
throw new Error('R2_SECRET_ACCESS_KEY environment variable is required');
if (!R2_BUCKET_NAME) throw new Error('R2_BUCKET_NAME environment variable is required');
}
export const r2Client = new S3Client({
region: 'auto',
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID!,
secretAccessKey: R2_SECRET_ACCESS_KEY!
}
});
export const R2_PUBLIC_URL = `https://pub-${R2_ACCOUNT_ID}.r2.dev`;
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
validateR2Config();
const buffer = Buffer.from(await file.arrayBuffer());
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path,
Body: buffer,
ContentType: contentType,
CacheControl: 'public, max-age=31536000, immutable'
})
);
return path;
}
export async function deleteFromR2(path: string): Promise<void> {
validateR2Config();
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
})
);
}
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
validateR2Config();
const command = new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
});
return await getSignedUrl(r2Client, command, { expiresIn });
}

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

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

View File

@@ -1,13 +1,46 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
if (!event.locals.user) { if (!locals.user) {
// if not logged in, redirect to login page
return redirect(302, '/login'); return redirect(302, '/login');
} }
// Build API URL with query parameters
const apiUrl = new URL('/api/finds', url.origin);
// Forward location filtering parameters
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
if (lat) apiUrl.searchParams.set('lat', lat);
if (lng) apiUrl.searchParams.set('lng', lng);
apiUrl.searchParams.set('radius', radius);
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
apiUrl.searchParams.set('order', 'desc'); // Newest first
try {
const response = await fetch(apiUrl.toString(), {
headers: {
Cookie: request.headers.get('Cookie') || ''
}
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const finds = await response.json();
return { return {
user: event.locals.user finds
}; };
} catch (err) {
console.error('Error loading finds:', err);
return {
finds: []
};
}
}; };

View File

@@ -1,5 +1,190 @@
<script lang="ts"> <script lang="ts">
import { Map } from '$lib'; import { Map } from '$lib';
import FindsList from '$lib/components/FindsList.svelte';
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
import FindPreview from '$lib/components/FindPreview.svelte';
import FindsFilter from '$lib/components/FindsFilter.svelte';
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button';
// Server response type
interface ServerFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: string; // Will be converted to Date type, but is a string from api
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
// Map component type
interface MapFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
// Interface for FindPreview component
interface FindPreviewData {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
let showCreateModal = $state(false);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
// All finds - convert server format to component format
let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
...serverFind,
createdAt: new Date(serverFind.createdAt), // Convert string to Date
user: {
id: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl
},
likeCount: serverFind.likeCount,
isLiked: serverFind.isLikedByUser,
isFromFriend: serverFind.isFromFriend,
media: serverFind.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
})
)
})) as MapFind[]
);
// Filtered finds based on current filter
let finds = $derived.by(() => {
if (!data.user) return allFinds;
switch (currentFilter) {
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isFromFriend === true);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
default:
return allFinds;
}
});
function handleFilterChange(filter: string) {
currentFilter = filter;
}
function handleFindCreated(event: CustomEvent) {
// For now, just close modal and refresh page as in original implementation
showCreateModal = false;
if (event.detail?.reload) {
window.location.reload();
}
}
function handleFindClick(find: MapFind) {
// Convert MapFind to FindPreviewData format
selectedFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
createdAt: find.createdAt.toISOString(),
user: find.user,
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
}))
};
}
function handleFindExplore(id: string) {
// Find the specific find and show preview
const find = finds.find((f) => f.id === id);
if (find) {
handleFindClick(find);
}
}
function closeFindPreview() {
selectedFind = null;
}
function openCreateModal() {
showCreateModal = true;
}
function closeCreateModal() {
showCreateModal = false;
}
</script> </script>
<svelte:head> <svelte:head>
@@ -25,14 +210,61 @@
<div class="home-container"> <div class="home-container">
<main class="main-content"> <main class="main-content">
<div class="map-section"> <div class="map-section">
<Map showLocationButton={true} autoCenter={true} /> <Map
showLocationButton={true}
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
<div class="finds-section">
<div class="finds-sticky-header">
<div class="finds-header-content">
<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} hideTitle={true} />
</div> </div>
</main> </main>
<!-- Floating action button for mobile -->
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<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>
</button>
</div> </div>
<!-- Modals -->
{#if showCreateModal}
<CreateFindModal
isOpen={showCreateModal}
onClose={closeCreateModal}
onFindCreated={handleFindCreated}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} />
{/if}
<style> <style>
.home-container { .home-container {
background-color: #f8f8f8; background-color: #f8f8f8;
min-height: 100vh;
} }
.main-content { .main-content {
@@ -56,16 +288,120 @@
border-radius: 0; border-radius: 0;
} }
.finds-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.finds-sticky-header {
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 {
display: flex;
align-items: center;
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) {
flex-shrink: 0;
}
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 100;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.main-content { .main-content {
padding: 16px; padding: 16px;
gap: 16px; gap: 16px;
} }
.finds-sticky-header {
padding: 16px 16px 12px 16px;
}
.finds-header-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.finds-title-section {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.finds-title {
font-size: 1.5rem;
}
:global(.create-find-button) {
display: none;
}
.fab {
display: flex;
}
.map-section :global(.map-container) {
height: 300px;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.main-content { .main-content {
padding: 12px; padding: 12px;
} }
.finds-sticky-header {
padding: 12px 12px 8px 12px;
}
} }
</style> </style>

View File

@@ -0,0 +1,278 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true';
try {
// Get user's friends if needed
let friendIds: string[] = [];
if (includeFriends || includePrivate) {
const friendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId
})
.from(friendship)
.where(
and(
eq(friendship.status, 'accepted'),
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
)
);
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
}
// Build privacy conditions
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
if (includePrivate) {
// Include user's own finds (both public and private)
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
}
if (includeFriends && friendIds.length > 0) {
// Include friends' finds (both public and private)
conditions.push(
sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
);
}
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
let whereConditions = baseCondition;
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
const latOffset = radiusKm / 111;
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
const locationConditions = and(
baseCondition,
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
);
if (locationConditions) {
whereConditions = locationConditions;
}
}
// Get all finds with filtering, like counts, and user's liked status
const finds = await db
.select({
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) 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)
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(whereConditions)
.groupBy(find.id, user.username, user.profilePictureUrl)
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
.limit(100);
// Get media for all finds
const findIds = finds.map((f) => f.id);
let media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}> = [];
if (findIds.length > 0) {
media = await db
.select({
id: findMedia.id,
findId: findMedia.findId,
type: findMedia.type,
url: findMedia.url,
thumbnailUrl: findMedia.thumbnailUrl,
orderIndex: findMedia.orderIndex
})
.from(findMedia)
.where(
sql`${findMedia.findId} IN (${sql.join(
findIds.map((id) => sql`${id}`),
sql`, `
)})`
)
.orderBy(findMedia.orderIndex);
}
// Group media by find
const mediaByFind = media.reduce(
(acc, item) => {
if (!acc[item.findId]) {
acc[item.findId] = [];
}
acc[item.findId].push(item);
return acc;
},
{} as Record<string, typeof media>
);
// Combine finds with their media and generate signed URLs
const findsWithMedia = await Promise.all(
finds.map(async (findItem) => {
const findMedia = mediaByFind[findItem.id] || [];
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate signed URLs directly
const [signedUrl, signedThumbnailUrl] = await Promise.all([
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
]);
return {
...mediaItem,
url: signedUrl,
thumbnailUrl: signedThumbnailUrl
};
})
);
// Generate signed URL for user profile picture if it exists
let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
try {
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for user profile picture:', error);
userProfilePictureUrl = null;
}
}
return {
...findItem,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser),
isFromFriend: Boolean(findItem.isFromFriend)
};
})
);
return json(findsWithMedia);
} catch (err) {
console.error('Error loading finds:', err);
throw error(500, 'Failed to load finds');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
if (!title || !latitude || !longitude) {
throw error(400, 'Title, latitude, and longitude are required');
}
if (title.length > 100) {
throw error(400, 'Title must be 100 characters or less');
}
if (description && description.length > 500) {
throw error(400, 'Description must be 500 characters or less');
}
const findId = generateFindId();
// Create find
const newFind = await db
.insert(find)
.values({
id: findId,
userId: locals.user.id,
title,
description,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName,
category,
isPublic: isPublic ? 1 : 0
})
.returning();
// Create media records if provided
if (media && media.length > 0) {
const mediaRecords = media.map(
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
id: generateFindId(),
findId,
type: item.type,
url: item.url,
thumbnailUrl: item.thumbnailUrl,
orderIndex: index
})
);
await db.insert(findMedia).values(mediaRecords);
}
return json({ success: true, find: newFind[0] });
};

View File

@@ -0,0 +1,105 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findLike, find } from '$lib/server/db/schema';
import { eq, and, count } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
function generateLikeId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export async function POST({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
// Check if find exists
const existingFind = await db.select().from(find).where(eq(find.id, findId)).limit(1);
if (existingFind.length === 0) {
throw error(404, 'Find not found');
}
// Check if user already liked this find
const existingLike = await db
.select()
.from(findLike)
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)))
.limit(1);
if (existingLike.length > 0) {
throw error(409, 'Find already liked');
}
// Create new like
const likeId = generateLikeId();
await db.insert(findLike).values({
id: likeId,
findId,
userId: locals.user.id
});
// Get updated like count
const likeCountResult = await db
.select({ count: count() })
.from(findLike)
.where(eq(findLike.findId, findId));
const likeCount = likeCountResult[0]?.count ?? 0;
return json({
success: true,
likeId,
isLiked: true,
likeCount
});
}
export async function DELETE({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
// Remove like
await db
.delete(findLike)
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)));
// Get updated like count
const likeCountResult = await db
.select({ count: count() })
.from(findLike)
.where(eq(findLike.findId, findId));
const likeCount = likeCountResult[0]?.count ?? 0;
return json({
success: true,
isLiked: false,
likeCount
});
}

View File

@@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
import { encodeBase64url } from '@oslojs/encoding';
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
const MAX_FILES = 5;
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime'];
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const formData = await request.formData();
const files = formData.getAll('files') as File[];
const findId = (formData.get('findId') as string) || generateFindId(); // Generate if creating new Find
if (files.length === 0 || files.length > MAX_FILES) {
throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
}
const uploadedMedia: Array<{
type: string;
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Validate file size
if (file.size > MAX_FILE_SIZE) {
throw error(400, `File ${file.name} exceeds maximum size of 100MB`);
}
// Process based on type
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
const result = await processAndUploadImage(file, findId, i);
uploadedMedia.push({ type: 'photo', ...result });
} else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
const result = await processAndUploadVideo(file, findId, i);
uploadedMedia.push({ type: 'video', ...result });
} else {
throw error(400, `File type ${file.type} not allowed`);
}
}
return json({ findId, media: uploadedMedia });
};

View File

@@ -0,0 +1,191 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { friendship, user } from '$lib/server/db/schema';
import { eq, and, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
function generateFriendshipId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const status = url.searchParams.get('status') || 'accepted';
const type = url.searchParams.get('type') || 'friends'; // 'friends', 'sent', 'received'
try {
let friendships;
if (type === 'friends') {
// Get accepted friendships where user is either sender or receiver
const friendshipsRaw = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt
})
.from(friendship)
.where(
and(
eq(friendship.status, status),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
// Get friend details for each friendship
friendships = await Promise.all(
friendshipsRaw.map(async (f) => {
const friendUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
const friendUser = await db
.select({
username: user.username,
profilePictureUrl: user.profilePictureUrl
})
.from(user)
.where(eq(user.id, friendUserId))
.limit(1);
return {
id: f.id,
userId: f.userId,
friendId: f.friendId,
status: f.status,
createdAt: f.createdAt,
friendUsername: friendUser[0]?.username || '',
friendProfilePictureUrl: friendUser[0]?.profilePictureUrl || null
};
})
);
} else if (type === 'sent') {
// Get friend requests sent by current user
friendships = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
})
.from(friendship)
.innerJoin(user, eq(friendship.friendId, user.id))
.where(and(eq(friendship.userId, locals.user.id), eq(friendship.status, status)));
} else if (type === 'received') {
// Get friend requests received by current user
friendships = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
})
.from(friendship)
.innerJoin(user, eq(friendship.userId, user.id))
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
}
// Generate signed URLs for profile pictures
const friendshipsWithSignedUrls = await Promise.all(
(friendships || []).map(async (friendship) => {
let profilePictureUrl = friendship.friendProfilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
})
);
return json(friendshipsWithSignedUrls);
} catch (err) {
console.error('Error loading friendships:', err);
throw error(500, 'Failed to load friendships');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { friendId } = data;
if (!friendId) {
throw error(400, 'Friend ID is required');
}
if (friendId === locals.user.id) {
throw error(400, 'Cannot send friend request to yourself');
}
try {
// Check if friend exists
const friendExists = await db.select().from(user).where(eq(user.id, friendId)).limit(1);
if (friendExists.length === 0) {
throw error(404, 'User not found');
}
// Check if friendship already exists
const existingFriendship = await db
.select()
.from(friendship)
.where(
or(
and(eq(friendship.userId, locals.user.id), eq(friendship.friendId, friendId)),
and(eq(friendship.userId, friendId), eq(friendship.friendId, locals.user.id))
)
)
.limit(1);
if (existingFriendship.length > 0) {
const status = existingFriendship[0].status;
if (status === 'accepted') {
throw error(400, 'Already friends');
} else if (status === 'pending') {
throw error(400, 'Friend request already sent');
} else if (status === 'blocked') {
throw error(400, 'Cannot send friend request');
}
}
// Create new friend request
const newFriendship = await db
.insert(friendship)
.values({
id: generateFriendshipId(),
userId: locals.user.id,
friendId,
status: 'pending'
})
.returning();
return json({ success: true, friendship: newFriendship[0] });
} catch (err) {
console.error('Error sending friend request:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to send friend request');
}
};

View File

@@ -0,0 +1,129 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { friendship } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const { friendshipId } = params;
const data = await request.json();
const { action } = data; // 'accept', 'decline', 'block'
if (!friendshipId) {
throw error(400, 'Friendship ID is required');
}
if (!action || !['accept', 'decline', 'block'].includes(action)) {
throw error(400, 'Valid action is required (accept, decline, block)');
}
try {
// Find the friendship
const existingFriendship = await db
.select()
.from(friendship)
.where(eq(friendship.id, friendshipId))
.limit(1);
if (existingFriendship.length === 0) {
throw error(404, 'Friendship not found');
}
const friendshipRecord = existingFriendship[0];
// Check if user is authorized to modify this friendship
// User can only accept/decline requests sent TO them, or cancel requests they sent
const isReceiver = friendshipRecord.friendId === locals.user.id;
const isSender = friendshipRecord.userId === locals.user.id;
if (!isReceiver && !isSender) {
throw error(403, 'Not authorized to modify this friendship');
}
// Only receivers can accept or decline pending requests
if ((action === 'accept' || action === 'decline') && !isReceiver) {
throw error(403, 'Only the recipient can accept or decline friend requests');
}
// Only accept pending requests
if (action === 'accept' && friendshipRecord.status !== 'pending') {
throw error(400, 'Can only accept pending friend requests');
}
let newStatus: string;
if (action === 'accept') {
newStatus = 'accepted';
} else if (action === 'decline') {
newStatus = 'declined';
} else if (action === 'block') {
newStatus = 'blocked';
} else {
throw error(400, 'Invalid action');
}
// Update the friendship status
const updatedFriendship = await db
.update(friendship)
.set({ status: newStatus })
.where(eq(friendship.id, friendshipId))
.returning();
return json({ success: true, friendship: updatedFriendship[0] });
} catch (err) {
console.error('Error updating friendship:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update friendship');
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const { friendshipId } = params;
if (!friendshipId) {
throw error(400, 'Friendship ID is required');
}
try {
// Find the friendship
const existingFriendship = await db
.select()
.from(friendship)
.where(eq(friendship.id, friendshipId))
.limit(1);
if (existingFriendship.length === 0) {
throw error(404, 'Friendship not found');
}
const friendshipRecord = existingFriendship[0];
// Check if user is part of this friendship
if (
friendshipRecord.userId !== locals.user.id &&
friendshipRecord.friendId !== locals.user.id
) {
throw error(403, 'Not authorized to delete this friendship');
}
// Delete the friendship
await db.delete(friendship).where(eq(friendship.id, friendshipId));
return json({ success: true });
} catch (err) {
console.error('Error deleting friendship:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete friendship');
}
};

View File

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

View File

@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteFromR2 } from '$lib/server/r2';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const DELETE: RequestHandler = async ({ request }) => {
try {
const { userId } = await request.json();
if (!userId) {
return json({ error: 'userId is required' }, { status: 400 });
}
// Get current user to find profile picture URL
const currentUser = await db.select().from(user).where(eq(user.id, userId)).limit(1);
if (!currentUser.length) {
return json({ error: 'User not found' }, { status: 404 });
}
const userRecord = currentUser[0];
if (!userRecord.profilePictureUrl) {
return json({ error: 'No profile picture to delete' }, { status: 400 });
}
// Extract the base path from the stored URL (should be like users/123/profile-1234567890-abcdef.webp)
const basePath = userRecord.profilePictureUrl.replace('.webp', '');
// Delete all variants (WebP and JPEG, main and thumbnails)
const deletePromises = [
deleteFromR2(`${basePath}.webp`),
deleteFromR2(`${basePath}.jpg`),
deleteFromR2(`${basePath}-thumb.webp`),
deleteFromR2(`${basePath}-thumb.jpg`)
];
// Execute all deletions, but don't fail if some files don't exist
await Promise.allSettled(deletePromises);
// Update user profile picture URL in database
await db.update(user).set({ profilePictureUrl: null }).where(eq(user.id, userId));
return json({ success: true });
} catch (error) {
console.error('Profile picture delete error:', error);
return json({ error: 'Failed to delete profile picture' }, { status: 500 });
}
};

View File

@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processAndUploadProfilePicture } from '$lib/server/media-processor';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const userId = formData.get('userId') as string;
if (!file || !userId) {
return json({ error: 'File and userId are required' }, { status: 400 });
}
// Validate file type
if (!file.type.startsWith('image/')) {
return json({ error: 'File must be an image' }, { status: 400 });
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return json({ error: 'File size must be less than 5MB' }, { status: 400 });
}
// Process and upload profile picture
const result = await processAndUploadProfilePicture(file, userId);
// Update user profile picture URL in database (store the WebP path)
await db.update(user).set({ profilePictureUrl: result.url }).where(eq(user.id, userId));
return json({
success: true,
profilePictureUrl: result.url,
thumbnailUrl: result.thumbnailUrl,
fallbackUrl: result.fallbackUrl,
fallbackThumbnailUrl: result.fallbackThumbnailUrl
});
} catch (error) {
console.error('Profile picture upload error:', error);
return json({ error: 'Failed to upload profile picture' }, { status: 500 });
}
};

View File

@@ -0,0 +1,94 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { user, friendship } from '$lib/server/db/schema';
import { eq, and, or, ilike, ne } from 'drizzle-orm';
import { getSignedR2Url } from '$lib/server/r2';
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const query = url.searchParams.get('q');
const limit = parseInt(url.searchParams.get('limit') || '20');
if (!query || query.trim().length < 2) {
throw error(400, 'Search query must be at least 2 characters');
}
try {
// Search for users by username, excluding current user
const users = await db
.select({
id: user.id,
username: user.username,
profilePictureUrl: user.profilePictureUrl
})
.from(user)
.where(and(ilike(user.username, `%${query.trim()}%`), ne(user.id, locals.user.id)))
.limit(Math.min(limit, 50));
// Get existing friendships to determine relationship status
const userIds = users.map((u) => u.id);
let existingFriendships: Array<{
userId: string;
friendId: string;
status: string;
}> = [];
if (userIds.length > 0) {
// Use a simpler approach: get all friendships involving the current user
// and then filter for the relevant user IDs
const allUserFriendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status
})
.from(friendship)
.where(or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id)));
// Filter to only include friendships with users in our search results
existingFriendships = allUserFriendships.filter((f) => {
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
return userIds.includes(otherUserId);
});
}
// Create a map of friendship statuses
const friendshipStatusMap = new Map<string, string>();
existingFriendships.forEach((f) => {
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
friendshipStatusMap.set(otherUserId, f.status);
});
// Generate signed URLs and add friendship status
const usersWithSignedUrls = await Promise.all(
users.map(async (userItem) => {
let profilePictureUrl = userItem.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
})
);
return json(usersWithSignedUrls);
} catch (err) {
console.error('Error searching users:', err);
throw error(500, 'Failed to search users');
}
};

View File

@@ -0,0 +1,36 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
try {
// Fetch friends, sent requests, and received requests in parallel
const [friendsResponse, sentResponse, receivedResponse] = await Promise.all([
fetch('/api/friends?type=friends&status=accepted'),
fetch('/api/friends?type=sent&status=pending'),
fetch('/api/friends?type=received&status=pending')
]);
if (!friendsResponse.ok || !sentResponse.ok || !receivedResponse.ok) {
throw error(500, 'Failed to load friends data');
}
const [friends, sentRequests, receivedRequests] = await Promise.all([
friendsResponse.json(),
sentResponse.json(),
receivedResponse.json()
]);
return {
friends,
sentRequests,
receivedRequests
};
} catch (err) {
console.error('Error loading friends page:', err);
throw error(500, 'Failed to load friends');
}
};

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import { Badge } from '$lib/components/badge';
import { toast } from 'svelte-sonner';
import type { PageData } from './$types';
type Friendship = {
id: string;
userId: string;
friendId: string;
status: string;
createdAt: string;
friendUsername: string;
friendProfilePictureUrl: string | null;
};
type SearchUser = {
id: string;
username: string;
profilePictureUrl: string | null;
friendshipStatus: string;
};
let { data }: { data: PageData } = $props();
let searchQuery = $state('');
let searchResults = $state<SearchUser[]>([]);
let isSearching = $state(false);
let activeTab = $state('friends');
let friends = $state<Friendship[]>(data.friends);
let sentRequests = $state<Friendship[]>(data.sentRequests);
let receivedRequests = $state<Friendship[]>(data.receivedRequests);
async function searchUsers() {
if (searchQuery.trim().length < 2) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(`/api/users?q=${encodeURIComponent(searchQuery.trim())}`);
if (!response.ok) {
throw new Error('Failed to search users');
}
searchResults = await response.json();
} catch (error) {
console.error('Error searching users:', error);
toast.error('Failed to search users');
} finally {
isSearching = false;
}
}
async function sendFriendRequest(friendId: string) {
try {
const response = await fetch('/api/friends', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ friendId })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to send friend request');
}
toast.success('Friend request sent!');
// Update the search results to reflect the new status
searchResults = searchResults.map((user) =>
user.id === friendId ? { ...user, friendshipStatus: 'pending' } : user
);
} catch (error) {
console.error('Error sending friend request:', error);
toast.error(error instanceof Error ? error.message : 'Failed to send friend request');
}
}
async function respondToFriendRequest(friendshipId: string, action: 'accept' | 'decline') {
try {
const response = await fetch(`/api/friends/${friendshipId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action })
});
if (!response.ok) {
throw new Error(`Failed to ${action} friend request`);
}
toast.success(`Friend request ${action}ed!`);
// Remove from received requests
receivedRequests = receivedRequests.filter((req: Friendship) => req.id !== friendshipId);
// If accepted, refetch friends list
if (action === 'accept') {
const friendsResponse = await fetch('/api/friends?type=friends&status=accepted');
if (friendsResponse.ok) {
friends = await friendsResponse.json();
}
}
} catch (error) {
console.error(`Error ${action}ing friend request:`, error);
toast.error(`Failed to ${action} friend request`);
}
}
async function removeFriend(friendshipId: string) {
try {
const response = await fetch(`/api/friends/${friendshipId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to remove friend');
}
toast.success('Friend removed');
friends = friends.filter((friend: Friendship) => friend.id !== friendshipId);
} catch (error) {
console.error('Error removing friend:', error);
toast.error('Failed to remove friend');
}
}
// Search users when query changes with debounce
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
// Track searchQuery dependency explicitly
searchQuery;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchUsers, 300);
});
</script>
<svelte:head>
<title>Friends - Serengo</title>
</svelte:head>
<div class="friends-page">
<div class="friends-container">
<!-- Tab Navigation -->
<div class="tabs">
<Button
variant={activeTab === 'friends' ? 'default' : 'outline'}
onclick={() => (activeTab = 'friends')}
>
Friends ({friends.length})
</Button>
<Button
variant={activeTab === 'requests' ? 'default' : 'outline'}
onclick={() => (activeTab = 'requests')}
>
Requests ({receivedRequests.length})
</Button>
<Button
variant={activeTab === 'search' ? 'default' : 'outline'}
onclick={() => (activeTab = 'search')}
>
Find Friends
</Button>
</div>
<!-- Friends List -->
{#if activeTab === 'friends'}
<Card>
<CardHeader>
<CardTitle>Your Friends</CardTitle>
</CardHeader>
<CardContent>
{#if friends.length === 0}
<p class="empty-state">
No friends yet. Use the "Find Friends" tab to search for people!
</p>
{:else}
<div class="user-grid">
{#each friends as friend (friend.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div class="user-info">
<span class="username">{friend.friendUsername}</span>
<Badge variant="secondary">Friend</Badge>
</div>
<Button variant="destructive" size="sm" onclick={() => removeFriend(friend.id)}>
Remove
</Button>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
{/if}
<!-- Friend Requests -->
{#if activeTab === 'requests'}
<div class="requests-section">
<!-- Received Requests -->
<Card>
<CardHeader>
<CardTitle>Friend Requests ({receivedRequests.length})</CardTitle>
</CardHeader>
<CardContent>
{#if receivedRequests.length === 0}
<p class="empty-state">No pending friend requests</p>
{:else}
<div class="user-grid">
{#each receivedRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="outline">Pending</Badge>
</div>
<div class="request-actions">
<Button
variant="default"
size="sm"
onclick={() => respondToFriendRequest(request.id, 'accept')}
>
Accept
</Button>
<Button
variant="outline"
size="sm"
onclick={() => respondToFriendRequest(request.id, 'decline')}
>
Decline
</Button>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
<!-- Sent Requests -->
{#if sentRequests.length > 0}
<Card>
<CardHeader>
<CardTitle>Sent Requests ({sentRequests.length})</CardTitle>
</CardHeader>
<CardContent>
<div class="user-grid">
{#each sentRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="secondary">Sent</Badge>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Search Users -->
{#if activeTab === 'search'}
<Card>
<CardHeader>
<CardTitle>Find Friends</CardTitle>
</CardHeader>
<CardContent>
<div class="search-section">
<Input bind:value={searchQuery} placeholder="Search for users by username..." />
{#if isSearching}
<p class="loading">Searching...</p>
{:else if searchQuery.trim().length >= 2}
{#if searchResults.length === 0}
<p class="empty-state">No users found matching "{searchQuery}"</p>
{:else}
<div class="user-grid">
{#each searchResults as user (user.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div class="user-info">
<span class="username">{user.username}</span>
{#if user.friendshipStatus === 'accepted'}
<Badge variant="secondary">Friend</Badge>
{:else if user.friendshipStatus === 'pending'}
<Badge variant="outline">Request Sent</Badge>
{:else if user.friendshipStatus === 'blocked'}
<Badge variant="destructive">Blocked</Badge>
{/if}
</div>
{#if user.friendshipStatus === 'none'}
<Button onclick={() => sendFriendRequest(user.id)} size="sm">
Add Friend
</Button>
{/if}
</div>
{/each}
</div>
{/if}
{:else if searchQuery.trim().length > 0}
<p class="hint">Please enter at least 2 characters to search</p>
{/if}
</div>
</CardContent>
</Card>
{/if}
</div>
</div>
<style>
.friends-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.friends-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.user-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.username {
font-weight: 500;
color: #1a1a1a;
}
.request-actions {
display: flex;
gap: 8px;
}
.requests-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.search-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state,
.loading,
.hint {
padding: 40px 20px;
text-align: center;
color: #6b7280;
}
@media (max-width: 768px) {
.friends-page {
padding: 12px;
}
.user-grid {
grid-template-columns: 1fr;
}
.tabs {
flex-wrap: wrap;
}
.request-actions {
flex-direction: column;
}
}
</style>

View File

@@ -19,6 +19,7 @@ const self = globalThis.self as unknown as ServiceWorkerGlobalScope;
const CACHE = `cache-${version}`; const CACHE = `cache-${version}`;
const RUNTIME_CACHE = `runtime-${version}`; const RUNTIME_CACHE = `runtime-${version}`;
const IMAGE_CACHE = `images-${version}`; const IMAGE_CACHE = `images-${version}`;
const R2_CACHE = `r2-${version}`;
const ASSETS = [ const ASSETS = [
...build, // the app itself ...build, // the app itself
@@ -69,7 +70,7 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
// Remove previous cached data from disk // Remove previous cached data from disk
async function deleteOldCaches() { async function deleteOldCaches() {
const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE]; const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE, R2_CACHE];
for (const key of await caches.keys()) { for (const key of await caches.keys()) {
if (!currentCaches.includes(key)) { if (!currentCaches.includes(key)) {
await caches.delete(key); await caches.delete(key);
@@ -91,6 +92,7 @@ self.addEventListener('fetch', (event) => {
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
const runtimeCache = await caches.open(RUNTIME_CACHE); const runtimeCache = await caches.open(RUNTIME_CACHE);
const imageCache = await caches.open(IMAGE_CACHE); const imageCache = await caches.open(IMAGE_CACHE);
const r2Cache = await caches.open(R2_CACHE);
// `build`/`files` can always be served from the cache // `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) { if (ASSETS.includes(url.pathname)) {
@@ -128,6 +130,45 @@ self.addEventListener('fetch', (event) => {
} }
} }
// Handle R2 resources with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(event.request);
if (response.ok) {
// Cache R2 resources for a long time as they're typically immutable
r2Cache.put(event.request, response.clone());
}
return response;
} catch {
// Return cached version if available, or fall through to other cache checks
return cachedResponse || new Response('R2 resource not available', { status: 404 });
}
}
// Handle OpenStreetMap tiles with cache-first strategy
if (url.hostname === 'tile.openstreetmap.org') {
const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(event.request);
if (response.ok) {
// Cache map tiles for a long time
r2Cache.put(event.request, response.clone());
}
return response;
} catch {
return cachedResponse || new Response('Map tile not available', { status: 404 });
}
}
// Handle API and dynamic content with network-first strategy // Handle API and dynamic content with network-first strategy
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) { if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
try { try {
@@ -166,7 +207,8 @@ self.addEventListener('fetch', (event) => {
const cachedResponse = const cachedResponse =
(await cache.match(event.request)) || (await cache.match(event.request)) ||
(await runtimeCache.match(event.request)) || (await runtimeCache.match(event.request)) ||
(await imageCache.match(event.request)); (await imageCache.match(event.request)) ||
(await r2Cache.match(event.request));
if (cachedResponse) { if (cachedResponse) {
return cachedResponse; return cachedResponse;

View File

@@ -1,7 +1,7 @@
{ {
"short_name": "Serengo", "short_name": "Serengo",
"name": "Serengo - meet the unexpected.", "name": "Serengo",
"description": "Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before.", "description": "Serengo - meet the unexpected.",
"icons": [ "icons": [
{ {
"src": "/logo.svg", "src": "/logo.svg",

View File

@@ -0,0 +1,6 @@
<svg width="400" height="300" viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="300" fill="#1F2937"/>
<circle cx="200" cy="150" r="30" fill="#374151"/>
<path d="M190 135L215 150L190 165V135Z" fill="#9CA3AF"/>
<text x="200" y="200" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="14">Video</text>
</svg>

After

Width:  |  Height:  |  Size: 391 B