Compare commits
31 Commits
search
...
public-fin
| Author | SHA1 | Date | |
|---|---|---|---|
|
73eeaf0c74
|
|||
|
2ac826cbf9
|
|||
|
5285a15335
|
|||
|
9f608067fc
|
|||
|
4c73b6f919
|
|||
|
42d7246cff
|
|||
|
63b3e5112b
|
|||
|
84f3d0bdb9
|
|||
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
|||
|
ae339d68e1
|
|||
|
0754d62d0e
|
|||
|
e27b2498b7
|
|||
|
4d288347ab
|
|||
|
d7f803c782
|
|||
|
df675640c2
|
|||
|
2efd4969e7
|
|||
|
b8c88d7a58
|
|||
|
af49ed6237
|
|||
|
d3adac8acc
|
|||
|
9800be0147
|
|||
|
4c973c4e7d
|
|||
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
@@ -38,3 +38,8 @@ R2_BUCKET_NAME=""
|
||||
|
||||
# Google Maps API for Places search
|
||||
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||
|
||||
# Web Push VAPID Keys for notifications (generate with: node scripts/generate-vapid-keys.js)
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
11
drizzle/0006_strange_firebird.sql
Normal file
11
drizzle/0006_strange_firebird.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "find_comment" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"find_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
37
drizzle/0007_grey_dark_beast.sql
Normal file
37
drizzle/0007_grey_dark_beast.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "notification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"data" jsonb,
|
||||
"is_read" boolean DEFAULT false,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"friend_requests" boolean DEFAULT true,
|
||||
"friend_accepted" boolean DEFAULT true,
|
||||
"find_liked" boolean DEFAULT true,
|
||||
"find_commented" boolean DEFAULT true,
|
||||
"push_enabled" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_subscription" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh_key" text NOT NULL,
|
||||
"auth_key" text NOT NULL,
|
||||
"user_agent" text,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_subscription" ADD CONSTRAINT "notification_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
521
drizzle/meta/0006_snapshot.json
Normal file
521
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
764
drizzle/meta/0007_snapshot.json
Normal file
764
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,764 @@
|
||||
{
|
||||
"id": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||
"prevId": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.find": {
|
||||
"name": "find",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location_name": {
|
||||
"name": "location_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_user_id_user_id_fk": {
|
||||
"name": "find_user_id_user_id_fk",
|
||||
"tableFrom": "find",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_comment": {
|
||||
"name": "find_comment",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_comment_find_id_find_id_fk": {
|
||||
"name": "find_comment_find_id_find_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_comment_user_id_user_id_fk": {
|
||||
"name": "find_comment_user_id_user_id_fk",
|
||||
"tableFrom": "find_comment",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_like": {
|
||||
"name": "find_like",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_like_find_id_find_id_fk": {
|
||||
"name": "find_like_find_id_find_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"find_like_user_id_user_id_fk": {
|
||||
"name": "find_like_user_id_user_id_fk",
|
||||
"tableFrom": "find_like",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.find_media": {
|
||||
"name": "find_media",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"find_id": {
|
||||
"name": "find_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_url": {
|
||||
"name": "fallback_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fallback_thumbnail_url": {
|
||||
"name": "fallback_thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order_index": {
|
||||
"name": "order_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"find_media_find_id_find_id_fk": {
|
||||
"name": "find_media_find_id_find_id_fk",
|
||||
"tableFrom": "find_media",
|
||||
"tableTo": "find",
|
||||
"columnsFrom": [
|
||||
"find_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friendship": {
|
||||
"name": "friendship",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_id": {
|
||||
"name": "friend_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friendship_user_id_user_id_fk": {
|
||||
"name": "friendship_user_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friendship_friend_id_user_id_fk": {
|
||||
"name": "friendship_friend_id_user_id_fk",
|
||||
"tableFrom": "friendship",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"friend_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_preferences": {
|
||||
"name": "notification_preferences",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_requests": {
|
||||
"name": "friend_requests",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"friend_accepted": {
|
||||
"name": "friend_accepted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_liked": {
|
||||
"name": "find_liked",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"find_commented": {
|
||||
"name": "find_commented",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"push_enabled": {
|
||||
"name": "push_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_preferences_user_id_user_id_fk": {
|
||||
"name": "notification_preferences_user_id_user_id_fk",
|
||||
"tableFrom": "notification_preferences",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notification_subscription": {
|
||||
"name": "notification_subscription",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"p256dh_key": {
|
||||
"name": "p256dh_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_key": {
|
||||
"name": "auth_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notification_subscription_user_id_user_id_fk": {
|
||||
"name": "notification_subscription_user_id_user_id_fk",
|
||||
"tableFrom": "notification_subscription",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"age": {
|
||||
"name": "age",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"profile_picture_url": {
|
||||
"name": "profile_picture_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"google_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,20 @@
|
||||
"when": 1760631798851,
|
||||
"tag": "0005_rapid_warpath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1762428302491,
|
||||
"tag": "0006_strange_firebird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1762522687342,
|
||||
"tag": "0007_grey_dark_beast",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,7 +25,17 @@ export default defineConfig(
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
'no-undef': 'off',
|
||||
// Disable no-navigation-without-resolve as we're using resolveRoute from $app/paths
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Allow unused vars that start with underscore
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Serengo Finds Feature Implementation Log
|
||||
|
||||
## Project Overview
|
||||
|
||||
Serengo is a location-based social discovery platform where users can save, share, and discover memorable places with media, reviews, and precise location data.
|
||||
|
||||
## Current Status: Phase 2A, 2C & 2D Complete + UI Integration ✅
|
||||
|
||||
### What Serengo Currently Has:
|
||||
|
||||
- Complete finds creation with photo uploads and location data
|
||||
- Interactive map with find markers and detailed previews
|
||||
- Responsive design with map/list view toggle
|
||||
- **NEW**: Modern WebP image processing with JPEG fallbacks
|
||||
- **NEW**: Full video support with custom VideoPlayer component
|
||||
- **NEW**: Like/unlike system with optimistic UI updates
|
||||
- **NEW**: Complete friends & privacy system with friend requests and filtered feeds
|
||||
- R2 storage integration with enhanced media processing
|
||||
- Full database schema with proper relationships and social features
|
||||
- Type-safe API endpoints and error handling
|
||||
- Mobile-optimized UI with floating action button
|
||||
|
||||
### Production Ready Features:
|
||||
|
||||
- Create/view finds with photos, descriptions, and categories
|
||||
- Location-based filtering and discovery
|
||||
- Media carousel with navigation (supports images and videos)
|
||||
- **NEW**: Video playback with custom controls and fullscreen support
|
||||
- **NEW**: Like/unlike finds with real-time count updates
|
||||
- **NEW**: Animated like buttons with heart animations
|
||||
- **NEW**: Friend request system with send/accept/decline functionality
|
||||
- **NEW**: Friends management page with user search and relationship status
|
||||
- **NEW**: Privacy-aware find feeds with friend-specific visibility filters
|
||||
- Share functionality with clipboard copy
|
||||
- Real-time map markers with click-to-preview
|
||||
- Grid layout for find browsing
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Implementation Plan (Updated October 14, 2025)
|
||||
|
||||
### Technical Requirements & Standards:
|
||||
|
||||
- **Media Formats**: Use modern WebP for images, WebM/AV1 for videos
|
||||
- **UI Components**: Leverage existing SHADCN components for consistency
|
||||
- **Code Quality**: Follow Svelte 5 best practices with clean, reusable components
|
||||
- **POI Search**: Integrate Google Maps Places API for location search
|
||||
- **Type Safety**: Maintain strict TypeScript throughout
|
||||
|
||||
### Phase 2A: Modern Media Support ✅ COMPLETE
|
||||
|
||||
**Goal**: Upgrade to modern file formats and video support
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Update media processor to output WebP images (with JPEG fallback)
|
||||
- [x] Implement MP4 video processing with thumbnail generation
|
||||
- [x] Create reusable VideoPlayer component using SHADCN
|
||||
- [x] Enhanced database schema with fallback URL support
|
||||
- [x] Optimize compression settings for web delivery
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Updated `media-processor.ts` to generate both WebP and JPEG versions
|
||||
- Enhanced `findMedia` table with `fallbackUrl` and `fallbackThumbnailUrl` fields
|
||||
- Created `VideoPlayer.svelte` with custom controls, progress bar, and fullscreen support
|
||||
- Added video placeholder SVG for consistent UI
|
||||
- Maintained backward compatibility with existing media
|
||||
|
||||
**Actual Effort**: ~12 hours
|
||||
|
||||
### Phase 2B: Enhanced Location & POI Search (Priority: High)
|
||||
|
||||
**Goal**: Google Maps integration for better location discovery
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Integrate Google Maps Places API for POI search
|
||||
- [ ] Create location search component with autocomplete
|
||||
- [ ] Add "Search nearby" functionality in CreateFindModal
|
||||
- [ ] Implement reverse geocoding for address display
|
||||
- [ ] Add place details (hours, ratings, etc.) from Google Places
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
### Phase 2C: Social Interactions ✅ COMPLETE
|
||||
|
||||
**Goal**: Like system and user engagement
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Implement like/unlike API using existing findLike table
|
||||
- [x] Create reusable LikeButton component with animations
|
||||
- [x] Add like counts and user's liked status to find queries
|
||||
- [x] Add optimistic UI updates for instant feedback
|
||||
- [x] **COMPLETED**: Full UI integration into FindCard and FindPreview components
|
||||
- [x] **COMPLETED**: Updated all data interfaces to support like information
|
||||
- [x] **COMPLETED**: Enhanced media carousel with VideoPlayer component integration
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Created `/api/finds/[findId]/like` endpoints for POST (like) and DELETE (unlike)
|
||||
- Built `LikeButton.svelte` with optimistic UI updates and heart animations
|
||||
- Enhanced find queries to include like counts and user's liked status via SQL aggregation
|
||||
- Integrated LikeButton into both FindCard (list view) and FindPreview (modal view)
|
||||
- Updated VideoPlayer usage throughout the application for consistent video playback
|
||||
- Maintained type safety across all interfaces and data flows
|
||||
|
||||
**Future Task:**
|
||||
|
||||
- [ ] Build "My Liked Finds" collection page (moved to Phase 2G)
|
||||
|
||||
**Actual Effort**: ~15 hours (including UI integration)
|
||||
|
||||
### Phase 2D: Friends & Privacy System ✅ COMPLETE
|
||||
|
||||
**Goal**: Social connections and privacy controls
|
||||
|
||||
**Completed Tasks:**
|
||||
|
||||
- [x] Build friend request system (send/accept/decline)
|
||||
- [x] Create Friends management page using SHADCN components
|
||||
- [x] Implement friend search with user suggestions
|
||||
- [x] Update find privacy logic to respect friendships
|
||||
- [x] Add friend-specific find visibility filters
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Created comprehensive friends API with `/api/friends` and `/api/friends/[friendshipId]` endpoints
|
||||
- Built `/api/users` endpoint for user search with friendship status integration
|
||||
- Developed complete Friends management page (`/routes/friends/`) with tabs for:
|
||||
- Friends list with remove functionality
|
||||
- Friend requests (received/sent) with accept/decline actions
|
||||
- User search with friend request sending capabilities
|
||||
- Enhanced finds API to support friend-based privacy filtering with `includeFriends` parameter
|
||||
- Created `FindsFilter.svelte` component with filter options:
|
||||
- All Finds (public, friends, and user's finds)
|
||||
- Public Only (publicly visible finds)
|
||||
- Friends Only (finds from friends)
|
||||
- My Finds (user's own finds)
|
||||
- Updated main page with integrated filter dropdown and real-time filtering
|
||||
- Enhanced ProfilePanel with Friends navigation link
|
||||
- Maintained type safety and error handling throughout all implementations
|
||||
|
||||
**Technical Architecture:**
|
||||
|
||||
- Leveraged existing `friendship` table schema without modifications
|
||||
- Used SHADCN components (Cards, Badges, Avatars, Buttons, Dropdowns) for consistent UI
|
||||
- Implemented proper authentication and authorization on all endpoints
|
||||
- Added comprehensive error handling with descriptive user feedback
|
||||
- Followed Svelte 5 patterns with `$state`, `$derived`, and `$props` runes
|
||||
|
||||
**Actual Effort**: ~22 hours
|
||||
|
||||
### Phase 2E: Advanced Filtering & Discovery (Priority: Medium)
|
||||
|
||||
**Goal**: Better find discovery and organization
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create FilterPanel component with category/distance/date filters
|
||||
- [ ] Implement text search through find titles/descriptions
|
||||
- [ ] Add sort options (recent, popular, nearest)
|
||||
- [ ] Build infinite scroll for find feeds
|
||||
- [ ] Add "Similar finds nearby" recommendations
|
||||
|
||||
**Estimated Effort**: 15-18 hours
|
||||
|
||||
### Phase 2F: Enhanced Sharing & Individual Pages (Priority: Medium)
|
||||
|
||||
**Goal**: Better sharing and find discoverability
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- [ ] Create individual find detail pages (`/finds/[id]`)
|
||||
- [ ] Add social media sharing with OpenGraph meta tags
|
||||
- [ ] Implement "Get Directions" integration with map apps
|
||||
- [ ] Build shareable find links with previews
|
||||
- [ ] Add "Copy link" functionality
|
||||
|
||||
**Estimated Effort**: 12-15 hours
|
||||
|
||||
---
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Component Architecture:
|
||||
|
||||
- Use composition pattern with reusable SHADCN components
|
||||
- Implement proper TypeScript interfaces for all props
|
||||
- Follow Svelte 5 runes pattern ($props, $derived, $effect)
|
||||
- Create clean separation between UI and business logic
|
||||
|
||||
### Code Quality:
|
||||
|
||||
- Maintain existing formatting (tabs, single quotes, 100 char width)
|
||||
- Use descriptive variable names and function signatures
|
||||
- Implement proper error boundaries and loading states
|
||||
- Add accessibility attributes (ARIA labels, keyboard navigation)
|
||||
|
||||
### Performance:
|
||||
|
||||
- Lazy load media content and heavy components
|
||||
- Implement proper caching strategies for API calls
|
||||
- Use virtual scrolling for long lists when needed
|
||||
- Optimize images/videos for web delivery
|
||||
|
||||
**Total Phase 2 Estimated Effort**: 82-105 hours
|
||||
**Total Phase 2 Completed Effort**: ~49 hours (Phases 2A: 12h + 2C: 15h + 2D: 22h)
|
||||
**Expected Timeline**: 8-10 weeks (part-time development)
|
||||
|
||||
## Next Steps:
|
||||
|
||||
1. ✅ **Completed**: Phase 2A (Modern Media Support) for immediate impact
|
||||
2. **Next Priority**: Phase 2B (Google Maps POI) for better UX
|
||||
3. ✅ **Completed**: Phase 2C (Social Interactions) for user engagement
|
||||
4. ✅ **Completed**: Phase 2D (Friends & Privacy System) for social connections
|
||||
5. **Continue with**: Phase 2E (Advanced Filtering) or 2F (Enhanced Sharing) based on user feedback
|
||||
|
||||
## Production Ready Features Summary:
|
||||
|
||||
**Core Functionality:**
|
||||
|
||||
- Create/view finds with photos, videos, descriptions, and categories
|
||||
- Location-based filtering and discovery with interactive map
|
||||
- Media carousel with navigation (WebP images and MP4 videos)
|
||||
- Real-time map markers with click-to-preview functionality
|
||||
|
||||
**Social Features:**
|
||||
|
||||
- Like/unlike finds with real-time count updates and animations
|
||||
- Friend request system (send, accept, decline, remove)
|
||||
- Friends management page with user search capabilities
|
||||
- Privacy-aware find feeds with customizable visibility filters
|
||||
|
||||
**Technical Excellence:**
|
||||
|
||||
- Modern media formats (WebP, MP4) with fallback support
|
||||
- Type-safe API endpoints with comprehensive error handling
|
||||
- Mobile-optimized responsive design
|
||||
- Performance-optimized with proper caching and lazy loading
|
||||
191
logs/logboek.md
191
logs/logboek.md
@@ -1,7 +1,174 @@
|
||||
# Logboek - Serengo Project
|
||||
|
||||
## November 2025
|
||||
|
||||
### 17 November 2025 - 3 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design**
|
||||
- Fullscreen map layout met side-sheet voor finds geïmplementeerd
|
||||
- Sidebar toggle functionaliteit toegevoegd
|
||||
- Local media proxy geïmplementeerd voor caching en CSP fixes
|
||||
- Mobile en desktop UI verbeteringen voor CreateFind en FindPreview
|
||||
- Overscroll behavior fixes
|
||||
- FindList overflow problemen opgelost
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 1c31e2c - add:sidebar toggle and fix overscroll behavior
|
||||
- d8cab06 - fix:overflow of findlist
|
||||
- d4d23ed - ui:find preview better ui
|
||||
- ab8b0ee - ui:create find better ui
|
||||
- dabc732 - fix:styling for mobile createfind
|
||||
- 1f0e814 - ui:remove mobile + button and use same as desktop
|
||||
- 96a173b - feat:use local proxy for media
|
||||
- 08f7e77 - ui:big ui update
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete UI refresh met fullscreen map en overlay side-sheet voor finds
|
||||
- Sidebar toggle voor betere map ervaring
|
||||
- Local media proxy (/api/media/[...path]) voor caching en snellere laadtijden
|
||||
- CSP issues opgelost door local proxy te gebruiken in plaats van directe R2 requests
|
||||
- LocationButton component verwijderd (430 lines cleanup)
|
||||
- Verbeterde mobile en desktop styling voor CreateFindModal
|
||||
- FindPreview UI verbeteringen voor betere gebruikerservaring
|
||||
- Overscroll behavior gefixed voor soepelere scrolling
|
||||
- Mobile + button verwijderd, unified desktop/mobile interface
|
||||
|
||||
---
|
||||
|
||||
### 8 November 2025 - 5 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Web Push Notifications Systeem**
|
||||
- Complete Web Push notification systeem geïmplementeerd
|
||||
- NotificationManager, NotificationPrompt, en NotificationSettings componenten
|
||||
- Notification preferences API endpoints
|
||||
- Push notification triggers voor likes, comments, en friend requests
|
||||
- Service worker push event handling
|
||||
- VAPID keys generation script
|
||||
|
||||
**Commits:**
|
||||
|
||||
- ae339d6 - chore:linting,formatting,type fixing, ....
|
||||
- 0754d62 - fix:push notification UI, settings and API
|
||||
- e27b249 - fix:notifications
|
||||
- 4d28834 - fix:notificationmanager
|
||||
- d7f803c - fix:add NotificationManager and enable in layout
|
||||
- df67564 - feat:add Web Push notification system
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete Web Push notification infrastructuur met VAPID keys
|
||||
- Database schema uitbreiding voor notification subscriptions en preferences
|
||||
- NotificationManager component voor real-time notification handling
|
||||
- NotificationPrompt voor gebruikers toestemming
|
||||
- NotificationSettings en NotificationSettingsSheet voor preference management
|
||||
- Push notifications bij likes, comments, en friend requests
|
||||
- Service worker integratie voor background push events
|
||||
- API endpoints voor subscription management en preferences
|
||||
- CSP updates voor FCM/GCM endpoints
|
||||
- Lucide-svelte dependency toegevoegd voor icons
|
||||
- Code linting, formatting, en type fixes
|
||||
|
||||
---
|
||||
|
||||
### 6 November 2025 - 4 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Comments Feature Implementatie**
|
||||
- Complete comment systeem voor finds geïmplementeerd
|
||||
- Comment creation, viewing, en deletion functionaliteit
|
||||
- API-sync layer voor real-time comment synchronisatie
|
||||
- Scrollable comments met limit functionaliteit
|
||||
- Comment form en list UI componenten
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 2efd496 - add:enhance comments list with scroll, limit, and styling
|
||||
- b8c88d7 - feat:comments
|
||||
- af49ed6 - logs:update logs
|
||||
|
||||
**Details:**
|
||||
|
||||
- Comment database schema en migraties
|
||||
- API endpoints voor comment CRUD operaties (/api/finds/[findId]/comments)
|
||||
- API-sync store uitbreiding voor comment state management
|
||||
- CommentForm component met real-time posting
|
||||
- CommentsList component met scrolling en limit ("+N more comments")
|
||||
- Comment component met delete functionaliteit
|
||||
- Integration in FindCard en FindPreview componenten
|
||||
- Responsive styling voor mobile en desktop
|
||||
- User authentication en authorization voor comments
|
||||
- Real-time updates via API-sync layer
|
||||
|
||||
---
|
||||
|
||||
## Oktober 2025
|
||||
|
||||
### 4 November 2025 - 1 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **UI Consistency & Media Layout Improvements**
|
||||
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
|
||||
- Media layout en sizing verbeteringen voor betere responsive design
|
||||
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
|
||||
- Component refactoring voor consistentere UI across applicatie
|
||||
- Loading states en fallback styling geconsolideerd
|
||||
|
||||
**Commits:**
|
||||
|
||||
- d3adac8 - add:ProfilePicture component and replace Avatar
|
||||
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
|
||||
|
||||
**Details:**
|
||||
|
||||
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
|
||||
- Avatar componenten vervangen door ProfilePicture voor consistentie
|
||||
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
|
||||
- Max-height van 600px toegevoegd voor images en videos met height:auto
|
||||
- Object-fit: contain toegepast voor media images in preview
|
||||
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
|
||||
- Loading states UI geconsolideerd in ProfilePicture component
|
||||
- Component library verder uitgebreid met herbruikbare UI elementen
|
||||
|
||||
---
|
||||
|
||||
### 27-29 Oktober 2025 - 10 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
|
||||
- **Phase 3: Google Places Integration & Sync Service**
|
||||
- Complete implementatie van sync-service voor API data synchronisatie
|
||||
- Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||
- Location tracking optimalisaties met continuous watching
|
||||
- CSP (Content Security Policy) fixes voor verbeterde security
|
||||
- Child subscription fixes voor data consistency
|
||||
|
||||
**Commits:**
|
||||
|
||||
- 4c973c4 - fix:some csp issues
|
||||
- d7fe909 - fix:new child subscription
|
||||
- 6620cc6 - feat:implement a sync-service
|
||||
- 3b3ebc2 - fix:continuously watch location
|
||||
- fef7c16 - feat:use GMaps places api for searching poi's
|
||||
|
||||
**Details:**
|
||||
|
||||
- Complete sync-service architectuur voor real-time data synchronisatie
|
||||
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
|
||||
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
|
||||
- Security verbeteringen door CSP issues op te lossen
|
||||
- Data consistency verbeteringen met child subscription fixes
|
||||
- Enhanced POI search capabilities met Google Maps integration
|
||||
|
||||
---
|
||||
|
||||
### 21 Oktober 2025 (Maandag) - 4 uren
|
||||
|
||||
**Werk uitgevoerd:**
|
||||
@@ -322,9 +489,9 @@
|
||||
|
||||
## Totaal Overzicht
|
||||
|
||||
**Totale geschatte uren:** 67 uren
|
||||
**Werkdagen:** 12 dagen
|
||||
**Gemiddelde uren per dag:** 5.6 uur
|
||||
**Totale geschatte uren:** 93 uren
|
||||
**Werkdagen:** 17 dagen
|
||||
**Gemiddelde uren per dag:** 5.5 uur
|
||||
|
||||
### Project Milestones:
|
||||
|
||||
@@ -340,6 +507,11 @@
|
||||
10. **16 Okt**: Friends & Privacy System implementatie
|
||||
11. **20 Okt**: Search logic improvements
|
||||
12. **21 Okt**: UI refinement en bug fixes
|
||||
13. **27-29 Okt**: Google Places Integration & Sync Service
|
||||
14. **4 Nov**: UI Consistency & Media Layout Improvements
|
||||
15. **6 Nov**: Comments Feature Implementatie
|
||||
16. **8 Nov**: Web Push Notifications Systeem
|
||||
17. **17 Nov**: UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design
|
||||
|
||||
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||
|
||||
@@ -366,3 +538,16 @@
|
||||
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
|
||||
- [x] Real-time find filtering op basis van privacy instellingen
|
||||
- [x] Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||
- [x] Sync-service voor API data synchronisatie
|
||||
- [x] Continuous location watching voor nauwkeurige tracking
|
||||
- [x] CSP security verbeteringen
|
||||
- [x] Comments systeem met real-time synchronisatie
|
||||
- [x] Scrollable comments met limit functionaliteit
|
||||
- [x] Web Push notifications systeem
|
||||
- [x] Notification preferences management
|
||||
- [x] Push notifications voor likes, comments, en friend requests
|
||||
- [x] Service worker push event handling
|
||||
- [x] Local media proxy voor caching en performance
|
||||
- [x] Fullscreen map UI met sidebar toggle
|
||||
- [x] Unified mobile/desktop interface
|
||||
|
||||
@@ -59,9 +59,12 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||
"arctic": "^3.7.0",
|
||||
"lucide-svelte": "^0.553.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"postgres": "^3.4.5",
|
||||
"sharp": "^0.34.4",
|
||||
"svelte-maplibre": "^1.2.1"
|
||||
"svelte-maplibre": "^1.2.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
20
scripts/generate-vapid-keys.js
Executable file
20
scripts/generate-vapid-keys.js
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate VAPID keys for Web Push notifications
|
||||
* Run this script once to generate your VAPID keys and add them to your .env file
|
||||
*/
|
||||
|
||||
import webpush from 'web-push';
|
||||
|
||||
console.log('Generating VAPID keys for Web Push notifications...\n');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('VAPID Keys Generated Successfully!');
|
||||
console.log('Add these to your .env file:\n');
|
||||
console.log(`VAPID_PUBLIC_KEY="${vapidKeys.publicKey}"`);
|
||||
console.log(`VAPID_PRIVATE_KEY="${vapidKeys.privateKey}"`);
|
||||
console.log('VAPID_SUBJECT="mailto:your-email@example.com"');
|
||||
console.log('\nReplace "your-email@example.com" with your actual email address.');
|
||||
console.log('\nIMPORTANT: Keep your private key secret and never commit it to version control!');
|
||||
@@ -88,6 +88,7 @@
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -50,9 +50,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
"worker-src 'self' blob:; " +
|
||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||
"font-src 'self' fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com; " +
|
||||
"connect-src 'self' *.openstreetmap.org; " +
|
||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||
"connect-src 'self' *.openstreetmap.org https://fcm.googleapis.com https://android.googleapis.com; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ProfilePanel } from '$lib';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
@@ -12,7 +13,7 @@
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1 class="app-title"><a href="/">Serengo</a></h1>
|
||||
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
|
||||
<div class="profile-container">
|
||||
<ProfilePanel
|
||||
username={user.username}
|
||||
@@ -27,8 +28,7 @@
|
||||
.app-header {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -37,7 +37,6 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<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>
|
||||
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
locationActions,
|
||||
locationStatus,
|
||||
locationError,
|
||||
isLocationLoading
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
variant?: 'primary' | 'secondary' | 'icon';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
showLabel = true
|
||||
}: Props = $props();
|
||||
|
||||
async function handleLocationClick() {
|
||||
const result = await locationActions.getCurrentLocation({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000
|
||||
});
|
||||
|
||||
if (!result && $locationError) {
|
||||
toast.error($locationError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = $derived(() => {
|
||||
if ($isLocationLoading) return 'Finding location...';
|
||||
if ($locationStatus === 'success') return 'Update location';
|
||||
return 'Find my location';
|
||||
});
|
||||
|
||||
const iconClass = $derived(() => {
|
||||
if ($isLocationLoading) return 'loading';
|
||||
if ($locationStatus === 'success') return 'success';
|
||||
if ($locationStatus === 'error') return 'error';
|
||||
return 'default';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="location-button {variant} {size} {className}"
|
||||
onclick={handleLocationClick}
|
||||
disabled={$isLocationLoading}
|
||||
title={buttonText()}
|
||||
>
|
||||
<span class="icon {iconClass()}">
|
||||
{#if $isLocationLoading}
|
||||
<div class="loading-skeleton">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
{:else if $locationStatus === 'success'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $locationStatus === 'error'}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showLabel && variant !== 'icon'}
|
||||
<span class="label">{buttonText()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.location-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.icon:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small.icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.medium.icon {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 12px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.large.icon {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.small .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.large .icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon.success svg {
|
||||
color: #10b981;
|
||||
fill: #10b981;
|
||||
}
|
||||
|
||||
.icon.error svg {
|
||||
color: #ef4444;
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.location-button .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location-button {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -163,6 +163,7 @@
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal.dropdown {
|
||||
@@ -171,11 +172,12 @@
|
||||
right: 0;
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
1
src/lib/components/auth/index.ts
Normal file
1
src/lib/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LoginForm } from './login-form.svelte';
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import { Button } from '$lib/components/button/index.js';
|
||||
import * as Card from '$lib/components/card/index.js';
|
||||
import { Label } from '$lib/components/label/index.js';
|
||||
@@ -8,7 +9,7 @@
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ActionData } from '../../routes/login/$types.js';
|
||||
import type { ActionData } from '../../../routes/login/$types.js';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
@@ -62,7 +63,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-4 w-full"
|
||||
onclick={() => goto(resolveRoute('/login/google'))}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
@@ -58,7 +59,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
href={disabled ? undefined : resolveRoute(href)}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
|
||||
141
src/lib/components/finds/Comment.svelte
Normal file
141
src/lib/components/finds/Comment.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import type { CommentState } from '$lib/stores/api-sync';
|
||||
|
||||
interface CommentProps {
|
||||
comment: CommentState;
|
||||
showDeleteButton?: boolean;
|
||||
onDelete?: (commentId: string) => void;
|
||||
}
|
||||
|
||||
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
|
||||
|
||||
function handleDelete() {
|
||||
if (onDelete) {
|
||||
onDelete(comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - dateObj.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) {
|
||||
return 'just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
} else if (days < 7) {
|
||||
return `${days}d`;
|
||||
} else {
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comment">
|
||||
<div class="comment-avatar">
|
||||
<ProfilePicture
|
||||
username={comment.user.username}
|
||||
profilePictureUrl={comment.user.profilePictureUrl}
|
||||
class="avatar-small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-username">@{comment.user.username}</span>
|
||||
<span class="comment-time">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div class="comment-text">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDeleteButton}
|
||||
<div class="comment-actions">
|
||||
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.comment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.avatar-small) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-username {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.delete-button) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.25rem;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.delete-button:hover) {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
</style>
|
||||
118
src/lib/components/finds/CommentForm.svelte
Normal file
118
src/lib/components/finds/CommentForm.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Send } from '@lucide/svelte';
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (content: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { onSubmit, placeholder = 'Add a comment...', disabled = false }: CommentFormProps = $props();
|
||||
|
||||
let content = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent || isSubmitting || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedContent.length > 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
onSubmit(trimmedContent);
|
||||
content = '';
|
||||
} catch (error) {
|
||||
console.error('Error submitting comment:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = $derived(
|
||||
content.trim().length > 0 && content.length <= 500 && !isSubmitting && !disabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<form class="comment-form" onsubmit={handleSubmit}>
|
||||
<div class="input-container">
|
||||
<Input
|
||||
bind:value={content}
|
||||
{placeholder}
|
||||
disabled={disabled || isSubmitting}
|
||||
class="comment-input"
|
||||
onkeydown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="ghost" size="sm" disabled={!canSubmit} class="submit-button">
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if content.length > 450}
|
||||
<div class="character-count" class:warning={content.length > 500}>
|
||||
{content.length}/500
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.comment-form {
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--background));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.comment-input) {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:global(.submit-button) {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled)) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.submit-button:not(:disabled):hover) {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.character-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.character-count.warning {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
</style>
|
||||
23
src/lib/components/finds/Comments.svelte
Normal file
23
src/lib/components/finds/Comments.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface CommentsProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsProps = $props();
|
||||
</script>
|
||||
|
||||
<CommentsList {findId} {currentUserId} {collapsed} {maxComments} {showCommentForm} {isScrollable} />
|
||||
253
src/lib/components/finds/CommentsList.svelte
Normal file
253
src/lib/components/finds/CommentsList.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import Comment from './Comment.svelte';
|
||||
import CommentForm from './CommentForm.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import { apiSync } from '$lib/stores/api-sync';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface CommentsListProps {
|
||||
findId: string;
|
||||
currentUserId?: string;
|
||||
collapsed?: boolean;
|
||||
maxComments?: number;
|
||||
showCommentForm?: boolean;
|
||||
isScrollable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
findId,
|
||||
currentUserId,
|
||||
collapsed = true,
|
||||
maxComments,
|
||||
showCommentForm = true,
|
||||
isScrollable = false
|
||||
}: CommentsListProps = $props();
|
||||
|
||||
let isExpanded = $state(!collapsed);
|
||||
let hasLoadedComments = $state(false);
|
||||
|
||||
const commentsState = apiSync.subscribeFindComments(findId);
|
||||
|
||||
onMount(() => {
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadComments() {
|
||||
if (hasLoadedComments) return;
|
||||
|
||||
hasLoadedComments = true;
|
||||
await apiSync.loadComments(findId);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
if (isExpanded && !hasLoadedComments) {
|
||||
loadComments();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment(content: string) {
|
||||
await apiSync.addComment(findId, content);
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId: string) {
|
||||
await apiSync.deleteComment(commentId, findId);
|
||||
}
|
||||
|
||||
function canDeleteComment(comment: { user: { id: string } }): boolean {
|
||||
return Boolean(
|
||||
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet loadingSkeleton()}
|
||||
<div class="loading-skeleton">
|
||||
{#each Array(3) as _, index (index)}
|
||||
<div class="comment-skeleton">
|
||||
<Skeleton class="avatar-skeleton" />
|
||||
<div class="content-skeleton">
|
||||
<Skeleton class="header-skeleton" />
|
||||
<Skeleton class="text-skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="comments-list">
|
||||
{#if collapsed}
|
||||
<button class="toggle-button" onclick={toggleExpanded}>
|
||||
{#if isExpanded}
|
||||
Hide comments ({$commentsState.commentCount})
|
||||
{:else if $commentsState.commentCount > 0}
|
||||
View comments ({$commentsState.commentCount})
|
||||
{:else}
|
||||
Add comment
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="comments-container">
|
||||
{#if showCommentForm}
|
||||
<div class="comment-form-container">
|
||||
<CommentForm onSubmit={handleAddComment} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $commentsState.isLoading && !hasLoadedComments}
|
||||
{@render loadingSkeleton()}
|
||||
{:else if $commentsState.error}
|
||||
<div class="error-message">
|
||||
Failed to load comments.
|
||||
<button class="retry-button" onclick={loadComments}> Try again </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="comments" class:scrollable={isScrollable}>
|
||||
{#each maxComments ? $commentsState.comments.slice(0, maxComments) : $commentsState.comments as comment (comment.id)}
|
||||
<Comment
|
||||
{comment}
|
||||
showDeleteButton={canDeleteComment(comment)}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||
{/each}
|
||||
{#if maxComments && $commentsState.comments.length > maxComments}
|
||||
<div class="see-more">
|
||||
<div class="see-more-text">
|
||||
+{$commentsState.comments.length - maxComments} more comments
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comments-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.comments-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.comment-form-container {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: hsl(var(--background));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comments {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comments.scrollable {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 0;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.see-more {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.see-more-text {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 1.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
color: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comment-skeleton {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
:global(.avatar-skeleton) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.header-skeleton) {
|
||||
width: 120px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:global(.text-skeleton) {
|
||||
width: 80%;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Label } from '$lib/components/label';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import POISearch from './POISearch.svelte';
|
||||
import POISearch from '../map/POISearch.svelte';
|
||||
import type { PlaceResult } from '$lib/utils/places';
|
||||
|
||||
interface Props {
|
||||
@@ -37,7 +36,6 @@
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let showModal = $state(true);
|
||||
let isMobile = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
@@ -54,13 +52,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!showModal) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showModal && $coordinates) {
|
||||
if (isOpen && $coordinates) {
|
||||
latitude = $coordinates.latitude.toString();
|
||||
longitude = $coordinates.longitude.toString();
|
||||
}
|
||||
@@ -129,8 +121,8 @@
|
||||
}
|
||||
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating find:', error);
|
||||
alert('Failed to create find. Please try again.');
|
||||
@@ -173,16 +165,27 @@
|
||||
|
||||
function closeModal() {
|
||||
resetForm();
|
||||
showModal = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create Find</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Create Find</h2>
|
||||
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
@@ -191,7 +194,7 @@
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<Label for="title">What did you find?</Label>
|
||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||
@@ -334,7 +337,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="modal-footer">
|
||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -343,24 +346,106 @@
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.create-find-sheet) {
|
||||
padding: 0 !important;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: 40%;
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global(.create-find-sheet) {
|
||||
height: 90vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -369,13 +454,14 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -390,14 +476,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 80px;
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
@@ -407,6 +487,7 @@
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
@@ -448,13 +529,14 @@
|
||||
}
|
||||
|
||||
.privacy-toggle:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.privacy-toggle input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
@@ -485,7 +567,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
@@ -493,6 +575,7 @@
|
||||
|
||||
.file-content span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
@@ -500,7 +583,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
@@ -516,24 +599,26 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.actions {
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
.modal-footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.location-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-header {
|
||||
@@ -544,18 +629,19 @@
|
||||
|
||||
.toggle-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
@@ -567,7 +653,7 @@
|
||||
.coordinates-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
@@ -578,38 +664,53 @@
|
||||
.coordinate {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.edit-coords-button {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
background: hsl(var(--secondary));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-coords-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-content {
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.actions :global(button) {
|
||||
flex: none;
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
<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';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
||||
|
||||
interface FindCardProps {
|
||||
id: string;
|
||||
@@ -23,6 +24,8 @@
|
||||
}>;
|
||||
likeCount?: number;
|
||||
isLiked?: boolean;
|
||||
commentCount?: number;
|
||||
currentUserId?: string;
|
||||
onExplore?: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -36,15 +39,48 @@
|
||||
media,
|
||||
likeCount = 0,
|
||||
isLiked = false,
|
||||
commentCount = 0,
|
||||
currentUserId,
|
||||
onExplore
|
||||
}: FindCardProps = $props();
|
||||
|
||||
let showComments = $state(false);
|
||||
|
||||
function handleExplore() {
|
||||
onExplore?.(id);
|
||||
}
|
||||
|
||||
function getUserInitials(username: string): string {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
function toggleComments() {
|
||||
showComments = !showComments;
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const url = `${window.location.origin}/finds/${id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: title,
|
||||
text: description || `Check out this find: ${title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,14 +88,11 @@
|
||||
<!-- Post Header -->
|
||||
<div class="post-header">
|
||||
<div class="user-info">
|
||||
<Avatar class="avatar">
|
||||
{#if user.profilePictureUrl}
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{getUserInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={user.username}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="user-details">
|
||||
<div class="username">@{user.username}</div>
|
||||
{#if locationName}
|
||||
@@ -80,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="more-button">
|
||||
<MoreHorizontal size={16} />
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -101,8 +134,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div class="post-media">
|
||||
{#if media && media.length > 0}
|
||||
{#if media && media.length > 0}
|
||||
<div class="post-media">
|
||||
{#if media[0].type === 'photo'}
|
||||
<img
|
||||
src={media[0].thumbnailUrl || media[0].url}
|
||||
@@ -120,31 +153,18 @@
|
||||
class="media-video"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="no-media">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="post-actions">
|
||||
<div class="action-buttons">
|
||||
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
|
||||
<MessageCircle size={16} />
|
||||
<span>comment</span>
|
||||
<span>{commentCount || 'comment'}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
|
||||
<Share size={16} />
|
||||
<span>share</span>
|
||||
</Button>
|
||||
@@ -153,20 +173,25 @@
|
||||
explore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
{#if showComments}
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={id}
|
||||
{currentUserId}
|
||||
collapsed={true}
|
||||
maxComments={5}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.find-card {
|
||||
background: white;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
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 */
|
||||
@@ -258,33 +283,23 @@
|
||||
/* Media */
|
||||
.post-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.5;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* Post Actions */
|
||||
@@ -300,12 +315,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button) {
|
||||
gap: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.action-button:hover) {
|
||||
@@ -317,6 +336,13 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Comments Section */
|
||||
.comments-section {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.post-actions {
|
||||
@@ -1,8 +1,8 @@
|
||||
<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';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||
import CommentsList from './CommentsList.svelte';
|
||||
|
||||
interface Find {
|
||||
id: string;
|
||||
@@ -30,11 +30,11 @@
|
||||
interface Props {
|
||||
find: Find | null;
|
||||
onClose: () => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
let { find, onClose }: Props = $props();
|
||||
let { find, onClose, currentUserId }: Props = $props();
|
||||
|
||||
let showModal = $state(true);
|
||||
let currentMediaIndex = $state(0);
|
||||
let isMobile = $state(false);
|
||||
|
||||
@@ -52,13 +52,6 @@
|
||||
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;
|
||||
@@ -95,33 +88,44 @@
|
||||
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
|
||||
});
|
||||
navigator
|
||||
.share({
|
||||
title: find.title,
|
||||
text: find.description || `Check out this find: ${find.title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(url);
|
||||
alert('Find URL copied to clipboard!');
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if find}
|
||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||
<SheetHeader class="sheet-header">
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="user-section">
|
||||
<Avatar class="user-avatar">
|
||||
{#if find.user.profilePictureUrl}
|
||||
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
|
||||
{/if}
|
||||
<AvatarFallback class="avatar-fallback">
|
||||
{find.user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={find.user.username}
|
||||
profilePictureUrl={find.user.profilePictureUrl}
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||
<h2 class="find-title">{find.title}</h2>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{find.user.username}</span>
|
||||
<span class="separator">•</span>
|
||||
@@ -133,9 +137,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="modal-body">
|
||||
{#if find.media && find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
@@ -254,38 +269,90 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments-section">
|
||||
<CommentsList
|
||||
findId={find.id}
|
||||
{currentUserId}
|
||||
collapsed={false}
|
||||
isScrollable={true}
|
||||
showCommentForm={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Base styles for sheet content */
|
||||
:global(.sheet-content) {
|
||||
padding: 0 !important;
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
min-width: 400px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles (bottom sheet) */
|
||||
@media (max-width: 767px) {
|
||||
:global(.sheet-content) {
|
||||
height: 80vh !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
max-height: calc(90vh - 20px);
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.sheet-header) {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -293,7 +360,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
@@ -314,13 +382,13 @@
|
||||
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-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
@@ -351,70 +419,55 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
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;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(.media-video) {
|
||||
@@ -479,9 +532,9 @@
|
||||
|
||||
.content-section {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -497,7 +550,6 @@
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -545,9 +597,18 @@
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile specific adjustments */
|
||||
@media (max-width: 640px) {
|
||||
:global(.sheet-header) {
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -560,21 +621,12 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.find-title) {
|
||||
font-size: 1.125rem !important;
|
||||
.find-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
src/lib/components/finds/LikeButton.svelte
Normal file
119
src/lib/components/finds/LikeButton.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
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();
|
||||
|
||||
// Local state stores for this like button - start with props but will be overridden by global state
|
||||
const likeState = writable({
|
||||
isLiked: isLiked,
|
||||
likeCount: likeCount,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
let apiSync: typeof import('$lib/stores/api-sync').apiSync | null = null;
|
||||
|
||||
// Initialize API sync and subscribe to global state
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
// Dynamically import the API sync
|
||||
const module = await import('$lib/stores/api-sync');
|
||||
apiSync = module.apiSync;
|
||||
|
||||
// Check if global state already exists for this find
|
||||
const existingState = apiSync.getEntityState('find', findId);
|
||||
|
||||
if (existingState) {
|
||||
// Use existing global state - it's more current than props
|
||||
console.log(`Using existing global state for find ${findId}`, existingState);
|
||||
} else {
|
||||
// Initialize with minimal data only if no global state exists
|
||||
console.log(`Initializing new minimal state for find ${findId}`);
|
||||
apiSync.setEntityState('find', findId, {
|
||||
id: findId,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount,
|
||||
// Minimal data needed for like functionality
|
||||
title: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
isPublic: true,
|
||||
createdAt: new Date(),
|
||||
userId: '',
|
||||
username: '',
|
||||
isFromFriend: false
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to global state for this find
|
||||
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||
globalLikeState.subscribe(
|
||||
(state: { isLiked: boolean; likeCount: number; isLoading: boolean }) => {
|
||||
likeState.set({
|
||||
isLiked: state.isLiked,
|
||||
likeCount: state.likeCount,
|
||||
isLoading: state.isLoading
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize API sync:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleLike() {
|
||||
if (!apiSync || !browser) return;
|
||||
|
||||
if ($likeState.isLoading) return;
|
||||
|
||||
try {
|
||||
await apiSync.toggleLike(findId);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={$likeState.isLoading}
|
||||
>
|
||||
<Heart
|
||||
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
|
||||
? 'scale-110 fill-red-500 text-red-500'
|
||||
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{#if $likeState.likeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{$likeState.likeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
10
src/lib/components/finds/index.ts
Normal file
10
src/lib/components/finds/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Comment } from './Comment.svelte';
|
||||
export { default as CommentForm } from './CommentForm.svelte';
|
||||
export { default as CommentsList } from './CommentsList.svelte';
|
||||
export { default as Comments } from './Comments.svelte';
|
||||
export { default as CreateFindModal } from './CreateFindModal.svelte';
|
||||
export { default as FindCard } from './FindCard.svelte';
|
||||
export { default as FindPreview } from './FindPreview.svelte';
|
||||
export { default as FindsFilter } from './FindsFilter.svelte';
|
||||
export { default as FindsList } from './FindsList.svelte';
|
||||
export { default as LikeButton } from './LikeButton.svelte';
|
||||
81
src/lib/components/map/LocationManager.svelte
Normal file
81
src/lib/components/map/LocationManager.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
|
||||
|
||||
interface Props {
|
||||
autoStart?: boolean;
|
||||
enableHighAccuracy?: boolean;
|
||||
timeout?: number;
|
||||
maximumAge?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
autoStart = true,
|
||||
enableHighAccuracy = true,
|
||||
timeout = 15000,
|
||||
maximumAge = 60000
|
||||
}: Props = $props();
|
||||
|
||||
// Location watching options
|
||||
const watchOptions = {
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!browser || !autoStart) return;
|
||||
|
||||
// Check if geolocation is supported
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation is not supported by this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have coordinates and aren't watching
|
||||
if ($coordinates && !$isWatching) {
|
||||
// Start watching immediately if we have previous coordinates
|
||||
startLocationWatching();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no coordinates, try to get current location first
|
||||
if (!$coordinates) {
|
||||
getCurrentLocationThenWatch();
|
||||
}
|
||||
});
|
||||
|
||||
async function getCurrentLocationThenWatch() {
|
||||
try {
|
||||
const result = await locationActions.getCurrentLocation(watchOptions);
|
||||
if (result) {
|
||||
// Successfully got location, now start watching
|
||||
startLocationWatching();
|
||||
}
|
||||
} catch {
|
||||
// If we can't get location due to permissions, don't auto-start watching
|
||||
console.log('Could not get initial location, location watching not started automatically');
|
||||
}
|
||||
}
|
||||
|
||||
function startLocationWatching() {
|
||||
if (!$isWatching) {
|
||||
locationActions.startWatching(watchOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function to stop watching when component is destroyed
|
||||
function cleanup() {
|
||||
if ($isWatching) {
|
||||
locationActions.stopWatching();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop watching when the component is destroyed
|
||||
onMount(() => {
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render anything, it just manages location watching -->
|
||||
@@ -6,9 +6,9 @@
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions
|
||||
locationActions,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import LocationButton from './LocationButton.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
|
||||
interface Find {
|
||||
@@ -38,7 +38,6 @@
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
class?: string;
|
||||
showLocationButton?: boolean;
|
||||
autoCenter?: boolean;
|
||||
finds?: Find[];
|
||||
onFindClick?: (find: Find) => void;
|
||||
@@ -66,7 +65,6 @@
|
||||
center,
|
||||
zoom,
|
||||
class: className = '',
|
||||
showLocationButton = true,
|
||||
autoCenter = true,
|
||||
finds = [],
|
||||
onFindClick
|
||||
@@ -89,8 +87,10 @@
|
||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||
|
||||
// Reactive center and zoom based on location or props
|
||||
// Only recenter when shouldZoomToLocation is true (user clicked location button)
|
||||
// or when autoCenter is true AND coordinates are first loaded
|
||||
const mapCenter = $derived(
|
||||
$coordinates && (autoCenter || $shouldZoomToLocation)
|
||||
$coordinates && $shouldZoomToLocation
|
||||
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
||||
: center || $getMapCenter
|
||||
);
|
||||
@@ -100,9 +100,7 @@
|
||||
// Force zoom to calculated level when location button is clicked
|
||||
return $getMapZoom;
|
||||
}
|
||||
if ($coordinates && autoCenter) {
|
||||
return $getMapZoom;
|
||||
}
|
||||
// Don't auto-zoom on coordinate updates, keep current zoom
|
||||
return zoom || 13;
|
||||
});
|
||||
|
||||
@@ -139,11 +137,14 @@
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker">
|
||||
<div class="marker-pulse"></div>
|
||||
<div class="marker-outer">
|
||||
<div class="marker-inner"></div>
|
||||
<div class="location-marker" class:watching={$isWatching}>
|
||||
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||
<div class="marker-outer" class:watching={$isWatching}>
|
||||
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||
</div>
|
||||
{#if $isWatching}
|
||||
<div class="watching-ring"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
@@ -175,12 +176,6 @@
|
||||
</Marker>
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
{#if showLocationButton}
|
||||
<div class="location-controls">
|
||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -215,17 +210,9 @@
|
||||
|
||||
.map-container :global(.maplibregl-map) {
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Location marker styles */
|
||||
:global(.location-marker) {
|
||||
width: 24px;
|
||||
@@ -243,6 +230,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-outer.watching) {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
:global(.marker-inner) {
|
||||
@@ -250,6 +244,12 @@
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-inner.watching) {
|
||||
background: #f59e0b;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
@@ -263,6 +263,22 @@
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse.watching) {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
animation: pulse-watching 1.5s infinite;
|
||||
}
|
||||
|
||||
:global(.watching-ring) {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 50%;
|
||||
animation: expand-ring 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
@@ -274,6 +290,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-watching {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expand-ring {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Find marker styles */
|
||||
:global(.find-marker) {
|
||||
width: 40px;
|
||||
@@ -36,17 +36,16 @@
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'autocomplete',
|
||||
query: query.trim()
|
||||
});
|
||||
const searchParams = new URL('/api/places', window.location.origin).searchParams;
|
||||
searchParams.set('action', 'autocomplete');
|
||||
searchParams.set('query', query.trim());
|
||||
|
||||
if ($coordinates) {
|
||||
params.set('lat', $coordinates.latitude.toString());
|
||||
params.set('lng', $coordinates.longitude.toString());
|
||||
searchParams.set('lat', $coordinates.latitude.toString());
|
||||
searchParams.set('lng', $coordinates.longitude.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/places?${params}`);
|
||||
const response = await fetch(`/api/places?${searchParams}`);
|
||||
if (response.ok) {
|
||||
suggestions = await response.json();
|
||||
showSuggestions = true;
|
||||
@@ -179,7 +178,7 @@
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-name">{suggestion.description}</span>
|
||||
<div class="suggestion-types">
|
||||
{#each suggestion.types.slice(0, 2) as type}
|
||||
{#each suggestion.types.slice(0, 2) as type, index (index)}
|
||||
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
3
src/lib/components/map/index.ts
Normal file
3
src/lib/components/map/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LocationManager } from './LocationManager.svelte';
|
||||
export { default as Map } from './Map.svelte';
|
||||
export { default as POISearch } from './POISearch.svelte';
|
||||
1
src/lib/components/media/index.ts
Normal file
1
src/lib/components/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as VideoPlayer } from './VideoPlayer.svelte';
|
||||
194
src/lib/components/notifications/NotificationManager.svelte
Normal file
194
src/lib/components/notifications/NotificationManager.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import NotificationPrompt from './NotificationPrompt.svelte';
|
||||
|
||||
/**
|
||||
* NotificationManager - Handles push notification subscription
|
||||
* Shows a prompt for users to enable notifications (requires user gesture for iOS)
|
||||
*/
|
||||
|
||||
let permissionStatus = $state<NotificationPermission>('default');
|
||||
let showPrompt = $state<boolean>(false);
|
||||
let isSupported = $state<boolean>(false);
|
||||
|
||||
const PROMPT_DISMISSED_KEY = 'notification-prompt-dismissed';
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
// Check if notifications and service workers are supported
|
||||
isSupported = 'Notification' in window && 'serviceWorker' in navigator;
|
||||
|
||||
if (!isSupported) {
|
||||
console.log('Notifications or service workers not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize without requesting permission
|
||||
initializeNotifications();
|
||||
});
|
||||
|
||||
async function initializeNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] Starting initialization...');
|
||||
|
||||
// Get current permission status
|
||||
permissionStatus = Notification.permission;
|
||||
console.log('[NotificationManager] Permission status:', permissionStatus);
|
||||
|
||||
// If already granted, subscribe automatically
|
||||
if (permissionStatus === 'granted') {
|
||||
console.log('[NotificationManager] Permission already granted');
|
||||
await subscribeToNotifications();
|
||||
}
|
||||
// If permission is default and not dismissed, show prompt
|
||||
else if (permissionStatus === 'default') {
|
||||
const dismissed = localStorage.getItem(PROMPT_DISMISSED_KEY);
|
||||
if (!dismissed) {
|
||||
showPrompt = true;
|
||||
}
|
||||
}
|
||||
// If denied, do nothing
|
||||
else {
|
||||
console.log('[NotificationManager] Permission denied by user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error initializing notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnableNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] User clicked enable notifications');
|
||||
showPrompt = false;
|
||||
|
||||
// Request permission (this is triggered by user gesture, so iOS will allow it)
|
||||
permissionStatus = await Notification.requestPermission();
|
||||
console.log('[NotificationManager] Permission response:', permissionStatus);
|
||||
|
||||
if (permissionStatus === 'granted') {
|
||||
await subscribeToNotifications();
|
||||
} else {
|
||||
console.log('[NotificationManager] Permission not granted');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error enabling notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissPrompt() {
|
||||
console.log('[NotificationManager] User dismissed notification prompt');
|
||||
showPrompt = false;
|
||||
localStorage.setItem(PROMPT_DISMISSED_KEY, 'true');
|
||||
}
|
||||
|
||||
async function subscribeToNotifications() {
|
||||
try {
|
||||
console.log('[NotificationManager] subscribeToNotifications called');
|
||||
|
||||
// Get or register service worker
|
||||
let registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
console.log('[NotificationManager] No SW found, registering...');
|
||||
registration = await navigator.serviceWorker.register('/service-worker.js', {
|
||||
type: 'module'
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
console.log('[NotificationManager] Service worker ready');
|
||||
|
||||
// Get VAPID public key from server
|
||||
console.log('[NotificationManager] Fetching VAPID key...');
|
||||
const response = await fetch('/api/notifications/subscribe');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
|
||||
const { publicKey } = await response.json();
|
||||
console.log('[NotificationManager] Got VAPID key:', publicKey);
|
||||
|
||||
// Check if already subscribed
|
||||
console.log('[NotificationManager] Checking existing subscription...');
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
console.log('[NotificationManager] Existing subscription:', subscription);
|
||||
|
||||
// If not subscribed, create new subscription
|
||||
if (!subscription) {
|
||||
console.log('[NotificationManager] Creating new subscription...');
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
console.log('[NotificationManager] Subscription created:', subscription);
|
||||
}
|
||||
|
||||
// Send subscription to server
|
||||
console.log('[NotificationManager] Sending subscription to server...');
|
||||
const saveResponse = await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
|
||||
auth: arrayBufferToBase64(subscription.getKey('auth'))
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[NotificationManager] Save response status:', saveResponse.status);
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text();
|
||||
console.error('[NotificationManager] Save failed:', errorText);
|
||||
throw new Error('Failed to save subscription to server');
|
||||
}
|
||||
|
||||
console.log('[NotificationManager] Successfully subscribed to push notifications!');
|
||||
} catch (error) {
|
||||
console.error('[NotificationManager] Error subscribing to push notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID public key from base64 to Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64 string
|
||||
*/
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
|
||||
if (!buffer) return '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPrompt}
|
||||
<NotificationPrompt onEnable={handleEnableNotifications} onDismiss={handleDismissPrompt} />
|
||||
{/if}
|
||||
173
src/lib/components/notifications/NotificationPrompt.svelte
Normal file
173
src/lib/components/notifications/NotificationPrompt.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { X, Bell } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onEnable: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { onEnable, onDismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="notification-prompt">
|
||||
<div class="notification-prompt-content">
|
||||
<div class="notification-prompt-icon">
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div class="notification-prompt-text">
|
||||
<h3>Enable Notifications</h3>
|
||||
<p>Stay updated when friends like or comment on your finds</p>
|
||||
</div>
|
||||
<div class="notification-prompt-actions">
|
||||
<button class="enable-button" onclick={onEnable}>Enable</button>
|
||||
<button class="dismiss-button" onclick={onDismiss} aria-label="Dismiss notification prompt">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notification-prompt {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.notification-prompt-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-prompt-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enable-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.enable-button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-prompt {
|
||||
top: 70px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.notification-prompt-content {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-prompt-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.notification-prompt-text h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.notification-prompt-text p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
@@ -0,0 +1,613 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface NotificationPreferences {
|
||||
friendRequests: boolean;
|
||||
friendAccepted: boolean;
|
||||
findLiked: boolean;
|
||||
findCommented: boolean;
|
||||
pushEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let preferences = $state<NotificationPreferences>({
|
||||
friendRequests: true,
|
||||
friendAccepted: true,
|
||||
findLiked: true,
|
||||
findCommented: true,
|
||||
pushEnabled: true
|
||||
});
|
||||
|
||||
let isLoading = $state<boolean>(true);
|
||||
let isSaving = $state<boolean>(false);
|
||||
let isSubscribing = $state<boolean>(false);
|
||||
let browserPermission = $state<NotificationPermission>('default');
|
||||
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);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
loadPreferences();
|
||||
checkBrowserPermission();
|
||||
});
|
||||
|
||||
function checkBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
browserPermission = 'denied';
|
||||
return;
|
||||
}
|
||||
browserPermission = Notification.permission;
|
||||
}
|
||||
|
||||
async function requestBrowserPermission() {
|
||||
if (!browser || !('Notification' in window)) {
|
||||
toast.error('Notifications are not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubscribing = true;
|
||||
const permission = await Notification.requestPermission();
|
||||
browserPermission = permission;
|
||||
|
||||
if (permission === 'granted') {
|
||||
// Subscribe to push notifications
|
||||
await subscribeToPush();
|
||||
toast.success('Notifications enabled successfully');
|
||||
} else if (permission === 'denied') {
|
||||
toast.error('Notification permission denied');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
toast.error('Failed to enable notifications');
|
||||
} finally {
|
||||
isSubscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const vapidPublicKey = await fetch('/api/notifications/subscribe').then((r) => r.text());
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
});
|
||||
|
||||
await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
isLoading = true;
|
||||
const response = await fetch('/api/notifications/preferences');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notification preferences');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
preferences = data;
|
||||
} catch (error) {
|
||||
console.error('Error loading notification preferences:', error);
|
||||
toast.error('Failed to load notification preferences');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
try {
|
||||
isSaving = true;
|
||||
const response = await fetch('/api/notifications/preferences', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(preferences)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save notification preferences');
|
||||
}
|
||||
|
||||
toast.success('Notification preferences updated');
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
toast.error('Failed to save notification preferences');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(key: keyof NotificationPreferences) {
|
||||
preferences[key] = !preferences[key];
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
const canTogglePreferences = $derived(browserPermission === 'granted' && preferences.pushEnabled);
|
||||
</script>
|
||||
|
||||
<div class="modal-container" class:mobile={isMobile}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2 class="modal-title">Notification Settings</h2>
|
||||
<p class="modal-subtitle">Manage your notification preferences</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading preferences...</div>
|
||||
{:else}
|
||||
<!-- Browser Permission Banner -->
|
||||
{#if browserPermission !== 'granted'}
|
||||
<div class="permission-banner {browserPermission === 'denied' ? 'denied' : 'default'}">
|
||||
<div class="permission-info">
|
||||
{#if browserPermission === 'denied'}
|
||||
<strong>Browser notifications blocked</strong>
|
||||
<p>
|
||||
Please enable notifications in your browser settings to receive push notifications
|
||||
</p>
|
||||
{:else}
|
||||
<strong>Browser permission required</strong>
|
||||
<p>Enable browser notifications to receive push notifications</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if browserPermission === 'default'}
|
||||
<button
|
||||
class="enable-button"
|
||||
onclick={requestBrowserPermission}
|
||||
disabled={isSubscribing}
|
||||
>
|
||||
{isSubscribing ? 'Enabling...' : 'Enable'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-list">
|
||||
<!-- Push Notifications Toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Push Notifications</h3>
|
||||
<p>Enable or disable all push notifications</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.pushEnabled}
|
||||
onchange={() => handleToggle('pushEnabled')}
|
||||
disabled={isSaving || browserPermission !== 'granted'}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Friend Requests -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Requests</h3>
|
||||
<p>Get notified when someone sends you a friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendRequests}
|
||||
onchange={() => handleToggle('friendRequests')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Friend Accepted -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Friend Request Accepted</h3>
|
||||
<p>Get notified when someone accepts your friend request</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.friendAccepted}
|
||||
onchange={() => handleToggle('friendAccepted')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Liked -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Likes</h3>
|
||||
<p>Get notified when someone likes your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findLiked}
|
||||
onchange={() => handleToggle('findLiked')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Find Commented -->
|
||||
<div class="setting-item" class:disabled={!canTogglePreferences}>
|
||||
<div class="setting-info">
|
||||
<h3>Find Comments</h3>
|
||||
<p>Get notified when someone comments on your find</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.findCommented}
|
||||
onchange={() => handleToggle('findCommented')}
|
||||
disabled={isSaving || !canTogglePreferences}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: 500px;
|
||||
min-width: 380px;
|
||||
max-height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container.mobile {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.permission-banner {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-banner.denied {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-info strong {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.enable-button {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.enable-button:hover:not(:disabled) {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.enable-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.setting-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: hsl(var(--border));
|
||||
margin: 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.toggle input:disabled + .toggle-slider {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Mobile specificadjust ments */
|
||||
@media (max-width: 767px) {
|
||||
.modal-container {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/lib/components/notifications/index.ts
Normal file
3
src/lib/components/notifications/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as NotificationManager } from './NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './NotificationSettings.svelte';
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||
import { Skeleton } from './skeleton';
|
||||
} from '../dropdown-menu';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||
import NotificationSettings from '../notifications/NotificationSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
@@ -21,9 +23,7 @@
|
||||
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();
|
||||
let showNotificationSettings = $state(false);
|
||||
|
||||
function openProfilePictureSheet() {
|
||||
showProfilePictureSheet = true;
|
||||
@@ -32,22 +32,19 @@
|
||||
function closeProfilePictureSheet() {
|
||||
showProfilePictureSheet = false;
|
||||
}
|
||||
|
||||
function openNotificationSettings() {
|
||||
showNotificationSettings = true;
|
||||
}
|
||||
|
||||
function closeNotificationSettings() {
|
||||
showNotificationSettings = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="profile-trigger">
|
||||
{#if loading}
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<Avatar class="profile-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="profile-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
||||
@@ -85,7 +82,11 @@
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="friends-item">
|
||||
<a href="/friends" class="friends-link">Friends</a>
|
||||
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
@@ -120,6 +121,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showNotificationSettings}
|
||||
<NotificationSettings onClose={closeNotificationSettings} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-trigger) {
|
||||
background: none;
|
||||
@@ -144,15 +149,6 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:global(.profile-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.profile-dropdown-content) {
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
@@ -211,6 +207,16 @@
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item) {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.notification-settings-item:hover) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.friends-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
59
src/lib/components/profile/ProfilePicture.svelte
Normal file
59
src/lib/components/profile/ProfilePicture.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
profilePictureUrl?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
class?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
username,
|
||||
profilePictureUrl,
|
||||
size = 'md',
|
||||
class: className,
|
||||
loading = false
|
||||
}: Props = $props();
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-16 w-16',
|
||||
xl: 'h-24 w-24'
|
||||
};
|
||||
|
||||
const fallbackSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
lg: 'text-xl',
|
||||
xl: 'text-3xl'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class={cn('animate-pulse rounded-full bg-gray-200', sizeClasses[size], className)}></div>
|
||||
{:else}
|
||||
<Avatar class={cn(sizeClasses[size], className)}>
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class={cn('profile-picture-fallback', fallbackSizeClasses[size])}>
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.profile-picture-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<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';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
|
||||
import { Button } from '../button';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
@@ -19,8 +19,6 @@
|
||||
let isDeleting = $state(false);
|
||||
let showModal = $state(true);
|
||||
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
// Close modal when showModal changes to false
|
||||
$effect(() => {
|
||||
if (!showModal && onClose) {
|
||||
@@ -118,14 +116,7 @@
|
||||
|
||||
<div class="profile-picture-content">
|
||||
<div class="current-avatar">
|
||||
<Avatar class="large-avatar">
|
||||
{#if profilePictureUrl}
|
||||
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||
{/if}
|
||||
<AvatarFallback class="large-avatar-fallback">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
@@ -190,15 +181,6 @@
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
:global(.large-avatar-fallback) {
|
||||
background: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
3
src/lib/components/profile/index.ts
Normal file
3
src/lib/components/profile/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ProfilePanel } from './ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './ProfilePictureSheet.svelte';
|
||||
@@ -1,7 +1,7 @@
|
||||
<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',
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] 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',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
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',
|
||||
'fixed inset-0 z-[9998] 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}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
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 ProfilePanel } from './components/profile/ProfilePanel.svelte';
|
||||
export { default as ProfilePicture } from './components/profile/ProfilePicture.svelte';
|
||||
export { default as ProfilePictureSheet } from './components/profile/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';
|
||||
export { default as Map } from './components/map/Map.svelte';
|
||||
export { default as LocationManager } from './components/map/LocationManager.svelte';
|
||||
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
|
||||
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
|
||||
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
|
||||
export { default as FindCard } from './components/finds/FindCard.svelte';
|
||||
export { default as FindsList } from './components/finds/FindsList.svelte';
|
||||
|
||||
// Skeleton Loading Components
|
||||
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
locationError,
|
||||
isLocationLoading,
|
||||
hasLocationAccess,
|
||||
isWatching,
|
||||
getMapCenter,
|
||||
getMapZoom
|
||||
} from './stores/location';
|
||||
|
||||
@@ -4,7 +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';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
// Generate signed URL for profile picture if it exists
|
||||
// Generate local proxy 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;
|
||||
}
|
||||
// It's a path, generate local proxy URL
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -75,13 +75,75 @@ export const friendship = pgTable('friendship', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the new tables
|
||||
export const findComment = pgTable('find_comment', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Notification system tables
|
||||
export const notification = pgTable('notification', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||
title: text('title').notNull(),
|
||||
message: text('message').notNull(),
|
||||
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||
isRead: boolean('is_read').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationSubscription = pgTable('notification_subscription', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
p256dhKey: text('p256dh_key').notNull(),
|
||||
authKey: text('auth_key').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
isActive: boolean('is_active').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendRequests: boolean('friend_requests').default(true),
|
||||
friendAccepted: boolean('friend_accepted').default(true),
|
||||
findLiked: boolean('find_liked').default(true),
|
||||
findCommented: boolean('find_commented').default(true),
|
||||
pushEnabled: boolean('push_enabled').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the tables
|
||||
export type Find = typeof find.$inferSelect;
|
||||
export type FindMedia = typeof findMedia.$inferSelect;
|
||||
export type FindLike = typeof findLike.$inferSelect;
|
||||
export type FindComment = typeof findComment.$inferSelect;
|
||||
export type Friendship = typeof friendship.$inferSelect;
|
||||
export type Notification = typeof notification.$inferSelect;
|
||||
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||
|
||||
export type FindInsert = typeof find.$inferInsert;
|
||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||
export type NotificationInsert = typeof notification.$inferInsert;
|
||||
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||
|
||||
160
src/lib/server/notifications.ts
Normal file
160
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { db } from './db';
|
||||
import { notification, notificationPreferences } from './db/schema';
|
||||
import type { NotificationInsert } from './db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type NotificationType =
|
||||
| 'friend_request'
|
||||
| 'friend_accepted'
|
||||
| 'find_liked'
|
||||
| 'find_commented';
|
||||
|
||||
export interface CreateNotificationData {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetNotificationsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Create a new notification record in the database
|
||||
*/
|
||||
async createNotification(data: CreateNotificationData): Promise<void> {
|
||||
const notificationData: NotificationInsert = {
|
||||
id: nanoid(),
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
data: data.data || null,
|
||||
isRead: false
|
||||
};
|
||||
|
||||
await db.insert(notification).values(notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has notifications enabled for a specific type
|
||||
*/
|
||||
async shouldNotify(userId: string, type: NotificationType): Promise<boolean> {
|
||||
const prefs = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, default to true
|
||||
if (prefs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pref = prefs[0];
|
||||
|
||||
// Check if push is enabled and specific notification type is enabled
|
||||
if (!pref.pushEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'friend_request':
|
||||
return pref.friendRequests ?? true;
|
||||
case 'friend_accepted':
|
||||
return pref.friendAccepted ?? true;
|
||||
case 'find_liked':
|
||||
return pref.findLiked ?? true;
|
||||
case 'find_commented':
|
||||
return pref.findCommented ?? true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user notifications with pagination and filtering
|
||||
*/
|
||||
async getUserNotifications(userId: string, options: GetNotificationsOptions = {}) {
|
||||
const { limit = 20, offset = 0, unreadOnly = false } = options;
|
||||
|
||||
let query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(eq(notification.userId, userId))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (unreadOnly) {
|
||||
query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
*/
|
||||
async markAsRead(notificationIds: string[]): Promise<void> {
|
||||
for (const id of notificationIds) {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
async markOneAsRead(notificationId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.id, notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await db.update(notification).set({ isRead: true }).where(eq(notification.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const notifications = await db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)));
|
||||
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(notification)
|
||||
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all notifications for a user
|
||||
*/
|
||||
async deleteAllNotifications(userId: string): Promise<void> {
|
||||
await db.delete(notification).where(eq(notification.userId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
185
src/lib/server/push.ts
Normal file
185
src/lib/server/push.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import webpush from 'web-push';
|
||||
import { db } from './db';
|
||||
import { notificationSubscription } from './db/schema';
|
||||
import type { NotificationSubscriptionInsert } from './db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from '$env/static/private';
|
||||
|
||||
// Initialize web-push with VAPID keys
|
||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY || !VAPID_SUBJECT) {
|
||||
throw new Error(
|
||||
'VAPID keys are not configured. Please set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
tag?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class PushService {
|
||||
/**
|
||||
* Save a push subscription for a user
|
||||
*/
|
||||
async saveSubscription(
|
||||
userId: string,
|
||||
subscription: PushSubscriptionData,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
const subscriptionData: NotificationSubscriptionInsert = {
|
||||
id: nanoid(),
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
// Check if subscription already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing subscription
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent: userAgent || null,
|
||||
isActive: true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(notificationSubscription.endpoint, subscription.endpoint));
|
||||
} else {
|
||||
// Insert new subscription
|
||||
await db.insert(notificationSubscription).values(subscriptionData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a push subscription
|
||||
*/
|
||||
async removeSubscription(userId: string, endpoint: string): Promise<void> {
|
||||
await db
|
||||
.delete(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.endpoint, endpoint)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active subscriptions for a user
|
||||
*/
|
||||
async getUserSubscriptions(userId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(notificationSubscription)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSubscription.userId, userId),
|
||||
eq(notificationSubscription.isActive, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to a user's subscriptions
|
||||
*/
|
||||
async sendPushNotification(userId: string, payload: PushNotificationPayload): Promise<void> {
|
||||
const subscriptions = await this.getUserSubscriptions(userId);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
console.log(`No active subscriptions found for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationPayload = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.message,
|
||||
icon: payload.icon || '/logo.svg',
|
||||
badge: payload.badge || '/logo.svg',
|
||||
tag: payload.tag,
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
...payload.data
|
||||
}
|
||||
});
|
||||
|
||||
const sendPromises = subscriptions.map(async (sub) => {
|
||||
const pushSubscription: webpush.PushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dhKey,
|
||||
auth: sub.authKey
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(pushSubscription, notificationPayload);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send push notification to ${sub.endpoint}:`, error);
|
||||
|
||||
// If the subscription is invalid (410 Gone or 404), deactivate it
|
||||
if (error instanceof Error && 'statusCode' in error) {
|
||||
const statusCode = (error as { statusCode: number }).statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.id, sub.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to multiple users
|
||||
*/
|
||||
async sendPushNotificationToUsers(
|
||||
userIds: string[],
|
||||
payload: PushNotificationPayload
|
||||
): Promise<void> {
|
||||
const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload));
|
||||
await Promise.all(sendPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a subscription
|
||||
*/
|
||||
async deactivateSubscription(endpoint: string): Promise<void> {
|
||||
await db
|
||||
.update(notificationSubscription)
|
||||
.set({ isActive: false })
|
||||
.where(eq(notificationSubscription.endpoint, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
export const pushService = new PushService();
|
||||
export { VAPID_PUBLIC_KEY };
|
||||
@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
export function getLocalR2Url(path: string): string {
|
||||
return `/api/media/${path}`;
|
||||
}
|
||||
|
||||
727
src/lib/stores/api-sync.ts
Normal file
727
src/lib/stores/api-sync.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
import { writable, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Core types for the API sync system
|
||||
export interface EntityState<T = unknown> {
|
||||
data: T;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface QueuedOperation {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
action?: string;
|
||||
data?: unknown;
|
||||
retry: number;
|
||||
maxRetries: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Specific entity state types
|
||||
export interface FindLikeState {
|
||||
isLiked: boolean;
|
||||
likeCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FindState {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
locationName?: string;
|
||||
category?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
media?: Array<{
|
||||
id: string;
|
||||
findId: string;
|
||||
type: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
orderIndex: number | null;
|
||||
}>;
|
||||
isLikedByUser: boolean;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isFromFriend: boolean;
|
||||
}
|
||||
|
||||
export interface CommentState {
|
||||
id: string;
|
||||
findId: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
profilePictureUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FindCommentsState {
|
||||
comments: CommentState[];
|
||||
commentCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Generate unique operation IDs
|
||||
function generateOperationId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Create operation key for deduplication
|
||||
function createOperationKey(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: string,
|
||||
action?: string
|
||||
): string {
|
||||
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
|
||||
}
|
||||
|
||||
class APISync {
|
||||
// Entity stores - each entity type has its own store
|
||||
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
|
||||
|
||||
// Operation queue for API calls
|
||||
private operationQueue = new Map<string, QueuedOperation>();
|
||||
private processingQueue = false;
|
||||
|
||||
// Cleanup tracking for memory management
|
||||
private subscriptions = new Map<string, Set<() => void>>();
|
||||
|
||||
constructor() {
|
||||
// Initialize core entity stores
|
||||
this.initializeEntityStore('find');
|
||||
this.initializeEntityStore('user');
|
||||
this.initializeEntityStore('friendship');
|
||||
this.initializeEntityStore('comment');
|
||||
|
||||
// Start processing queue
|
||||
this.startQueueProcessor();
|
||||
}
|
||||
|
||||
private initializeEntityStore(entityType: string): void {
|
||||
if (!this.entityStores.has(entityType)) {
|
||||
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
|
||||
}
|
||||
}
|
||||
|
||||
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
|
||||
this.initializeEntityStore(entityType);
|
||||
return this.entityStores.get(entityType)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific entity's state
|
||||
*/
|
||||
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (!entity) {
|
||||
// Return default state if entity doesn't exist
|
||||
return {
|
||||
data: null as T,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
}
|
||||
return entity as EntityState<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe specifically to find like state
|
||||
*/
|
||||
subscribeFindLikes(findId: string): Readable<FindLikeState> {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
isLiked: false,
|
||||
likeCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const findData = entity.data as FindState;
|
||||
return {
|
||||
isLiked: findData.isLikedByUser,
|
||||
likeCount: findData.likeCount,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to find comments state
|
||||
*/
|
||||
subscribeFindComments(findId: string): Readable<FindCommentsState> {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
return derived(store, ($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (!entity || !entity.data) {
|
||||
return {
|
||||
comments: [],
|
||||
commentCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const commentsData = entity.data as CommentState[];
|
||||
return {
|
||||
comments: commentsData,
|
||||
commentCount: commentsData.length,
|
||||
isLoading: entity.isLoading,
|
||||
error: entity.error
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity state exists
|
||||
*/
|
||||
hasEntityState(entityType: string, entityId: string): boolean {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let exists = false;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
exists = $entities.has(entityId);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current entity state
|
||||
*/
|
||||
getEntityState<T>(entityType: string, entityId: string): T | null {
|
||||
const store = this.getEntityStore(entityType);
|
||||
let currentState: T | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(entityId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as T;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state with server data (only if no existing state)
|
||||
*/
|
||||
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
newEntities.set(entityId, {
|
||||
data,
|
||||
isLoading,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize entity state only if it doesn't exist yet
|
||||
*/
|
||||
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||
if (!this.hasEntityState(entityType, entityId)) {
|
||||
this.setEntityState(entityType, entityId, data, isLoading);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity loading state
|
||||
*/
|
||||
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity error state
|
||||
*/
|
||||
private setEntityError(entityType: string, entityId: string, error: string): void {
|
||||
const store = this.getEntityStore(entityType);
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(entityId);
|
||||
if (existing) {
|
||||
newEntities.set(entityId, {
|
||||
...existing,
|
||||
isLoading: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an operation for processing
|
||||
*/
|
||||
async queueOperation(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
action?: string,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const operationKey = createOperationKey(entityType, entityId, operation, action);
|
||||
|
||||
// Check if same operation is already queued
|
||||
if (this.operationQueue.has(operationKey)) {
|
||||
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedOperation: QueuedOperation = {
|
||||
id: generateOperationId(),
|
||||
entityType,
|
||||
entityId,
|
||||
operation,
|
||||
action,
|
||||
data,
|
||||
retry: 0,
|
||||
maxRetries: 3,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.operationQueue.set(operationKey, queuedOperation);
|
||||
|
||||
// Set entity to loading state
|
||||
this.setEntityLoading(entityType, entityId, true);
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the operation queue
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processingQueue || this.operationQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingQueue = true;
|
||||
|
||||
const operations = Array.from(this.operationQueue.entries());
|
||||
|
||||
for (const [operationKey, operation] of operations) {
|
||||
try {
|
||||
await this.executeOperation(operation);
|
||||
this.operationQueue.delete(operationKey);
|
||||
} catch (error) {
|
||||
console.error(`Operation ${operationKey} failed:`, error);
|
||||
|
||||
if (operation.retry < operation.maxRetries) {
|
||||
operation.retry++;
|
||||
console.log(
|
||||
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
|
||||
);
|
||||
} else {
|
||||
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
|
||||
this.operationQueue.delete(operationKey);
|
||||
this.setEntityError(
|
||||
operation.entityType,
|
||||
operation.entityId,
|
||||
'Operation failed after multiple retries'
|
||||
);
|
||||
toast.error('Failed to sync changes. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processingQueue = false;
|
||||
|
||||
// If more operations were added while processing, process again
|
||||
if (this.operationQueue.size > 0) {
|
||||
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific operation
|
||||
*/
|
||||
private async executeOperation(operation: QueuedOperation): Promise<void> {
|
||||
const { entityType, entityId, operation: op, action, data } = operation;
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
// Handle like operations
|
||||
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
// Handle comment creation
|
||||
response = await fetch(`/api/finds/${entityId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
// Handle comment deletion
|
||||
response = await fetch(`/api/finds/comments/${entityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update entity state with successful result
|
||||
if (entityType === 'find' && action === 'like') {
|
||||
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||
} else if (entityType === 'comment' && op === 'create') {
|
||||
this.addCommentToState(result.data.findId, result.data);
|
||||
} else if (entityType === 'comment' && op === 'delete') {
|
||||
this.removeCommentFromState(entityId, data as { findId: string });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find like state after successful API call
|
||||
*/
|
||||
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: isLiked,
|
||||
likeCount: likeCount
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the queue processor
|
||||
*/
|
||||
private startQueueProcessor(): void {
|
||||
// Process queue every 100ms
|
||||
setInterval(() => {
|
||||
if (this.operationQueue.size > 0 && !this.processingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle like for a find
|
||||
*/
|
||||
async toggleLike(findId: string): Promise<void> {
|
||||
// Get current state for optimistic update
|
||||
const store = this.getEntityStore('find');
|
||||
let currentState: FindState | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe(($entities) => {
|
||||
const entity = $entities.get(findId);
|
||||
if (entity?.data) {
|
||||
currentState = entity.data as FindState;
|
||||
}
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
if (!currentState) {
|
||||
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const findState = currentState as FindState;
|
||||
const newIsLiked = !findState.isLikedByUser;
|
||||
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
|
||||
|
||||
// Update state optimistically
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
isLikedByUser: newIsLiked,
|
||||
likeCount: newLikeCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment to find comments state
|
||||
*/
|
||||
private addCommentToState(findId: string, comment: CommentState): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
const comments = existing?.data ? (existing.data as CommentState[]) : [];
|
||||
|
||||
// If this is a real comment from server, remove any temporary comment with the same content
|
||||
let updatedComments = comments;
|
||||
if (!comment.id.startsWith('temp-')) {
|
||||
updatedComments = comments.filter(
|
||||
(c) => !(c.id.startsWith('temp-') && c.content === comment.content)
|
||||
);
|
||||
}
|
||||
|
||||
updatedComments = [comment, ...updatedComments];
|
||||
|
||||
newEntities.set(findId, {
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count only for temp comments (optimistic updates)
|
||||
if (comment.id.startsWith('temp-')) {
|
||||
this.updateFindCommentCount(findId, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment from find comments state
|
||||
*/
|
||||
private removeCommentFromState(commentId: string, data: { findId: string }): void {
|
||||
const store = this.getEntityStore('comment');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(data.findId);
|
||||
|
||||
if (existing?.data) {
|
||||
const comments = existing.data as CommentState[];
|
||||
const updatedComments = comments.filter((c) => c.id !== commentId);
|
||||
|
||||
newEntities.set(data.findId, {
|
||||
...existing,
|
||||
data: updatedComments,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
|
||||
// Update find comment count
|
||||
this.updateFindCommentCount(data.findId, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update find comment count
|
||||
*/
|
||||
private updateFindCommentCount(findId: string, delta: number): void {
|
||||
const store = this.getEntityStore('find');
|
||||
|
||||
store.update(($entities) => {
|
||||
const newEntities = new Map($entities);
|
||||
const existing = newEntities.get(findId);
|
||||
|
||||
if (existing && existing.data) {
|
||||
const findData = existing.data as FindState;
|
||||
newEntities.set(findId, {
|
||||
...existing,
|
||||
data: {
|
||||
...findData,
|
||||
commentCount: Math.max(0, findData.commentCount + delta)
|
||||
},
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return newEntities;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load comments for a find
|
||||
*/
|
||||
async loadComments(findId: string): Promise<void> {
|
||||
if (this.hasEntityState('comment', findId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setEntityLoading('comment', findId, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/finds/${findId}/comments`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.setEntityState('comment', findId, result.data, false);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load comments');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading comments:', error);
|
||||
this.setEntityError('comment', findId, 'Failed to load comments');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a find
|
||||
*/
|
||||
async addComment(findId: string, content: string): Promise<void> {
|
||||
// Optimistic update: add temporary comment
|
||||
const tempComment: CommentState = {
|
||||
id: `temp-${Date.now()}`,
|
||||
findId,
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
user: {
|
||||
id: 'current-user',
|
||||
username: 'You',
|
||||
profilePictureUrl: undefined
|
||||
}
|
||||
};
|
||||
|
||||
this.addCommentToState(findId, tempComment);
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', findId, 'create', undefined, { content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
*/
|
||||
async deleteComment(commentId: string, findId: string): Promise<void> {
|
||||
// Optimistic update: remove comment
|
||||
this.removeCommentFromState(commentId, { findId });
|
||||
|
||||
// Queue the operation
|
||||
await this.queueOperation('comment', commentId, 'delete', undefined, { findId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize find data from server (only if no existing state)
|
||||
*/
|
||||
initializeFindData(finds: FindState[]): void {
|
||||
for (const find of finds) {
|
||||
this.initializeEntityState('find', find.id, find);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize comments data for a find
|
||||
*/
|
||||
initializeCommentsData(findId: string, comments: CommentState[]): void {
|
||||
this.initializeEntityState('comment', findId, comments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup unused subscriptions (call this when components unmount)
|
||||
*/
|
||||
cleanup(entityType: string, entityId: string): void {
|
||||
const key = `${entityType}:${entityId}`;
|
||||
const subscriptions = this.subscriptions.get(key);
|
||||
if (subscriptions) {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiSync = new APISync();
|
||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
||||
locationStore,
|
||||
($location) => $location.shouldZoomToLocation
|
||||
);
|
||||
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||
|
||||
// Location actions
|
||||
export const locationActions = {
|
||||
|
||||
@@ -5,11 +5,22 @@
|
||||
import { page } from '$app/state';
|
||||
import { Toaster } from '$lib/components/sonner/index.js';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import LocationManager from '$lib/components/map/LocationManager.svelte';
|
||||
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let { children, data } = $props();
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Handle loading state only on client to prevent hydration mismatch
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
isLoading = !isLoginRoute && !data?.user && data !== null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -32,6 +43,12 @@
|
||||
|
||||
<Toaster />
|
||||
|
||||
<!-- Auto-start location and notfication watching for authenticated users -->
|
||||
{#if data?.user && !isLoginRoute}
|
||||
<LocationManager autoStart={true} />
|
||||
<NotificationManager />
|
||||
{/if}
|
||||
|
||||
{#if showHeader && data.user}
|
||||
<Header user={data.user} />
|
||||
{:else if isLoading}
|
||||
@@ -47,12 +64,12 @@
|
||||
|
||||
<style>
|
||||
.header-skeleton {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
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);
|
||||
|
||||
@@ -17,8 +12,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||
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
|
||||
|
||||
// Only include private and friends' finds if user is logged in
|
||||
if (locals.user) {
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<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 FindsList from '$lib/components/finds/FindsList.svelte';
|
||||
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
|
||||
import FindPreview from '$lib/components/finds/FindPreview.svelte';
|
||||
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||
|
||||
// Server response type
|
||||
interface ServerFind {
|
||||
@@ -91,6 +94,34 @@
|
||||
let showCreateModal = $state(false);
|
||||
let selectedFind: FindPreviewData | null = $state(null);
|
||||
let currentFilter = $state('all');
|
||||
let isSidebarVisible = $state(true);
|
||||
|
||||
// Initialize API sync with server data on mount
|
||||
onMount(async () => {
|
||||
if (browser && data.finds && data.finds.length > 0) {
|
||||
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
||||
id: serverFind.id,
|
||||
title: serverFind.title,
|
||||
description: serverFind.description,
|
||||
latitude: serverFind.latitude,
|
||||
longitude: serverFind.longitude,
|
||||
locationName: serverFind.locationName,
|
||||
category: serverFind.category,
|
||||
isPublic: Boolean(serverFind.isPublic),
|
||||
createdAt: new Date(serverFind.createdAt),
|
||||
userId: serverFind.userId,
|
||||
username: serverFind.username,
|
||||
profilePictureUrl: serverFind.profilePictureUrl || undefined,
|
||||
media: serverFind.media,
|
||||
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
||||
likeCount: serverFind.likeCount || 0,
|
||||
commentCount: 0,
|
||||
isFromFriend: Boolean(serverFind.isFromFriend)
|
||||
}));
|
||||
|
||||
apiSync.initializeFindData(findStates);
|
||||
}
|
||||
});
|
||||
|
||||
// All finds - convert server format to component format
|
||||
let allFinds = $derived(
|
||||
@@ -185,6 +216,10 @@
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
isSidebarVisible = !isSidebarVisible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -208,24 +243,23 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<main class="main-content">
|
||||
<div class="map-section">
|
||||
<Map
|
||||
showLocationButton={true}
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||
{finds}
|
||||
onFindClick={handleFindClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="finds-section">
|
||||
<div class="finds-sticky-header">
|
||||
<div class="finds-header-content">
|
||||
<div class="finds-title-section">
|
||||
<h2 class="finds-title">Finds</h2>
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
<!-- Sidebar container -->
|
||||
<div class="sidebar-container">
|
||||
<!-- Left sidebar with finds list -->
|
||||
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||
<div class="finds-header">
|
||||
{#if data.user}
|
||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||
<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" />
|
||||
@@ -233,19 +267,34 @@
|
||||
</svg>
|
||||
Create Find
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-prompt">
|
||||
<h3>Welcome to Serengo</h3>
|
||||
<p>Login to create finds and view your friends' discoveries</p>
|
||||
<a href="/login" class="login-button">Login</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="finds-list-container">
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
</div>
|
||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||
</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>
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
class:collapsed={!isSidebarVisible}
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Toggle finds list"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isSidebarVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
@@ -258,150 +307,198 @@
|
||||
{/if}
|
||||
|
||||
{#if selectedFind}
|
||||
<FindPreview find={selectedFind} onClose={closeFindPreview} />
|
||||
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 500px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.finds-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
position: sticky;
|
||||
.map-section {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: white;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 24px 24px 16px 24px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
.login-prompt {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
.login-prompt h3 {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.finds-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 16px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.finds-header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title-section {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finds-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.create-find-button) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
.sidebar-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle.collapsed {
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.finds-sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.finds-sidebar.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.finds-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 300px;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
.finds-sidebar {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.finds-sticky-header {
|
||||
padding: 12px 12px 8px 12px;
|
||||
.finds-header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateFindId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -12,10 +12,6 @@ function generateFindId(): string {
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -25,9 +21,9 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||
|
||||
try {
|
||||
// Get user's friends if needed
|
||||
// Get user's friends if needed and user is logged in
|
||||
let friendIds: string[] = [];
|
||||
if (includeFriends || includePrivate) {
|
||||
if (locals.user && (includeFriends || includePrivate)) {
|
||||
const friendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
@@ -37,7 +33,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, 'accepted'),
|
||||
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -47,12 +43,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// Build privacy conditions
|
||||
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||
|
||||
if (includePrivate) {
|
||||
if (locals.user && includePrivate) {
|
||||
// Include user's own finds (both public and private)
|
||||
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
|
||||
conditions.push(sql`${find.userId} = ${locals.user.id}`);
|
||||
}
|
||||
|
||||
if (includeFriends && friendIds.length > 0) {
|
||||
if (locals.user && includeFriends && friendIds.length > 0) {
|
||||
// Include friends' finds (both public and private)
|
||||
conditions.push(
|
||||
sql`${find.userId} IN (${sql.join(
|
||||
@@ -103,19 +99,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl,
|
||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
|
||||
SELECT 1 FROM ${findLike}
|
||||
WHERE ${findLike.findId} = ${find.id}
|
||||
AND ${findLike.userId} = ${locals.user.id}
|
||||
) THEN 1 ELSE 0 END`,
|
||||
isFromFriend: sql<boolean>`CASE WHEN ${
|
||||
friendIds.length > 0
|
||||
? sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
: sql`FALSE`
|
||||
} THEN 1 ELSE 0 END`
|
||||
isLikedByUser: locals.user
|
||||
? 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`
|
||||
: sql<boolean>`0`,
|
||||
isFromFriend: locals.user
|
||||
? sql<boolean>`CASE WHEN ${
|
||||
friendIds.length > 0
|
||||
? sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
: sql`FALSE`
|
||||
} THEN 1 ELSE 0 END`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
@@ -176,31 +176,25 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
// 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
|
||||
// URLs in database are now paths, generate local proxy URLs
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
||||
]);
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: signedUrl,
|
||||
thumbnailUrl: signedThumbnailUrl
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate signed URL for user profile picture if it exists
|
||||
// Generate local proxy 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;
|
||||
}
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
115
src/routes/api/finds/[findId]/+server.ts
Normal file
115
src/routes/api/finds/[findId]/+server.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the find with user info and like count
|
||||
const findResult = 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)`,
|
||||
commentCount: sql<number>`COALESCE((
|
||||
SELECT COUNT(*) FROM ${findComment}
|
||||
WHERE ${findComment.findId} = ${find.id}
|
||||
), 0)`,
|
||||
isLikedByUser: locals.user
|
||||
? 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`
|
||||
: sql<boolean>`0`
|
||||
})
|
||||
.from(find)
|
||||
.innerJoin(user, eq(find.userId, user.id))
|
||||
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||
.where(eq(find.id, findId))
|
||||
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||
.limit(1);
|
||||
|
||||
if (findResult.length === 0) {
|
||||
throw error(404, 'Find not found');
|
||||
}
|
||||
|
||||
const findData = findResult[0];
|
||||
|
||||
// Check if the find is public or if user has access
|
||||
const isOwner = locals.user && findData.userId === locals.user.id;
|
||||
const isPublic = findData.isPublic === 1;
|
||||
|
||||
if (!isPublic && !isOwner) {
|
||||
throw error(403, 'This find is private');
|
||||
}
|
||||
|
||||
// Get media for the find
|
||||
const 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(eq(findMedia.findId, findId))
|
||||
.orderBy(findMedia.orderIndex);
|
||||
|
||||
// Generate signed URLs for media
|
||||
const mediaWithSignedUrls = await Promise.all(
|
||||
media.map(async (mediaItem) => {
|
||||
const localUrl = getLocalR2Url(mediaItem.url);
|
||||
const localThumbnailUrl =
|
||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||
: mediaItem.thumbnailUrl;
|
||||
|
||||
return {
|
||||
...mediaItem,
|
||||
url: localUrl,
|
||||
thumbnailUrl: localThumbnailUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate local proxy URL for user profile picture
|
||||
let userProfilePictureUrl = findData.profilePictureUrl;
|
||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||
}
|
||||
|
||||
return json({
|
||||
...findData,
|
||||
profilePictureUrl: userProfilePictureUrl,
|
||||
media: mediaWithSignedUrls,
|
||||
isLikedByUser: Boolean(findData.isLikedByUser)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to load find');
|
||||
}
|
||||
};
|
||||
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findComment, user, find } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
if (!findId) {
|
||||
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const comments = await db
|
||||
.select({
|
||||
id: findComment.id,
|
||||
findId: findComment.findId,
|
||||
content: findComment.content,
|
||||
createdAt: findComment.createdAt,
|
||||
updatedAt: findComment.updatedAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
}
|
||||
})
|
||||
.from(findComment)
|
||||
.innerJoin(user, eq(findComment.userId, user.id))
|
||||
.where(eq(findComment.findId, findId))
|
||||
.orderBy(desc(findComment.createdAt));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: comments,
|
||||
count: comments.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
return json({ success: false, error: 'Failed to fetch comments' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals, request }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const findId = params.findId;
|
||||
if (!findId) {
|
||||
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { content } = body;
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return json({ success: false, error: 'Comment content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (content.length > 500) {
|
||||
return json(
|
||||
{ success: false, error: 'Comment too long (max 500 characters)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const commentId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(findComment)
|
||||
.values({
|
||||
id: commentId,
|
||||
findId,
|
||||
userId: session.userId,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
const commentWithUser = await db
|
||||
.select({
|
||||
id: findComment.id,
|
||||
findId: findComment.findId,
|
||||
content: findComment.content,
|
||||
createdAt: findComment.createdAt,
|
||||
updatedAt: findComment.updatedAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
}
|
||||
})
|
||||
.from(findComment)
|
||||
.innerJoin(user, eq(findComment.userId, user.id))
|
||||
.where(eq(findComment.id, commentId))
|
||||
.limit(1);
|
||||
|
||||
if (commentWithUser.length === 0) {
|
||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Send notification to find owner if not self-comment
|
||||
const findData = await db.select().from(find).where(eq(find.id, findId)).limit(1);
|
||||
|
||||
if (findData.length > 0 && findData[0].userId !== session.userId) {
|
||||
const findOwner = findData[0];
|
||||
const shouldNotify = await notificationService.shouldNotify(
|
||||
findOwner.userId,
|
||||
'find_commented'
|
||||
);
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get commenter's username
|
||||
const commenterUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
const commenterUsername = commenterUser[0]?.username || 'Someone';
|
||||
const findTitle = findOwner.title || 'your find';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: findOwner.userId,
|
||||
type: 'find_commented',
|
||||
title: 'New comment on your find',
|
||||
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
commentId,
|
||||
commenterId: session.userId,
|
||||
commenterUsername,
|
||||
findTitle,
|
||||
commentContent: content.trim().substring(0, 100)
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(findOwner.userId, {
|
||||
title: 'New comment on your find',
|
||||
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||
url: `/?find=${findOwner.id}`,
|
||||
tag: 'find_commented',
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
commentId,
|
||||
commenterId: session.userId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: commentWithUser[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating comment:', error);
|
||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findLike, find } from '$lib/server/db/schema';
|
||||
import { findLike, find, user } from '$lib/server/db/schema';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
function generateLikeId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -59,6 +61,49 @@ export async function POST({
|
||||
|
||||
const likeCount = likeCountResult[0]?.count ?? 0;
|
||||
|
||||
// Send notification to find owner if not self-like
|
||||
const findOwner = existingFind[0];
|
||||
if (findOwner.userId !== locals.user.id) {
|
||||
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_liked');
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get liker's username
|
||||
const likerUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
const likerUsername = likerUser[0]?.username || 'Someone';
|
||||
const findTitle = findOwner.title || 'your find';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: findOwner.userId,
|
||||
type: 'find_liked',
|
||||
title: 'Someone liked your find',
|
||||
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
likerId: locals.user.id,
|
||||
likerUsername,
|
||||
findTitle
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(findOwner.userId, {
|
||||
title: 'Someone liked your find',
|
||||
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||
url: `/?find=${findOwner.id}`,
|
||||
tag: 'find_liked',
|
||||
data: {
|
||||
findId: findOwner.id,
|
||||
likerId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
likeId,
|
||||
|
||||
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { findComment } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const session = locals.session;
|
||||
if (!session) {
|
||||
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const commentId = params.commentId;
|
||||
if (!commentId) {
|
||||
return json({ success: false, error: 'Comment ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingComment = await db
|
||||
.select()
|
||||
.from(findComment)
|
||||
.where(eq(findComment.id, commentId))
|
||||
.limit(1);
|
||||
|
||||
if (existingComment.length === 0) {
|
||||
return json({ success: false, error: 'Comment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (existingComment[0].userId !== session.userId) {
|
||||
return json(
|
||||
{ success: false, error: 'Not authorized to delete this comment' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(findComment).where(eq(findComment.id, commentId));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: { id: commentId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting comment:', error);
|
||||
return json({ success: false, error: 'Failed to delete comment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,9 @@ 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';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
function generateFriendshipId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
@@ -96,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
});
|
||||
|
||||
return json(friendshipsWithSignedUrls);
|
||||
} catch (err) {
|
||||
@@ -180,6 +175,34 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Send notification to friend
|
||||
const shouldNotify = await notificationService.shouldNotify(friendId, 'friend_request');
|
||||
if (shouldNotify) {
|
||||
await notificationService.createNotification({
|
||||
userId: friendId,
|
||||
type: 'friend_request',
|
||||
title: 'New friend request',
|
||||
message: `${locals.user.username} sent you a friend request`,
|
||||
data: {
|
||||
friendshipId: newFriendship[0].id,
|
||||
senderId: locals.user.id,
|
||||
senderUsername: locals.user.username
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(friendId, {
|
||||
title: 'New friend request',
|
||||
message: `${locals.user.username} sent you a friend request`,
|
||||
url: '/friends',
|
||||
tag: 'friend_request',
|
||||
data: {
|
||||
friendshipId: newFriendship[0].id,
|
||||
senderId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true, friendship: newFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error sending friend request:', err);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { friendship, user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
import { pushService } from '$lib/server/push';
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
@@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
.where(eq(friendship.id, friendshipId))
|
||||
.returning();
|
||||
|
||||
// Send notification if friend request was accepted
|
||||
if (action === 'accept') {
|
||||
const senderId = friendshipRecord.userId;
|
||||
const shouldNotify = await notificationService.shouldNotify(senderId, 'friend_accepted');
|
||||
|
||||
if (shouldNotify) {
|
||||
// Get accepter's username
|
||||
const accepterUser = await db
|
||||
.select({ username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
const accepterUsername = accepterUser[0]?.username || 'Someone';
|
||||
|
||||
await notificationService.createNotification({
|
||||
userId: senderId,
|
||||
type: 'friend_accepted',
|
||||
title: 'Friend request accepted',
|
||||
message: `${accepterUsername} accepted your friend request`,
|
||||
data: {
|
||||
friendshipId: friendshipRecord.id,
|
||||
accepterId: locals.user.id,
|
||||
accepterUsername
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await pushService.sendPushNotification(senderId, {
|
||||
title: 'Friend request accepted',
|
||||
message: `${accepterUsername} accepted your friend request`,
|
||||
url: '/friends',
|
||||
tag: 'friend_accepted',
|
||||
data: {
|
||||
friendshipId: friendshipRecord.id,
|
||||
accepterId: locals.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, friendship: updatedFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating friendship:', err);
|
||||
|
||||
63
src/routes/api/media/[...path]/+server.ts
Normal file
63
src/routes/api/media/[...path]/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client } from '$lib/server/r2';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const path = params.path;
|
||||
|
||||
if (!path) {
|
||||
throw error(400, 'Path is required');
|
||||
}
|
||||
|
||||
if (!R2_BUCKET_NAME) {
|
||||
throw error(500, 'R2_BUCKET_NAME environment variable is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
});
|
||||
|
||||
const response = await r2Client.send(command);
|
||||
|
||||
if (!response.Body) {
|
||||
throw error(404, 'File not found');
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const body = response.Body as AsyncIterable<Uint8Array>;
|
||||
for await (const chunk of body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': response.ContentType || 'application/octet-stream',
|
||||
'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(buffer);
|
||||
} catch (err) {
|
||||
console.error('Error fetching file from R2:', err);
|
||||
throw error(500, 'Failed to fetch file');
|
||||
}
|
||||
};
|
||||
|
||||
export const OPTIONS: RequestHandler = async ({ setHeaders }) => {
|
||||
setHeaders({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
83
src/routes/api/notifications/+server.ts
Normal file
83
src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
|
||||
// GET /api/notifications - Get user notifications
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
const unreadOnly = url.searchParams.get('unreadOnly') === 'true';
|
||||
|
||||
try {
|
||||
const notifications = await notificationService.getUserNotifications(user.id, {
|
||||
limit,
|
||||
offset,
|
||||
unreadOnly
|
||||
});
|
||||
|
||||
return json({ notifications });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
return json({ error: 'Failed to fetch notifications' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// PATCH /api/notifications - Mark notifications as read
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { notificationIds, markAll } = await request.json();
|
||||
|
||||
if (markAll) {
|
||||
await notificationService.markAllAsRead(user.id);
|
||||
} else if (Array.isArray(notificationIds) && notificationIds.length > 0) {
|
||||
await notificationService.markAsRead(notificationIds);
|
||||
} else {
|
||||
return json(
|
||||
{ error: 'Invalid request: provide notificationIds or markAll' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking notifications as read:', error);
|
||||
return json({ error: 'Failed to mark notifications as read' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/notifications - Delete notifications
|
||||
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { notificationId, deleteAll } = await request.json();
|
||||
|
||||
if (deleteAll) {
|
||||
await notificationService.deleteAllNotifications(user.id);
|
||||
} else if (notificationId) {
|
||||
await notificationService.deleteNotification(notificationId, user.id);
|
||||
} else {
|
||||
return json(
|
||||
{ error: 'Invalid request: provide notificationId or deleteAll' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting notifications:', error);
|
||||
return json({ error: 'Failed to delete notifications' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
18
src/routes/api/notifications/count/+server.ts
Normal file
18
src/routes/api/notifications/count/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { notificationService } from '$lib/server/notifications';
|
||||
|
||||
// GET /api/notifications/count - Get unread notification count
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await notificationService.getUnreadCount(user.id);
|
||||
return json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error);
|
||||
return json({ error: 'Failed to fetch unread count' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
93
src/routes/api/notifications/preferences/+server.ts
Normal file
93
src/routes/api/notifications/preferences/+server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { notificationPreferences } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// GET - Fetch user's notification preferences
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's preferences
|
||||
const [preferences] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, return defaults
|
||||
if (!preferences) {
|
||||
return json({
|
||||
friendRequests: true,
|
||||
friendAccepted: true,
|
||||
findLiked: true,
|
||||
findCommented: true,
|
||||
pushEnabled: true
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
friendRequests: preferences.friendRequests,
|
||||
friendAccepted: preferences.friendAccepted,
|
||||
findLiked: preferences.findLiked,
|
||||
findCommented: preferences.findCommented,
|
||||
pushEnabled: preferences.pushEnabled
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching notification preferences:', err);
|
||||
throw error(500, 'Failed to fetch notification preferences');
|
||||
}
|
||||
};
|
||||
|
||||
// POST - Update user's notification preferences
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { friendRequests, friendAccepted, findLiked, findCommented, pushEnabled } = body;
|
||||
|
||||
// Validate boolean values
|
||||
const preferences = {
|
||||
friendRequests: typeof friendRequests === 'boolean' ? friendRequests : true,
|
||||
friendAccepted: typeof friendAccepted === 'boolean' ? friendAccepted : true,
|
||||
findLiked: typeof findLiked === 'boolean' ? findLiked : true,
|
||||
findCommented: typeof findCommented === 'boolean' ? findCommented : true,
|
||||
pushEnabled: typeof pushEnabled === 'boolean' ? pushEnabled : true
|
||||
};
|
||||
|
||||
// Check if preferences exist
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, locals.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
// Update existing preferences
|
||||
await db
|
||||
.update(notificationPreferences)
|
||||
.set({
|
||||
...preferences,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(notificationPreferences.userId, locals.user.id));
|
||||
} else {
|
||||
// Create new preferences
|
||||
await db.insert(notificationPreferences).values({
|
||||
userId: locals.user.id,
|
||||
...preferences
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true, preferences });
|
||||
} catch (err) {
|
||||
console.error('Error updating notification preferences:', err);
|
||||
throw error(500, 'Failed to update notification preferences');
|
||||
}
|
||||
};
|
||||
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
54
src/routes/api/notifications/subscribe/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { pushService, VAPID_PUBLIC_KEY } from '$lib/server/push';
|
||||
|
||||
// GET /api/notifications/subscribe - Get VAPID public key
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({ publicKey: VAPID_PUBLIC_KEY });
|
||||
};
|
||||
|
||||
// POST /api/notifications/subscribe - Subscribe to push notifications
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await request.json();
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return json({ error: 'Invalid subscription data' }, { status: 400 });
|
||||
}
|
||||
|
||||
await pushService.saveSubscription(user.id, subscription, userAgent);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving subscription:', error);
|
||||
return json({ error: 'Failed to save subscription' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
|
||||
export const DELETE: RequestHandler = async ({ locals, request }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { endpoint } = await request.json();
|
||||
|
||||
if (!endpoint) {
|
||||
return json({ error: 'Endpoint is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await pushService.removeSubscription(user.id, endpoint);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing subscription:', error);
|
||||
return json({ error: 'Failed to remove subscription' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { getLocalR2Url } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
@@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Generate local proxy URLs and add friendship status
|
||||
const usersWithSignedUrls = users.map((userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||
}
|
||||
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
});
|
||||
|
||||
return json(usersWithSignedUrls);
|
||||
} catch (err) {
|
||||
|
||||
43
src/routes/finds/[findId]/+page.server.ts
Normal file
43
src/routes/finds/[findId]/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, url, request }) => {
|
||||
const findId = params.findId;
|
||||
|
||||
if (!findId) {
|
||||
throw error(400, 'Find ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Build API URL
|
||||
const apiUrl = new URL(`/api/finds/${findId}`, url.origin);
|
||||
|
||||
// Fetch the find data - no auth required for public finds
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: {
|
||||
Cookie: request.headers.get('Cookie') || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw error(404, 'Find not found');
|
||||
} else if (response.status === 403) {
|
||||
throw error(403, 'This find is private');
|
||||
}
|
||||
throw error(response.status, 'Failed to load find');
|
||||
}
|
||||
|
||||
const find = await response.json();
|
||||
|
||||
return {
|
||||
find
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading find:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to load find');
|
||||
}
|
||||
};
|
||||
780
src/routes/finds/[findId]/+page.svelte
Normal file
780
src/routes/finds/[findId]/+page.svelte
Normal file
@@ -0,0 +1,780 @@
|
||||
<script lang="ts">
|
||||
import { Map } from '$lib';
|
||||
import LikeButton from '$lib/components/finds/LikeButton.svelte';
|
||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
||||
import CommentsList from '$lib/components/finds/CommentsList.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let currentMediaIndex = $state(0);
|
||||
let isPanelVisible = $state(true);
|
||||
|
||||
function nextMedia() {
|
||||
if (!data.find?.media) return;
|
||||
currentMediaIndex = (currentMediaIndex + 1) % data.find.media.length;
|
||||
}
|
||||
|
||||
function prevMedia() {
|
||||
if (!data.find?.media) return;
|
||||
currentMediaIndex =
|
||||
currentMediaIndex === 0 ? data.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 (!data.find) return;
|
||||
|
||||
const lat = parseFloat(data.find.latitude);
|
||||
const lng = parseFloat(data.find.longitude);
|
||||
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function shareFindUrl() {
|
||||
if (!data.find) return;
|
||||
|
||||
const url = `${window.location.origin}/finds/${data.find.id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: data.find.title,
|
||||
text: data.find.description || `Check out this find: ${data.find.title}`,
|
||||
url: url
|
||||
})
|
||||
.catch((error) => {
|
||||
// User cancelled or error occurred
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
alert('Find URL copied to clipboard!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelVisible = !isPanelVisible;
|
||||
}
|
||||
|
||||
// Create the map find format
|
||||
let mapFinds = $derived(
|
||||
data.find
|
||||
? [
|
||||
{
|
||||
id: data.find.id,
|
||||
title: data.find.title,
|
||||
description: data.find.description,
|
||||
latitude: data.find.latitude,
|
||||
longitude: data.find.longitude,
|
||||
locationName: data.find.locationName,
|
||||
category: data.find.category,
|
||||
isPublic: data.find.isPublic,
|
||||
createdAt: new Date(data.find.createdAt),
|
||||
userId: data.find.userId,
|
||||
user: {
|
||||
id: data.find.userId,
|
||||
username: data.find.username,
|
||||
profilePictureUrl: data.find.profilePictureUrl
|
||||
},
|
||||
likeCount: data.find.likeCount,
|
||||
isLiked: data.find.isLikedByUser,
|
||||
media: data.find.media?.map(
|
||||
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||
type: m.type,
|
||||
url: m.url,
|
||||
thumbnailUrl: m.thumbnailUrl || m.url
|
||||
})
|
||||
)
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Get first media for OG image
|
||||
let ogImage = $derived(data.find?.media?.[0]?.url || '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
<meta property="og:type" content="article" />
|
||||
{#if ogImage}
|
||||
<meta property="og:image" content={ogImage} />
|
||||
{/if}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={data.find?.description ||
|
||||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
|
||||
/>
|
||||
{#if ogImage}
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="public-find-page">
|
||||
<!-- Fullscreen map -->
|
||||
<div class="map-section">
|
||||
<Map
|
||||
autoCenter={true}
|
||||
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
|
||||
finds={mapFinds}
|
||||
onFindClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Details panel container -->
|
||||
<div class="panel-container">
|
||||
<!-- Details panel -->
|
||||
<div class="find-details-panel" class:hidden={!isPanelVisible}>
|
||||
{#if data.find}
|
||||
<div class="panel-content">
|
||||
<div class="panel-header">
|
||||
<div class="user-section">
|
||||
<ProfilePicture
|
||||
username={data.find.username}
|
||||
profilePictureUrl={data.find.profilePictureUrl}
|
||||
class="user-avatar"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<h1 class="find-title">{data.find.title}</h1>
|
||||
<div class="find-meta">
|
||||
<span class="username">@{data.find.username}</span>
|
||||
<span class="separator">•</span>
|
||||
<span class="date">{formatDate(data.find.createdAt)}</span>
|
||||
{#if data.find.category}
|
||||
<span class="separator">•</span>
|
||||
<span class="category">{data.find.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if data.find.media && data.find.media.length > 0}
|
||||
<div class="media-container">
|
||||
<div class="media-viewer">
|
||||
{#if data.find.media[currentMediaIndex].type === 'photo'}
|
||||
<img
|
||||
src={data.find.media[currentMediaIndex].url}
|
||||
alt={data.find.title}
|
||||
class="media-image"
|
||||
/>
|
||||
{:else}
|
||||
<VideoPlayer
|
||||
src={data.find.media[currentMediaIndex].url}
|
||||
poster={data.find.media[currentMediaIndex].thumbnailUrl}
|
||||
class="media-video"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if data.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 data.find.media.length > 1}
|
||||
<div class="media-indicators">
|
||||
{#each data.find.media as _, 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 data.find.description}
|
||||
<p class="description">{data.find.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.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>{data.find.locationName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<LikeButton
|
||||
findId={data.find.id}
|
||||
isLiked={data.find.isLikedByUser || false}
|
||||
likeCount={data.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 class="comments-section">
|
||||
<CommentsList
|
||||
findId={data.find.id}
|
||||
currentUserId={data.user?.id}
|
||||
collapsed={false}
|
||||
isScrollable={true}
|
||||
showCommentForm={data.user ? true : false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="error-state">
|
||||
<h1>Find not found</h1>
|
||||
<p>This find does not exist or is private.</p>
|
||||
<a href="/" class="back-button">Back to Home</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="panel-toggle"
|
||||
class:collapsed={!isPanelVisible}
|
||||
onclick={togglePanel}
|
||||
aria-label="Toggle find details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
{#if isPanelVisible}
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-find-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-section :global(.maplibregl-map) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.panel-toggle svg {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.find-details-panel {
|
||||
width: 40%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: calc(100vh - 100px);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.find-details-panel.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
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;
|
||||
}
|
||||
|
||||
.find-title {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.find-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
background: hsl(var(--muted));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
: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: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.error-state h1 {
|
||||
font-family: 'Washington', serif;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-toggle.collapsed {
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.panel-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.find-details-panel {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.find-details-panel.hidden {
|
||||
display: none;
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.find-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
:global(.user-avatar) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.map-section :global(.map-container) {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.find-details-panel {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { Input } from '$lib/components/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
|
||||
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
||||
import { Badge } from '$lib/components/badge';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData } from './$types';
|
||||
@@ -134,10 +134,10 @@
|
||||
// Search users when query changes with debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
$effect(() => {
|
||||
// Track searchQuery dependency explicitly
|
||||
searchQuery;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(searchUsers, 300);
|
||||
if (searchQuery) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(searchUsers, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -184,10 +184,10 @@
|
||||
<div class="user-grid">
|
||||
{#each friends as friend (friend.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
|
||||
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={friend.friendUsername}
|
||||
profilePictureUrl={friend.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{friend.friendUsername}</span>
|
||||
<Badge variant="secondary">Friend</Badge>
|
||||
@@ -218,15 +218,10 @@
|
||||
<div class="user-grid">
|
||||
{#each receivedRequests as request (request.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={request.friendProfilePictureUrl}
|
||||
alt={request.friendUsername}
|
||||
/>
|
||||
<AvatarFallback
|
||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
||||
>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={request.friendUsername}
|
||||
profilePictureUrl={request.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{request.friendUsername}</span>
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
@@ -264,15 +259,10 @@
|
||||
<div class="user-grid">
|
||||
{#each sentRequests as request (request.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={request.friendProfilePictureUrl}
|
||||
alt={request.friendUsername}
|
||||
/>
|
||||
<AvatarFallback
|
||||
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
|
||||
>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={request.friendUsername}
|
||||
profilePictureUrl={request.friendProfilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{request.friendUsername}</span>
|
||||
<Badge variant="secondary">Sent</Badge>
|
||||
@@ -305,10 +295,10 @@
|
||||
<div class="user-grid">
|
||||
{#each searchResults as user (user.id)}
|
||||
<div class="user-card">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
|
||||
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ProfilePicture
|
||||
username={user.username}
|
||||
profilePictureUrl={user.profilePictureUrl}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="username">{user.username}</span>
|
||||
{#if user.friendshipStatus === 'accepted'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import LoginForm from '$lib/components/login-form.svelte';
|
||||
import LoginForm from '$lib/components/auth/login-form.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
@@ -130,8 +130,12 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle R2 resources with cache-first strategy
|
||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
||||
// Handle R2 resources and local media proxy with cache-first strategy
|
||||
if (
|
||||
url.hostname.includes('.r2.dev') ||
|
||||
url.hostname.includes('.r2.cloudflarestorage.com') ||
|
||||
url.pathname.startsWith('/api/media/')
|
||||
) {
|
||||
const cachedResponse = await r2Cache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
@@ -146,7 +150,7 @@ self.addEventListener('fetch', (event) => {
|
||||
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 });
|
||||
return cachedResponse || new Response('Media resource not available', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,3 +226,52 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
event.respondWith(respond());
|
||||
});
|
||||
|
||||
// Handle push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data.json();
|
||||
const title = data.title || 'Serengo';
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/logo.svg',
|
||||
badge: data.badge || '/logo.svg',
|
||||
tag: data.tag || 'default',
|
||||
data: data.data || {},
|
||||
requireInteraction: false,
|
||||
vibrate: [200, 100, 200]
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
})
|
||||
.then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user