Compare commits
14 Commits
comments
...
biguiupdat
| Author | SHA1 | Date | |
|---|---|---|---|
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
|||
|
ae339d68e1
|
|||
|
0754d62d0e
|
|||
|
e27b2498b7
|
|||
|
4d288347ab
|
|||
|
d7f803c782
|
|||
|
df675640c2
|
@@ -38,3 +38,8 @@ R2_BUCKET_NAME=""
|
|||||||
|
|
||||||
# Google Maps API for Places search
|
# Google Maps API for Places search
|
||||||
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
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"
|
||||||
|
|||||||
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;
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1762428302491,
|
"when": 1762428302491,
|
||||||
"tag": "0006_strange_firebird",
|
"tag": "0006_strange_firebird",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762522687342,
|
||||||
|
"tag": "0007_grey_dark_beast",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,17 @@ export default defineConfig(
|
|||||||
rules: {
|
rules: {
|
||||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
// 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
|
// 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: '^_'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,9 +59,12 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
|
"lucide-svelte": "^0.553.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"svelte-maplibre": "^1.2.1"
|
"svelte-maplibre": "^1.2.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"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;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
"font-src 'self' fonts.gstatic.com; " +
|
"font-src 'self' fonts.gstatic.com; " +
|
||||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
"connect-src 'self' *.openstreetmap.org; " +
|
"connect-src 'self' *.openstreetmap.org https://fcm.googleapis.com https://android.googleapis.com; " +
|
||||||
"frame-ancestors 'none'; " +
|
"frame-ancestors 'none'; " +
|
||||||
"base-uri 'self'; " +
|
"base-uri 'self'; " +
|
||||||
"form-action 'self';"
|
"form-action 'self';"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
await apiSync.deleteComment(commentId, findId);
|
await apiSync.deleteComment(commentId, findId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canDeleteComment(comment: any): boolean {
|
function canDeleteComment(comment: { user: { id: string } }): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||||
);
|
);
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
{#snippet loadingSkeleton()}
|
{#snippet loadingSkeleton()}
|
||||||
<div class="loading-skeleton">
|
<div class="loading-skeleton">
|
||||||
{#each Array(3) as _}
|
{#each Array(3) as _, index (index)}
|
||||||
<div class="comment-skeleton">
|
<div class="comment-skeleton">
|
||||||
<Skeleton class="avatar-skeleton" />
|
<Skeleton class="avatar-skeleton" />
|
||||||
<div class="content-skeleton">
|
<div class="content-skeleton">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
|
||||||
import { Input } from '$lib/components/input';
|
import { Input } from '$lib/components/input';
|
||||||
import { Label } from '$lib/components/label';
|
import { Label } from '$lib/components/label';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
{ value: 'other', label: 'Other' }
|
{ value: 'other', label: 'Other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let showModal = $state(true);
|
|
||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -54,13 +52,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!showModal) {
|
if (isOpen && $coordinates) {
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (showModal && $coordinates) {
|
|
||||||
latitude = $coordinates.latitude.toString();
|
latitude = $coordinates.latitude.toString();
|
||||||
longitude = $coordinates.longitude.toString();
|
longitude = $coordinates.longitude.toString();
|
||||||
}
|
}
|
||||||
@@ -129,8 +121,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
showModal = false;
|
|
||||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||||
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating find:', error);
|
console.error('Error creating find:', error);
|
||||||
alert('Failed to create find. Please try again.');
|
alert('Failed to create find. Please try again.');
|
||||||
@@ -173,16 +165,27 @@
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
resetForm();
|
resetForm();
|
||||||
showModal = false;
|
onClose();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
<div class="modal-container" class:mobile={isMobile}>
|
||||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
<div class="modal-content">
|
||||||
<SheetHeader>
|
<div class="modal-header">
|
||||||
<SheetTitle>Create Find</SheetTitle>
|
<h2 class="modal-title">Create Find</h2>
|
||||||
</SheetHeader>
|
<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
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
@@ -191,7 +194,7 @@
|
|||||||
}}
|
}}
|
||||||
class="form"
|
class="form"
|
||||||
>
|
>
|
||||||
<div class="form-content">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label for="title">What did you find?</Label>
|
<Label for="title">What did you find?</Label>
|
||||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||||
@@ -334,7 +337,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="modal-footer">
|
||||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -343,24 +346,106 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.create-find-sheet) {
|
.modal-container {
|
||||||
padding: 0 !important;
|
position: fixed;
|
||||||
width: 100%;
|
top: 80px;
|
||||||
max-width: 500px;
|
right: 20px;
|
||||||
height: 100vh;
|
width: 40%;
|
||||||
border-radius: 0;
|
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) {
|
@keyframes slideIn {
|
||||||
:global(.create-find-sheet) {
|
from {
|
||||||
height: 90vh;
|
opacity: 0;
|
||||||
border-radius: 12px 12px 0 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 {
|
.form {
|
||||||
@@ -369,13 +454,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-content {
|
.modal-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -390,14 +476,8 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.field-group {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 80px;
|
min-height: 100px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -407,6 +487,7 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
@@ -448,13 +529,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle:hover {
|
.privacy-toggle:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle input[type='checkbox'] {
|
.privacy-toggle input[type='checkbox'] {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
accent-color: hsl(var(--primary));
|
accent-color: hsl(var(--primary));
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-upload {
|
.file-upload {
|
||||||
@@ -485,7 +567,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
@@ -493,6 +575,7 @@
|
|||||||
|
|
||||||
.file-content span {
|
.file-content span {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-selected {
|
.file-selected {
|
||||||
@@ -500,7 +583,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted) / 0.5);
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -516,24 +599,26 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.modal-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-top: 1px solid hsl(var(--border));
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: hsl(var(--background));
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions :global(button) {
|
.modal-footer :global(button) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-section {
|
.location-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-header {
|
.location-header {
|
||||||
@@ -544,18 +629,19 @@
|
|||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.375rem 0.75rem;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: hsl(var(--secondary));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--secondary-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button:hover {
|
.toggle-button:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--secondary) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-display {
|
.coordinates-display {
|
||||||
@@ -567,7 +653,7 @@
|
|||||||
.coordinates-info {
|
.coordinates-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: hsl(var(--muted) / 0.5);
|
background: hsl(var(--muted) / 0.5);
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
@@ -578,38 +664,53 @@
|
|||||||
.coordinate {
|
.coordinate {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-coords-button {
|
.edit-coords-button {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.375rem 0.75rem;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: hsl(var(--secondary));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--secondary-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-coords-button:hover {
|
.edit-coords-button:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--secondary) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
/* Mobile specific adjustments */
|
||||||
.form-content {
|
@media (max-width: 767px) {
|
||||||
|
.modal-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
flex-direction: column;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions :global(button) {
|
.modal-footer {
|
||||||
flex: none;
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -161,16 +161,8 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.find-card {
|
.find-card {
|
||||||
background: white;
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
transition: box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.find-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Post Header */
|
/* Post Header */
|
||||||
@@ -294,12 +286,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.action-button) {
|
:global(.action-button) {
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.action-button:hover) {
|
:global(.action-button:hover) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
|
|
||||||
let { find, onClose, currentUserId }: Props = $props();
|
let { find, onClose, currentUserId }: Props = $props();
|
||||||
|
|
||||||
let showModal = $state(true);
|
|
||||||
let currentMediaIndex = $state(0);
|
let currentMediaIndex = $state(0);
|
||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
|
|
||||||
@@ -54,13 +52,6 @@
|
|||||||
return () => window.removeEventListener('resize', checkIsMobile);
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal when showModal changes to false
|
|
||||||
$effect(() => {
|
|
||||||
if (!showModal) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function nextMedia() {
|
function nextMedia() {
|
||||||
if (!find?.media) return;
|
if (!find?.media) return;
|
||||||
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||||
@@ -110,9 +101,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if find}
|
{#if find}
|
||||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
<div class="modal-container" class:mobile={isMobile}>
|
||||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
<div class="modal-content">
|
||||||
<SheetHeader class="sheet-header">
|
<div class="modal-header">
|
||||||
<div class="user-section">
|
<div class="user-section">
|
||||||
<ProfilePicture
|
<ProfilePicture
|
||||||
username={find.user.username}
|
username={find.user.username}
|
||||||
@@ -120,7 +111,7 @@
|
|||||||
class="user-avatar"
|
class="user-avatar"
|
||||||
/>
|
/>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
<h2 class="find-title">{find.title}</h2>
|
||||||
<div class="find-meta">
|
<div class="find-meta">
|
||||||
<span class="username">@{find.user.username}</span>
|
<span class="username">@{find.user.username}</span>
|
||||||
<span class="separator">•</span>
|
<span class="separator">•</span>
|
||||||
@@ -132,9 +123,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
{#if find.media && find.media.length > 0}
|
||||||
<div class="media-container">
|
<div class="media-container">
|
||||||
<div class="media-viewer">
|
<div class="media-viewer">
|
||||||
@@ -263,37 +265,78 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Base styles for sheet content */
|
.modal-container {
|
||||||
:global(.sheet-content) {
|
position: fixed;
|
||||||
padding: 0 !important;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop styles (side sheet) */
|
@keyframes slideIn {
|
||||||
@media (min-width: 768px) {
|
from {
|
||||||
:global(.sheet-content) {
|
opacity: 0;
|
||||||
width: 80vw !important;
|
transform: translateX(20px);
|
||||||
max-width: 600px !important;
|
}
|
||||||
height: 100vh !important;
|
to {
|
||||||
border-radius: 0 !important;
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile styles (bottom sheet) */
|
.modal-container.mobile {
|
||||||
@media (max-width: 767px) {
|
top: auto;
|
||||||
:global(.sheet-content) {
|
bottom: 0;
|
||||||
height: 50vh !important;
|
left: 0;
|
||||||
border-radius: 16px 16px 0 0 !important;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.sheet-header) {
|
.modal-content {
|
||||||
padding: 1rem 1.5rem;
|
display: flex;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +344,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.user-avatar) {
|
:global(.user-avatar) {
|
||||||
@@ -322,13 +366,13 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-title) {
|
.find-title {
|
||||||
font-family: 'Washington', serif !important;
|
font-family: 'Washington', serif;
|
||||||
font-size: 1.25rem !important;
|
font-size: 1.25rem;
|
||||||
font-weight: 600 !important;
|
font-weight: 600;
|
||||||
margin: 0 !important;
|
margin: 0;
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--foreground));
|
||||||
line-height: 1.3 !important;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.find-meta {
|
.find-meta {
|
||||||
@@ -359,7 +403,27 @@
|
|||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-body {
|
.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: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -387,8 +451,8 @@
|
|||||||
|
|
||||||
/* Desktop media viewer - maximize available space */
|
/* Desktop media viewer - maximize available space */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.sheet-body {
|
.modal-body {
|
||||||
height: calc(100vh - 140px);
|
height: calc(100vh - 180px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-container {
|
.media-container {
|
||||||
@@ -404,8 +468,8 @@
|
|||||||
|
|
||||||
/* Mobile media viewer - maximize available space */
|
/* Mobile media viewer - maximize available space */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.sheet-body {
|
.modal-body {
|
||||||
height: calc(80vh - 140px);
|
height: calc(90vh - 180px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-container {
|
.media-container {
|
||||||
@@ -490,6 +554,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
@@ -556,15 +621,16 @@
|
|||||||
.comments-section {
|
.comments-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-top: 1px solid hsl(var(--border));
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop comments section */
|
/* Desktop comments section */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.comments-section {
|
.comments-section {
|
||||||
height: calc(100vh - 400px);
|
height: calc(100vh - 500px);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,14 +638,14 @@
|
|||||||
/* Mobile comments section */
|
/* Mobile comments section */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.comments-section {
|
.comments-section {
|
||||||
height: calc(80vh - 350px);
|
height: calc(90vh - 450px);
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile specific adjustments */
|
/* Mobile specific adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
:global(.sheet-header) {
|
.modal-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,8 +658,8 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-title) {
|
.find-title {
|
||||||
font-size: 1.125rem !important;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
@@ -610,7 +676,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comments-section {
|
.comments-section {
|
||||||
height: calc(80vh - 380px);
|
height: calc(90vh - 480px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ProfilePanel } from '$lib';
|
import { ProfilePanel } from '$lib';
|
||||||
|
import { resolveRoute } from '$app/paths';
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
|
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<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">
|
<div class="profile-container">
|
||||||
<ProfilePanel
|
<ProfilePanel
|
||||||
username={user.username}
|
username={user.username}
|
||||||
@@ -27,8 +28,7 @@
|
|||||||
.app-header {
|
.app-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 100;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 1200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-container {
|
.profile-container {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
let apiSync: any = null;
|
let apiSync: typeof import('$lib/stores/api-sync').apiSync | null = null;
|
||||||
|
|
||||||
// Initialize API sync and subscribe to global state
|
// Initialize API sync and subscribe to global state
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -65,13 +65,15 @@
|
|||||||
|
|
||||||
// Subscribe to global state for this find
|
// Subscribe to global state for this find
|
||||||
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||||
globalLikeState.subscribe((state: any) => {
|
globalLikeState.subscribe(
|
||||||
|
(state: { isLiked: boolean; likeCount: number; isLoading: boolean }) => {
|
||||||
likeState.set({
|
likeState.set({
|
||||||
isLiked: state.isLiked,
|
isLiked: state.isLiked,
|
||||||
likeCount: state.likeCount,
|
likeCount: state.likeCount,
|
||||||
isLoading: state.isLoading
|
isLoading: state.isLoading
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize API sync:', error);
|
console.error('Failed to initialize API sync:', error);
|
||||||
}
|
}
|
||||||
@@ -81,8 +83,7 @@
|
|||||||
async function toggleLike() {
|
async function toggleLike() {
|
||||||
if (!apiSync || !browser) return;
|
if (!apiSync || !browser) return;
|
||||||
|
|
||||||
const currentState = likeState;
|
if ($likeState.isLoading) return;
|
||||||
if (currentState && (currentState as any).isLoading) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiSync.toggleLike(findId);
|
await apiSync.toggleLike(findId);
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import {
|
|
||||||
locationActions,
|
|
||||||
locationStatus,
|
|
||||||
locationError,
|
|
||||||
isLocationLoading,
|
|
||||||
isWatching
|
|
||||||
} 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();
|
|
||||||
|
|
||||||
// Track if location watching is active from the derived store
|
|
||||||
|
|
||||||
async function handleLocationClick() {
|
|
||||||
if ($isWatching) {
|
|
||||||
// Stop watching if currently active
|
|
||||||
locationActions.stopWatching();
|
|
||||||
toast.success('Location watching stopped');
|
|
||||||
} else {
|
|
||||||
// Try to get current location first, then start watching
|
|
||||||
const result = await locationActions.getCurrentLocation({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 15000,
|
|
||||||
maximumAge: 300000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Start watching for continuous updates
|
|
||||||
locationActions.startWatching({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 15000,
|
|
||||||
maximumAge: 60000 // Update every minute
|
|
||||||
});
|
|
||||||
toast.success('Location watching started');
|
|
||||||
} else if ($locationError) {
|
|
||||||
toast.error($locationError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonText = $derived(() => {
|
|
||||||
if ($isLocationLoading) return 'Finding location...';
|
|
||||||
if ($isWatching) return 'Stop watching location';
|
|
||||||
if ($locationStatus === 'success') return 'Watch location';
|
|
||||||
return 'Find my location';
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconClass = $derived(() => {
|
|
||||||
if ($isLocationLoading) return 'loading';
|
|
||||||
if ($isWatching) return 'watching';
|
|
||||||
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 $isWatching}
|
|
||||||
<svg viewBox="0 0 24 24" class="watching-icon">
|
|
||||||
<path
|
|
||||||
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="9"
|
|
||||||
r="15"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
fill="none"
|
|
||||||
opacity="0.3"
|
|
||||||
class="pulse-ring"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if $locationStatus === 'success'}
|
|
||||||
<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.watching svg {
|
|
||||||
color: #f59e0b;
|
|
||||||
fill: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon.error svg {
|
|
||||||
color: #ef4444;
|
|
||||||
fill: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.watching-icon .pulse-ring {
|
|
||||||
animation: pulse-ring 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.5);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1.5);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.location-button .label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-button {
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
locationActions,
|
locationActions,
|
||||||
isWatching
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import LocationButton from './LocationButton.svelte';
|
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
|
||||||
interface Find {
|
interface Find {
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
showLocationButton?: boolean;
|
|
||||||
autoCenter?: boolean;
|
autoCenter?: boolean;
|
||||||
finds?: Find[];
|
finds?: Find[];
|
||||||
onFindClick?: (find: Find) => void;
|
onFindClick?: (find: Find) => void;
|
||||||
@@ -67,7 +65,6 @@
|
|||||||
center,
|
center,
|
||||||
zoom,
|
zoom,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
showLocationButton = true,
|
|
||||||
autoCenter = true,
|
autoCenter = true,
|
||||||
finds = [],
|
finds = [],
|
||||||
onFindClick
|
onFindClick
|
||||||
@@ -179,12 +176,6 @@
|
|||||||
</Marker>
|
</Marker>
|
||||||
{/each}
|
{/each}
|
||||||
</MapLibre>
|
</MapLibre>
|
||||||
|
|
||||||
{#if showLocationButton}
|
|
||||||
<div class="location-controls">
|
|
||||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -219,17 +210,9 @@
|
|||||||
|
|
||||||
.map-container :global(.maplibregl-map) {
|
.map-container :global(.maplibregl-map) {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Location marker styles */
|
/* Location marker styles */
|
||||||
:global(.location-marker) {
|
:global(.location-marker) {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
background: white;
|
background: white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.dropdown {
|
.modal.dropdown {
|
||||||
@@ -171,11 +172,12 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
z-index: 1000;
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal::backdrop {
|
.modal::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 9998;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|||||||
194
src/lib/components/NotificationManager.svelte
Normal file
194
src/lib/components/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/NotificationPrompt.svelte
Normal file
173
src/lib/components/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>
|
||||||
469
src/lib/components/NotificationSettings.svelte
Normal file
469
src/lib/components/NotificationSettings.svelte
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
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="notification-settings">
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notification-settings {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-banner {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fbbf24;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-banner.denied {
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-info strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-button:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-list {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 16px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
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: #d1d5db;
|
||||||
|
border-radius: 28px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:disabled + .toggle-slider {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.notification-settings {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/lib/components/NotificationSettingsSheet.svelte
Normal file
36
src/lib/components/NotificationSettingsSheet.svelte
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from './sheet/index.js';
|
||||||
|
import NotificationSettings from './NotificationSettings.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onClose }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet open={true} {onClose}>
|
||||||
|
<SheetContent class="notification-settings-sheet">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Notifications</SheetTitle>
|
||||||
|
<SheetDescription>Manage your notification preferences</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="notification-settings-content">
|
||||||
|
<NotificationSettings />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.notification-settings-sheet) {
|
||||||
|
max-width: 500px;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-settings-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,17 +36,16 @@
|
|||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const searchParams = new URL('/api/places', window.location.origin).searchParams;
|
||||||
action: 'autocomplete',
|
searchParams.set('action', 'autocomplete');
|
||||||
query: query.trim()
|
searchParams.set('query', query.trim());
|
||||||
});
|
|
||||||
|
|
||||||
if ($coordinates) {
|
if ($coordinates) {
|
||||||
params.set('lat', $coordinates.latitude.toString());
|
searchParams.set('lat', $coordinates.latitude.toString());
|
||||||
params.set('lng', $coordinates.longitude.toString());
|
searchParams.set('lng', $coordinates.longitude.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/places?${params}`);
|
const response = await fetch(`/api/places?${searchParams}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
suggestions = await response.json();
|
suggestions = await response.json();
|
||||||
showSuggestions = true;
|
showSuggestions = true;
|
||||||
@@ -179,7 +178,7 @@
|
|||||||
<div class="suggestion-content">
|
<div class="suggestion-content">
|
||||||
<span class="suggestion-name">{suggestion.description}</span>
|
<span class="suggestion-name">{suggestion.description}</span>
|
||||||
<div class="suggestion-types">
|
<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>
|
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolveRoute } from '$app/paths';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||||
|
import NotificationSettingsSheet from './NotificationSettingsSheet.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||||
|
|
||||||
let showProfilePictureSheet = $state(false);
|
let showProfilePictureSheet = $state(false);
|
||||||
|
let showNotificationSettingsSheet = $state(false);
|
||||||
|
|
||||||
function openProfilePictureSheet() {
|
function openProfilePictureSheet() {
|
||||||
showProfilePictureSheet = true;
|
showProfilePictureSheet = true;
|
||||||
@@ -29,6 +32,14 @@
|
|||||||
function closeProfilePictureSheet() {
|
function closeProfilePictureSheet() {
|
||||||
showProfilePictureSheet = false;
|
showProfilePictureSheet = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNotificationSettingsSheet() {
|
||||||
|
showNotificationSettingsSheet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNotificationSettingsSheet() {
|
||||||
|
showNotificationSettingsSheet = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -71,7 +82,11 @@
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem class="friends-item">
|
<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={openNotificationSettingsSheet}>
|
||||||
|
Notifications
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -106,6 +121,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showNotificationSettingsSheet}
|
||||||
|
<NotificationSettingsSheet onClose={closeNotificationSettingsSheet} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.profile-trigger) {
|
:global(.profile-trigger) {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -188,6 +207,16 @@
|
|||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.notification-settings-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.notification-settings-item:hover) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
.friends-link {
|
.friends-link {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { type VariantProps, tv } from 'tailwind-variants';
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
import { resolveRoute } from '$app/paths';
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
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",
|
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}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : resolveRoute(href)}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? 'link' : undefined}
|
role={disabled ? 'link' : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolveRoute } from '$app/paths';
|
||||||
import { Button } from '$lib/components/button/index.js';
|
import { Button } from '$lib/components/button/index.js';
|
||||||
import * as Card from '$lib/components/card/index.js';
|
import * as Card from '$lib/components/card/index.js';
|
||||||
import { Label } from '$lib/components/label/index.js';
|
import { Label } from '$lib/components/label/index.js';
|
||||||
@@ -62,7 +63,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path
|
<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"
|
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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { tv, type VariantProps } from 'tailwind-variants';
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
export const sheetVariants = tv({
|
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: {
|
variants: {
|
||||||
side: {
|
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',
|
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
|
bind:ref
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ export { default as ProfilePictureSheet } from './components/ProfilePictureSheet
|
|||||||
export { default as Header } from './components/Header.svelte';
|
export { default as Header } from './components/Header.svelte';
|
||||||
export { default as Modal } from './components/Modal.svelte';
|
export { default as Modal } from './components/Modal.svelte';
|
||||||
export { default as Map } from './components/Map.svelte';
|
export { default as Map } from './components/Map.svelte';
|
||||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
|
||||||
export { default as LocationManager } from './components/LocationManager.svelte';
|
export { default as LocationManager } from './components/LocationManager.svelte';
|
||||||
|
export { default as NotificationManager } from './components/NotificationManager.svelte';
|
||||||
|
export { default as NotificationPrompt } from './components/NotificationPrompt.svelte';
|
||||||
|
export { default as NotificationSettings } from './components/NotificationSettings.svelte';
|
||||||
|
export { default as NotificationSettingsSheet } from './components/NotificationSettingsSheet.svelte';
|
||||||
export { default as FindCard } from './components/FindCard.svelte';
|
export { default as FindCard } from './components/FindCard.svelte';
|
||||||
export { default as FindsList } from './components/FindsList.svelte';
|
export { default as FindsList } from './components/FindsList.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
|||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
import { getSignedR2Url } from '$lib/server/r2';
|
import { getLocalR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
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));
|
.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;
|
let profilePictureUrl = user.profilePictureUrl;
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
// It's a path, generate signed URL
|
// It's a path, generate local proxy URL
|
||||||
try {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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', {
|
export const user = pgTable('user', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -88,15 +88,62 @@ export const findComment = pgTable('find_comment', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type exports for the new tables
|
// 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 Find = typeof find.$inferSelect;
|
||||||
export type FindMedia = typeof findMedia.$inferSelect;
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
export type FindLike = typeof findLike.$inferSelect;
|
export type FindLike = typeof findLike.$inferSelect;
|
||||||
export type FindComment = typeof findComment.$inferSelect;
|
export type FindComment = typeof findComment.$inferSelect;
|
||||||
export type Friendship = typeof friendship.$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 FindInsert = typeof find.$inferInsert;
|
||||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||||
export type FriendshipInsert = typeof friendship.$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 });
|
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLocalR2Url(path: string): string {
|
||||||
|
return `/api/media/${path}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Toaster } from '$lib/components/sonner/index.js';
|
import { Toaster } from '$lib/components/sonner/index.js';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
import LocationManager from '$lib/components/LocationManager.svelte';
|
import LocationManager from '$lib/components/LocationManager.svelte';
|
||||||
|
import NotificationManager from '$lib/components/NotificationManager.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
@@ -42,9 +43,10 @@
|
|||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
<!-- Auto-start location watching for authenticated users -->
|
<!-- Auto-start location and notfication watching for authenticated users -->
|
||||||
{#if data?.user && !isLoginRoute}
|
{#if data?.user && !isLoginRoute}
|
||||||
<LocationManager autoStart={true} />
|
<LocationManager autoStart={true} />
|
||||||
|
<NotificationManager />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showHeader && data.user}
|
{#if showHeader && data.user}
|
||||||
@@ -62,12 +64,12 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-skeleton {
|
.header-skeleton {
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
background: white;
|
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
let selectedFind: FindPreviewData | null = $state(null);
|
let selectedFind: FindPreviewData | null = $state(null);
|
||||||
let currentFilter = $state('all');
|
let currentFilter = $state('all');
|
||||||
|
let isSidebarVisible = $state(true);
|
||||||
|
|
||||||
// Initialize API sync with server data on mount
|
// Initialize API sync with server data on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -215,6 +216,10 @@
|
|||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
isSidebarVisible = !isSidebarVisible;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -238,10 +243,9 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<main class="main-content">
|
<!-- Fullscreen map -->
|
||||||
<div class="map-section">
|
<div class="map-section">
|
||||||
<Map
|
<Map
|
||||||
showLocationButton={true}
|
|
||||||
autoCenter={true}
|
autoCenter={true}
|
||||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||||
{finds}
|
{finds}
|
||||||
@@ -249,13 +253,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="finds-section">
|
<!-- Toggle button -->
|
||||||
<div class="finds-sticky-header">
|
<button class="sidebar-toggle" onclick={toggleSidebar} aria-label="Toggle finds list">
|
||||||
<div class="finds-header-content">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
<div class="finds-title-section">
|
{#if isSidebarVisible}
|
||||||
<h2 class="finds-title">Finds</h2>
|
<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>
|
||||||
|
|
||||||
|
<!-- Left sidebar with finds list -->
|
||||||
|
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||||
|
<div class="finds-header">
|
||||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||||
</div>
|
|
||||||
<Button onclick={openCreateModal} class="create-find-button">
|
<Button onclick={openCreateModal} class="create-find-button">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
@@ -264,18 +276,10 @@
|
|||||||
Create Find
|
Create Find
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="finds-list-container">
|
||||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
<!-- Floating action button for mobile -->
|
|
||||||
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
@@ -293,145 +297,148 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.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;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-sticky-header {
|
.map-section {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
left: 0;
|
||||||
background: white;
|
width: 100vw;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
height: 100vh;
|
||||||
padding: 24px 24px 16px 24px;
|
z-index: 0;
|
||||||
border-radius: 12px 12px 0 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-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 90px;
|
||||||
|
left: 20px;
|
||||||
|
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 {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
left: 80px;
|
||||||
|
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 {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
padding: 20px;
|
||||||
gap: 1rem;
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title-section {
|
.finds-list-container {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
.finds-title {
|
|
||||||
font-family: 'Washington', serif;
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.create-find-button) {
|
:global(.create-find-button) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 2rem;
|
|
||||||
right: 2rem;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: hsl(var(--primary));
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
cursor: pointer;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.main-content {
|
.sidebar-toggle {
|
||||||
|
top: auto;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-sidebar {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
height: 50vh;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-sidebar.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header {
|
||||||
padding: 16px;
|
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 {
|
|
||||||
display: flex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-section :global(.map-container) {
|
.map-section :global(.map-container) {
|
||||||
height: 300px;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.main-content {
|
.finds-sidebar {
|
||||||
padding: 12px;
|
height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-sticky-header {
|
.finds-header {
|
||||||
padding: 12px 12px 8px 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
|
|||||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
import { getSignedR2Url } from '$lib/server/r2';
|
import { getLocalR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
function generateFindId(): string {
|
function generateFindId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
@@ -176,31 +176,24 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
// Generate signed URLs for all media items
|
// Generate signed URLs for all media items
|
||||||
const mediaWithSignedUrls = await Promise.all(
|
const mediaWithSignedUrls = await Promise.all(
|
||||||
findMedia.map(async (mediaItem) => {
|
findMedia.map(async (mediaItem) => {
|
||||||
// URLs in database are now paths, generate signed URLs directly
|
// URLs in database are now paths, generate local proxy URLs
|
||||||
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
const localUrl = getLocalR2Url(mediaItem.url);
|
||||||
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mediaItem,
|
...mediaItem,
|
||||||
url: signedUrl,
|
url: localUrl,
|
||||||
thumbnailUrl: signedThumbnailUrl
|
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;
|
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||||
try {
|
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
|
||||||
userProfilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { findComment, user } from '$lib/server/db/schema';
|
import { findComment, user, find } from '$lib/server/db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
const session = locals.session;
|
const session = locals.session;
|
||||||
@@ -74,7 +76,7 @@ export const POST: RequestHandler = async ({ params, locals, request }) => {
|
|||||||
const commentId = crypto.randomUUID();
|
const commentId = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const [newComment] = await db
|
await db
|
||||||
.insert(findComment)
|
.insert(findComment)
|
||||||
.values({
|
.values({
|
||||||
id: commentId,
|
id: commentId,
|
||||||
@@ -108,6 +110,57 @@ export const POST: RequestHandler = async ({ params, locals, request }) => {
|
|||||||
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
data: commentWithUser[0]
|
data: commentWithUser[0]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
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 { eq, and, count } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
function generateLikeId(): string {
|
function generateLikeId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
@@ -59,6 +61,49 @@ export async function POST({
|
|||||||
|
|
||||||
const likeCount = likeCountResult[0]?.count ?? 0;
|
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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
likeId,
|
likeId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { findComment, user } from '$lib/server/db/schema';
|
import { findComment } from '$lib/server/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { db } from '$lib/server/db';
|
|||||||
import { friendship, user } from '$lib/server/db/schema';
|
import { friendship, user } from '$lib/server/db/schema';
|
||||||
import { eq, and, or } from 'drizzle-orm';
|
import { eq, and, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
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 {
|
function generateFriendshipId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
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)));
|
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed URLs for profile pictures
|
// Generate local proxy URLs for profile pictures
|
||||||
const friendshipsWithSignedUrls = await Promise.all(
|
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
|
||||||
(friendships || []).map(async (friendship) => {
|
|
||||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
try {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...friendship,
|
...friendship,
|
||||||
friendProfilePictureUrl: profilePictureUrl
|
friendProfilePictureUrl: profilePictureUrl
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return json(friendshipsWithSignedUrls);
|
return json(friendshipsWithSignedUrls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -180,6 +175,34 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.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] });
|
return json({ success: true, friendship: newFriendship[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error sending friend request:', err);
|
console.error('Error sending friend request:', err);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
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 { eq } from 'drizzle-orm';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
.where(eq(friendship.id, friendshipId))
|
.where(eq(friendship.id, friendshipId))
|
||||||
.returning();
|
.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] });
|
return json({ success: true, friendship: updatedFriendship[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating friendship:', 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 { db } from '$lib/server/db';
|
||||||
import { user, friendship } from '$lib/server/db/schema';
|
import { user, friendship } from '$lib/server/db/schema';
|
||||||
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
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 }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -63,17 +63,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
friendshipStatusMap.set(otherUserId, f.status);
|
friendshipStatusMap.set(otherUserId, f.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate signed URLs and add friendship status
|
// Generate local proxy URLs and add friendship status
|
||||||
const usersWithSignedUrls = await Promise.all(
|
const usersWithSignedUrls = users.map((userItem) => {
|
||||||
users.map(async (userItem) => {
|
|
||||||
let profilePictureUrl = userItem.profilePictureUrl;
|
let profilePictureUrl = userItem.profilePictureUrl;
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
try {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||||
@@ -83,8 +77,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
profilePictureUrl,
|
profilePictureUrl,
|
||||||
friendshipStatus
|
friendshipStatus
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return json(usersWithSignedUrls);
|
return json(usersWithSignedUrls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -134,10 +134,10 @@
|
|||||||
// Search users when query changes with debounce
|
// Search users when query changes with debounce
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Track searchQuery dependency explicitly
|
if (searchQuery) {
|
||||||
searchQuery;
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(searchUsers, 300);
|
searchTimeout = setTimeout(searchUsers, 300);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle R2 resources with cache-first strategy
|
// Handle R2 resources and local media proxy with cache-first strategy
|
||||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) {
|
||||||
const cachedResponse = await r2Cache.match(event.request);
|
const cachedResponse = await r2Cache.match(event.request);
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
@@ -146,7 +146,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return response;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
// Return cached version if available, or fall through to other cache checks
|
// 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 +222,52 @@ self.addEventListener('fetch', (event) => {
|
|||||||
|
|
||||||
event.respondWith(respond());
|
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