16 Commits

Author SHA1 Message Date
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
59 changed files with 7178 additions and 39 deletions

View File

@@ -29,3 +29,9 @@ STACK_SECRET_SERVER_KEY=***********************
# Google Oauth for google login
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Cloudflare R2 Storage for Finds media
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""

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,
"tag": "0002_robust_firedrake",
"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
}
]
}

641
finds.md Normal file
View File

@@ -0,0 +1,641 @@
# Serengo "Finds" Feature - Implementation Specification
## Feature Overview
Add a "Finds" feature to Serengo that allows users to save, share, and discover location-based experiences. A Find represents a memorable place that users want to share with friends, complete with photos/videos, reviews, and precise location data.
## Core Concept
**What is a Find?**
- A user-created post about a specific location they've visited
- Contains: location coordinates, title, description/review, media (photos/videos), timestamp
- Shareable with friends and the Serengo community
- Displayed as interactive markers on the map
## Database Schema
Add the following tables to `src/lib/server/db/schema.ts`:
```typescript
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: numeric('latitude', { precision: 10, scale: 7 }).notNull(),
longitude: numeric('longitude', { precision: 10, scale: 7 }).notNull(),
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
isPublic: boolean('is_public').default(true),
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'),
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()
});
```
## UI Components to Create
### 1. FindsMap Component (`src/lib/components/FindsMap.svelte`)
- Extends the existing Map component
- Displays custom markers for Finds (different from location marker)
- Clicking a Find marker opens a FindPreview modal
- Shows only friends' Finds by default, with toggle for public Finds
- Clusters markers when zoomed out for better performance
### 2. CreateFindModal Component (`src/lib/components/CreateFindModal.svelte`)
- Modal that opens when user clicks "Create Find" button
- Form fields:
- Title (required, max 100 chars)
- Description/Review (optional, max 500 chars)
- Location (auto-filled from user's current location, editable)
- Category selector (dropdown)
- Photo/Video upload (up to 5 files)
- Privacy toggle (Public/Friends Only)
- Image preview grid with drag-to-reorder
- Submit creates Find and refreshes map
### 3. FindPreview Component (`src/lib/components/FindPreview.svelte`)
- Compact card showing Find details
- Image/video carousel
- Title, description, username, timestamp
- Like button with count
- Share button
- "Get Directions" button (opens in maps app)
- Edit/Delete buttons (if user owns the Find)
### 4. FindsList Component (`src/lib/components/FindsList.svelte`)
- Feed view of Finds (alternative to map view)
- Infinite scroll or pagination
- Filter by: Friends, Public, Category, Date range
- Sort by: Recent, Popular (most liked), Nearest
### 5. FindsFilter Component (`src/lib/components/FindsFilter.svelte`)
- Dropdown/sidebar with filters:
- View: Friends Only / Public / My Finds
- Category: All / Cafes / Restaurants / Parks / etc.
- Distance: Within 1km / 5km / 10km / 50km / Any
## Routes to Add
### `/finds` - Main Finds page
- Server load: Fetch user's friends' Finds + public Finds within radius
- Toggle between Map view and List view
- "Create Find" floating action button
- Integrated FindsFilter component
### `/finds/[id]` - Individual Find detail page
- Full Find details with all photos/videos
- Comments section (future feature)
- Share button with social media integration
- Similar Finds nearby
### `/finds/create` - Create Find page (alternative to modal)
- Full-page form for creating a Find
- Better for mobile experience
- Can use when coming from external share (future feature)
### `/profile/[userId]/finds` - User's Finds profile
- Grid view of all user's Finds
- Can filter by category
- Private Finds only visible to owner
## API Endpoints
### `POST /api/finds` - Create Find
- Upload images/videos to storage (suggest Cloudflare R2 or similar)
- Create Find record in database
- Return created Find with media URLs
### `GET /api/finds?lat={lat}&lng={lng}&radius={km}` - Get nearby Finds
- Query Finds within radius of coordinates
- Filter by privacy settings and friendship status
- Return with user info and media
### `PATCH /api/finds/[id]` - Update Find
- Only owner can update
- Update title, description, category, privacy
### `DELETE /api/finds/[id]` - Delete Find
- Only owner can delete
- Cascade delete media files
### `POST /api/finds/[id]/like` - Like/Unlike Find
- Toggle like status
- Return updated like count
## File Upload Strategy with Cloudflare R2
**Using Cloudflare R2 (Free Tier: 10GB storage, 1M Class A ops/month, 10M Class B ops/month)**
### Setup Configuration
Create `src/lib/server/r2.ts`:
```typescript
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import {
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET_NAME
} from '$env/static/private';
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`; // Configure custom domain for production
// Upload file to R2
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
const buffer = Buffer.from(await file.arrayBuffer());
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path,
Body: buffer,
ContentType: contentType,
// Cache control for PWA compatibility
CacheControl: 'public, max-age=31536000, immutable'
})
);
return `${R2_PUBLIC_URL}/${path}`;
}
// Delete file from R2
export async function deleteFromR2(path: string): Promise<void> {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
})
);
}
// Generate signed URL for temporary access (optional, for private files)
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
});
return await getSignedUrl(r2Client, command, { expiresIn });
}
```
### Environment Variables
Add to `.env`:
```
R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET_NAME=serengo-finds
```
### Image Processing & Thumbnail Generation
Create `src/lib/server/media-processor.ts`:
```typescript
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 }> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const extension = file.name.split('.').pop();
const filename = `finds/${findId}/image-${index}-${timestamp}`;
// Process full-size image (resize if too large, optimize)
const processedImage = await sharp(buffer)
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate thumbnail
const thumbnail = await sharp(buffer)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 80 })
.toBuffer();
// Upload both to R2
const imageFile = new File([processedImage], `${filename}.jpg`, { type: 'image/jpeg' });
const thumbFile = new File([thumbnail], `${filename}-thumb.jpg`, { type: 'image/jpeg' });
const [url, thumbnailUrl] = await Promise.all([
uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
return { url, thumbnailUrl };
}
export async function processAndUploadVideo(
file: File,
findId: string,
index: number
): Promise<{ url: string; thumbnailUrl: string }> {
const timestamp = Date.now();
const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`;
// Upload video directly (no processing on server to save resources)
const url = await uploadToR2(file, filename, 'video/mp4');
// For video thumbnail, generate on client-side or use placeholder
// This keeps server-side processing minimal
const thumbnailUrl = `/video-placeholder.jpg`; // Use static placeholder
return { url, thumbnailUrl };
}
```
### PWA Service Worker Compatibility
Update `src/service-worker.ts` to handle R2 media:
```typescript
// Add to the existing service worker after the IMAGE_CACHE declaration
const R2_DOMAINS = [
'pub-', // R2 public URLs pattern
'r2.dev',
'r2.cloudflarestorage.com'
];
// In the fetch event listener, update image handling:
// Handle R2 media with cache-first strategy
if (url.hostname.includes('r2.dev') || R2_DOMAINS.some((domain) => url.hostname.includes(domain))) {
const cachedResponse = await imageCache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(event.request);
if (response.ok) {
// Cache R2 media for offline access
imageCache.put(event.request, response.clone());
}
return response;
} catch {
// Return cached version or fallback
return cachedResponse || new Response('Media not available offline', { status: 404 });
}
}
```
### API Endpoint for Upload
Create `src/routes/api/finds/upload/+server.ts`:
```typescript
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
import { generateUserId } from '$lib/server/auth'; // Reuse ID generation
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'];
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) || generateUserId(); // 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 }> = [];
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 });
};
```
### Client-Side Upload Component
Update `CreateFindModal.svelte` to handle uploads:
```typescript
async function handleFileUpload(files: FileList) {
const formData = new FormData();
Array.from(files).forEach((file) => {
formData.append('files', file);
});
// If editing existing Find, include findId
if (existingFindId) {
formData.append('findId', existingFindId);
}
const response = await fetch('/api/finds/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
// Store result for Find creation
uploadedMedia = result.media;
generatedFindId = result.findId;
}
```
### R2 Bucket Configuration
**Important R2 Settings for PWA:**
1. **Public Access:** Enable public bucket access for `serengo-finds` bucket
2. **CORS Configuration:**
```json
[
{
"AllowedOrigins": ["https://serengo.ziasvannes.tech", "http://localhost:5173"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
```
3. **Custom Domain (Recommended):**
- Point `media.serengo.ziasvannes.tech` to R2 bucket
- Enables better caching and CDN integration
- Update `R2_PUBLIC_URL` in r2.ts
4. **Cache Headers:** Files uploaded with `Cache-Control: public, max-age=31536000, immutable`
- PWA service worker can cache indefinitely
- Perfect for user-uploaded content that won't change
### Offline Support Strategy
**How PWA handles R2 media:**
1. **First Visit (Online):**
- Finds and media load from R2
- Service worker caches all media in `IMAGE_CACHE`
- Thumbnails cached for fast list views
2. **Subsequent Visits (Online):**
- Service worker serves from cache immediately (cache-first)
- No network delay for previously viewed media
3. **Offline:**
- All cached Finds and media available
- New Find creation queued (implement with IndexedDB)
- Upload when connection restored
### Cost Optimization
**R2 Free Tier Limits:**
- 10GB storage (≈ 10,000 high-quality photos or 100 videos)
- 1M Class A operations (write/upload)
- 10M Class B operations (read/download)
**Stay Within Free Tier:**
- Aggressive thumbnail generation (reduces bandwidth)
- Cache-first strategy (reduces reads)
- Lazy load images (only fetch what's visible)
- Compress images server-side (sharp optimization)
- Video uploads: limit to 100MB, encourage shorter clips
**Monitoring:**
- Track R2 usage in Cloudflare dashboard
- Alert at 80% of free tier limits
- Implement rate limiting on uploads (max 10 Finds/day per user)
## Map Integration
Update `src/lib/components/Map.svelte`:
- Add prop `finds?: Find[]` to display Find markers
- Create custom Find marker component with distinct styling
- Different marker colors for:
- Your own Finds (blue)
- Friends' Finds (green)
- Public Finds (orange)
- Add clustering for better performance with many markers
- Implement marker click handler to open FindPreview modal
## Social Features (Phase 2)
### Friends System
- Add friend request functionality
- Accept/decline friend requests
- Friends list page
- Privacy settings respect friendship status
### Notifications
- Notify when friend creates a Find nearby
- Notify when someone likes your Find
- Notify on friend request
### Discovery Feed
- Trending Finds in your area
- Recommended Finds based on your history
- Explore by category
## Mobile Considerations
- FindsMap should be touch-friendly with proper zoom controls
- Create Find button as floating action button (FAB) for easy access
- Optimize image upload for mobile (compress before upload)
- Consider PWA features for photo capture
- Offline support: Queue Finds when offline, sync when online
## Security & Privacy
- Validate all user inputs (title, description lengths)
- Sanitize text content to prevent XSS
- Check ownership before allowing edit/delete
- Respect privacy settings in all queries
- Rate limit Find creation (e.g., max 20 per day)
- Implement CSRF protection (already in place)
- Validate file types and sizes on upload
## Performance Optimizations
- Lazy load images in FindsList and FindPreview
- Implement virtual scrolling for long lists
- Cache Find queries with stale-while-revalidate
- Use CDN for media files
- Implement marker clustering on map
- Paginate Finds feed (20-50 per page)
- Index database on: (latitude, longitude), userId, createdAt
## Styling Guidelines
- Use existing Serengo design system and components
- Find markers should be visually distinct from location marker
- Maintain newspaper-inspired aesthetic
- Use Washington font for Find titles
- Keep UI clean and uncluttered
- Smooth animations for modal open/close
- Skeleton loading states for Finds feed
## Success Metrics to Track
- Number of Finds created per user
- Photo/video upload rate
- Likes per Find
- Map engagement (clicks on Find markers)
- Share rate
- Friend connections made
## Implementation Priority
**Phase 1 (MVP):**
1. Database schema and migrations
2. Create Find functionality (with photos only)
3. FindsMap with markers
4. FindPreview modal
5. Basic Find feed
**Phase 2:** 6. Video support 7. Like functionality 8. Friends system 9. Enhanced filters and search 10. Share functionality
**Phase 3:** 11. Comments on Finds 12. Discovery feed 13. Notifications 14. Advanced analytics
## Example User Flow
1. User opens Serengo app and sees map with their friends' Finds
2. User visits a cool café and wants to share it
3. Clicks "Create Find" FAB button
4. Modal opens with their current location pre-filled
5. User adds title "Amazing Belgian Waffles at Café Belga"
6. Writes review: "Best waffles I've had in Brussels! Great atmosphere too."
7. Uploads 2 photos of the waffles and café interior
8. Selects category "Cafe" and keeps it Public
9. Clicks "Share Find"
10. Find appears on map as new marker
11. Friends see it in their feed and can like/save it
12. Friends can click marker to see details and get directions
---
## Next Steps
1. Run database migrations to create new tables
2. Implement basic Create Find API endpoint
3. Build CreateFindModal component
4. Update Map component to display Find markers
5. Test end-to-end flow
6. Iterate based on user feedback
This feature will transform Serengo from a simple location app into a social discovery platform where users share and explore unexpected places together.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,186 @@
# 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 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
- 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
- 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 (Priority: Medium-High)
**Goal**: Social connections and privacy controls
**Tasks:**
- [ ] Build friend request system (send/accept/decline)
- [ ] Create Friends management page using SHADCN components
- [ ] Implement friend search with user suggestions
- [ ] Update find privacy logic to respect friendships
- [ ] Add friend-specific find visibility filters
**Estimated Effort**: 20-25 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
**Expected Timeline**: 8-10 weeks (part-time development)
## Next Steps:
1. Begin with Phase 2A (Modern Media Support) for immediate impact
2. Implement Phase 2B (Google Maps POI) for better UX
3. Add Phase 2C (Social Interactions) for user engagement
4. Continue with remaining phases based on user feedback

BIN
logs/20251014/login.pdf Normal file

Binary file not shown.

View File

@@ -2,23 +2,96 @@
## Oktober 2025
### 7 Oktober 2025 (Maandag) - 4 uren
### 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:** Nog niet gecommit (staged changes)
**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:**
- **Grote SEO, PWA en performance optimalisaties**
- Logo padding gefixed
- Favicon bestanden opgeruimd (verwijderd oude favicon bestanden)
- Manifest.json geoptimaliseerd
- Manifest.json geoptimaliseerd en naming fixes
- Sitemap.xml automatisch gegenereerd
- Service worker uitgebreid voor caching
- Meta tags en Open Graph voor SEO
- Background afbeelding gecomprimeerd (50% kleiner)
- Performance logs/PDFs opgeslagen voor vergelijking
- Performance logs/PDFs opgeslagen (voor én na optimalisaties)
- Vite config optimalisaties
- 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:**
@@ -27,6 +100,8 @@
- Performance optimalisaties door image compression
- Automatische sitemap generatie
- Service worker caching voor offline functionaliteit
- CSP security verbeteringen
- Performance monitoring met Lighthouse logs (voor/na vergelijking)
---
@@ -163,9 +238,9 @@
## Totaal Overzicht
**Totale geschatte uren:** 36 uren
**Werkdagen:** 6 dagen
**Gemiddelde uren per dag:** 6 uur
**Totale geschatte uren:** 55 uren
**Werkdagen:** 9 dagen
**Gemiddelde uren per dag:** 6.1 uur
### Project Milestones:
@@ -175,17 +250,28 @@
4. **29 Sept**: Component architectuur verbetering
5. **2-3 Okt**: Maps en location features
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
### Hoofdfunctionaliteiten geïmplementeerd:
- Gebruikersauthenticatie (Lucia + Google OAuth)
- Responsive UI met custom componenten
- Real-time locatie tracking
- Interactive maps (MapLibre GL JS)
- PWA functionaliteit
- Docker deployment
- Database (PostgreSQL + Drizzle ORM)
- Toast notifications
- Loading states en error handling
- SEO optimalisatie (meta tags, Open Graph, sitemap)
- Performance optimalisaties (image compression, caching)
- [x] Gebruikersauthenticatie (Lucia + Google OAuth)
- [x] Responsive UI met custom componenten
- [x] Real-time locatie tracking
- [x] Interactive maps (MapLibre GL JS)
- [x] PWA functionaliteit
- [x] Docker deployment
- [x] Database (PostgreSQL + Drizzle ORM)
- [x] Toast notifications
- [x] Loading states en error handling
- [x] SEO optimalisatie (meta tags, Open Graph, sitemap)
- [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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,432 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { coordinates } from '$lib/stores/location';
interface Props {
isOpen: boolean;
onClose: () => void;
onFindCreated: (event: CustomEvent) => void;
}
let { isOpen, onClose, onFindCreated }: Props = $props();
// Form state
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 }>>([]);
// Categories
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' }
];
// Auto-fill location when modal opens
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
// Handle file selection
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
// Upload media 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;
}
// Submit form
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
// Upload media first if any
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
// Create the find
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');
}
// Reset form and close modal
resetForm();
onClose();
// Notify parent about new find creation
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 resetForm() {
title = '';
description = '';
locationName = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
// Reset file input
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<Modal bind:showModal={isOpen} positioning="center">
{#snippet header()}
<h2>Create Find</h2>
{/snippet}
<div class="form-container">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label for="title">Title *</label>
<Input name="title" placeholder="What did you find?" required bind:value={title} />
<span class="char-count">{title.length}/100</span>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="form-group">
<label for="longitude">Longitude *</label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
<div class="form-group">
<label for="category">Category</label>
<select name="category" bind:value={category}>
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="media-files">Photos/Videos (max 5)</label>
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
multiple
max="5"
onchange={handleFileChange}
/>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-preview">
{#each Array.from(selectedFiles) as file (file.name)}
<span class="file-name">{file.name}</span>
{/each}
</div>
{/if}
</div>
<div class="form-group">
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
Cancel
</button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</div>
</Modal>
{/if}
<style>
.form-container {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
font-size: 0.9rem;
font-family: 'Washington', serif;
}
textarea {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
resize: vertical;
min-height: 100px;
font-family: inherit;
}
textarea:focus {
background-color: #d5d5d5;
}
textarea::placeholder {
color: #888;
}
select {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
cursor: pointer;
}
select:focus {
background-color: #d5d5d5;
}
input[type='file'] {
width: 100%;
padding: 12px;
border: 2px dashed #ccc;
border-radius: 8px;
background-color: #f9f9f9;
cursor: pointer;
transition: border-color 0.2s ease;
}
input[type='file']:hover {
border-color: #999;
}
.char-count {
font-size: 0.8rem;
color: #666;
text-align: right;
display: block;
margin-top: 4px;
}
.file-preview {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-name {
background-color: #f0f0f0;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #666;
}
.privacy-toggle {
display: flex;
align-items: center;
cursor: pointer;
font-weight: normal;
}
.privacy-toggle input[type='checkbox'] {
margin-right: 8px;
width: 18px;
height: 18px;
}
.privacy-help {
font-size: 0.8rem;
color: #666;
margin-top: 4px;
margin-left: 26px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
}
.button {
flex: 1;
padding: 1.25rem 2rem;
border: none;
border-radius: 2rem;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
height: 3.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button:disabled:hover {
transform: none;
}
.button.secondary {
background-color: #6c757d;
color: white;
}
.button.secondary:hover:not(:disabled) {
background-color: #5a6268;
transform: translateY(-1px);
}
@media (max-width: 640px) {
.form-container {
padding: 16px;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</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,202 @@
<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;
}
let {
finds,
onFindExplore,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display'
}: FindsListProps = $props();
function handleFindExplore(id: string) {
onFindExplore?.(id);
}
</script>
<section class="finds-feed">
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{#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%;
}
.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 = {
id: string;
username: string;
profilePictureUrl?: string | null;
};
let { user }: { user: User } = $props();
@@ -11,9 +12,13 @@
<header class="app-header">
<div class="header-content">
<h1 class="app-title">Serengo</h1>
<h1 class="app-title"><a href="/">Serengo</a></h1>
<div class="profile-container">
<ProfilePanel username={user.username} id={user.id} />
<ProfilePanel
username={user.username}
id={user.id}
profilePictureUrl={user.profilePictureUrl}
/>
</div>
</div>
</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 { 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 {
style?: StyleSpecification;
center?: [number, number];
@@ -18,6 +40,8 @@
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
}
let {
@@ -43,7 +67,9 @@
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true
autoCenter = true,
finds = [],
onFindClick
}: Props = $props();
let mapLoaded = $state(false);
@@ -121,6 +147,33 @@
</div>
</Marker>
{/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>
{#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) {
.map-container {
height: 300px;

View File

@@ -7,19 +7,31 @@
DropdownMenuSeparator,
DropdownMenuTrigger
} from './dropdown-menu';
import { Avatar, AvatarFallback } from './avatar';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Skeleton } from './skeleton';
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
interface Props {
username: string;
id: string;
profilePictureUrl?: string | null;
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
const initial = username.charAt(0).toUpperCase();
function openProfilePictureSheet() {
showProfilePictureSheet = true;
}
function closeProfilePictureSheet() {
showProfilePictureSheet = false;
}
</script>
<DropdownMenu>
@@ -28,6 +40,9 @@
<Skeleton class="h-10 w-10 rounded-full" />
{:else}
<Avatar class="profile-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
@@ -65,6 +80,16 @@
<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">
<span class="info-label">Username</span>
<span class="info-value">{username}</span>
@@ -86,6 +111,15 @@
</DropdownMenuContent>
</DropdownMenu>
{#if showProfilePictureSheet}
<ProfilePictureSheet
userId={id}
{username}
{profilePictureUrl}
onClose={closeProfilePictureSheet}
/>
{/if}
<style>
:global(.profile-trigger) {
background: none;
@@ -156,6 +190,35 @@
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) {
padding: 0;
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 ErrorMessage } from './components/ErrorMessage.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 Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.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
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 { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -31,7 +32,11 @@ export async function validateSessionToken(token: string) {
const [result] = await db
.select({
// 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
})
.from(table.session)
@@ -58,7 +63,25 @@ export async function validateSessionToken(token: string) {
.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>>;
@@ -95,7 +118,8 @@ export async function createUser(googleId: string, name: string) {
username: name,
googleId,
passwordHash: null,
age: null
age: null,
profilePictureUrl: null
};
await db.insert(table.user).values(user);
return user;

View File

@@ -5,7 +5,8 @@ export const user = pgTable('user', {
age: integer('age'),
username: text('username').notNull().unique(),
passwordHash: text('password_hash'),
googleId: text('google_id').unique()
googleId: text('google_id').unique(),
profilePictureUrl: text('profile_picture_url')
});
export const session = pgTable('session', {
@@ -19,3 +20,68 @@ export const session = pgTable('session', {
export type Session = typeof session.$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 });
}

View File

@@ -1,13 +1,46 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
// if not logged in, redirect to login page
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
if (!locals.user) {
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 {
user: event.locals.user
finds
};
} catch (err) {
console.error('Error loading finds:', err);
return {
finds: []
};
}
};

View File

@@ -1,5 +1,187 @@
<script lang="ts">
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;
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;
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,
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.isPublic === 0 && find.userId !== data.user!.id);
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>
<svelte:head>
@@ -25,14 +207,58 @@
<div class="home-container">
<main class="main-content">
<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-header">
<div class="finds-title-section">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
</div>
</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>
<!-- Modals -->
{#if showCreateModal}
<CreateFindModal
isOpen={showCreateModal}
onClose={closeCreateModal}
onFindCreated={handleFindCreated}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} />
{/if}
<style>
.home-container {
background-color: #f8f8f8;
min-height: 100vh;
}
.main-content {
@@ -56,16 +282,92 @@
border-radius: 0;
}
.finds-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.finds-header {
position: relative;
}
.finds-title-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
:global(.create-find-button) {
position: absolute;
top: 0;
right: 0;
z-index: 10;
}
.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) {
.main-content {
padding: 16px;
gap: 16px;
}
.finds-section {
padding: 16px;
}
.finds-title-section {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
:global(.create-find-button) {
display: none;
}
.fab {
display: flex;
}
.map-section :global(.map-container) {
height: 300px;
}
}
@media (max-width: 480px) {
.main-content {
padding: 12px;
}
.finds-section {
padding: 12px;
}
}
</style>

View File

@@ -0,0 +1,269 @@
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`
})
.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)
};
})
);
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,175 @@
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
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,
or(
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
)
)
.where(
and(
eq(friendship.status, status),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
} 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,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,97 @@
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) {
existingFriendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status
})
.from(friendship)
.where(
or(
and(
eq(friendship.userId, locals.user.id),
or(...userIds.map((id) => eq(friendship.friendId, id)))
),
and(
eq(friendship.friendId, locals.user.id),
or(...userIds.map((id) => eq(friendship.userId, id)))
)
)
);
}
// 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,429 @@
<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(() => {
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 RUNTIME_CACHE = `runtime-${version}`;
const IMAGE_CACHE = `images-${version}`;
const R2_CACHE = `r2-${version}`;
const ASSETS = [
...build, // the app itself
@@ -69,7 +70,7 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
// Remove previous cached data from disk
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()) {
if (!currentCaches.includes(key)) {
await caches.delete(key);
@@ -91,6 +92,7 @@ self.addEventListener('fetch', (event) => {
const cache = await caches.open(CACHE);
const runtimeCache = await caches.open(RUNTIME_CACHE);
const imageCache = await caches.open(IMAGE_CACHE);
const r2Cache = await caches.open(R2_CACHE);
// `build`/`files` can always be served from the cache
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
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
try {
@@ -166,7 +207,8 @@ self.addEventListener('fetch', (event) => {
const cachedResponse =
(await cache.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) {
return cachedResponse;

View File

@@ -1,7 +1,7 @@
{
"short_name": "Serengo",
"name": "Serengo - meet the unexpected.",
"description": "Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before.",
"name": "Serengo",
"description": "Serengo - meet the unexpected.",
"icons": [
{
"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