Compare commits
37 Commits
optimaliza
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae339d68e1
|
|||
|
0754d62d0e
|
|||
|
e27b2498b7
|
|||
|
4d288347ab
|
|||
|
d7f803c782
|
|||
|
df675640c2
|
|||
|
2efd4969e7
|
|||
|
b8c88d7a58
|
|||
|
af49ed6237
|
|||
|
d3adac8acc
|
|||
|
9800be0147
|
|||
|
4c973c4e7d
|
|||
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
|||
|
fef7c160e2
|
|||
|
43afa6dacc
|
|||
|
aa9ed77499
|
|||
|
e1c5846fa4
|
|||
|
634ce8adf8
|
|||
|
f547ee5a84
|
|||
|
a01d183072
|
|||
|
fdbd495bdd
|
|||
|
e54c4fb98e
|
|||
|
bee03a57ec
|
|||
|
aea324988d
|
|||
|
067e228393
|
|||
|
b4515d1d6a
|
|||
|
bf542e6b76
|
|||
|
88ed74fde2
|
|||
|
b95c7dad7b
|
|||
|
1d858e40e1
|
|||
|
e0f5595e88
|
|||
|
c454b66b39
|
|||
| 407e1d37b5 | |||
|
c8bae0c53c
|
|||
|
b2d14574d3
|
14
.env.example
14
.env.example
@@ -29,3 +29,17 @@ STACK_SECRET_SERVER_KEY=***********************
|
|||||||
# Google Oauth for google login
|
# Google Oauth for google login
|
||||||
GOOGLE_CLIENT_ID=""
|
GOOGLE_CLIENT_ID=""
|
||||||
GOOGLE_CLIENT_SECRET=""
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Cloudflare R2 Storage for Finds media
|
||||||
|
R2_ACCOUNT_ID=""
|
||||||
|
R2_ACCESS_KEY_ID=""
|
||||||
|
R2_SECRET_ACCESS_KEY=""
|
||||||
|
R2_BUCKET_NAME=""
|
||||||
|
|
||||||
|
# Google Maps API for Places search
|
||||||
|
GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|||||||
45
drizzle/0003_woozy_lily_hollister.sql
Normal file
45
drizzle/0003_woozy_lily_hollister.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE TABLE "find" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"latitude" text NOT NULL,
|
||||||
|
"longitude" text NOT NULL,
|
||||||
|
"location_name" text,
|
||||||
|
"category" text,
|
||||||
|
"is_public" integer DEFAULT 1,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "find_like" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"find_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "find_media" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"find_id" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"thumbnail_url" text,
|
||||||
|
"order_index" integer DEFAULT 0,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "friendship" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"friend_id" text NOT NULL,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "find" ADD CONSTRAINT "find_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_like" ADD CONSTRAINT "find_like_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_like" ADD CONSTRAINT "find_like_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_media" ADD CONSTRAINT "find_media_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "friendship" ADD CONSTRAINT "friendship_friend_id_user_id_fk" FOREIGN KEY ("friend_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
2
drizzle/0004_large_doctor_strange.sql
Normal file
2
drizzle/0004_large_doctor_strange.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "find_media" ADD COLUMN "fallback_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_media" ADD COLUMN "fallback_thumbnail_url" text;
|
||||||
1
drizzle/0005_rapid_warpath.sql
Normal file
1
drizzle/0005_rapid_warpath.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "profile_picture_url" text;
|
||||||
11
drizzle/0006_strange_firebird.sql
Normal file
11
drizzle/0006_strange_firebird.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "find_comment" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"find_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
37
drizzle/0007_grey_dark_beast.sql
Normal file
37
drizzle/0007_grey_dark_beast.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "notification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"data" jsonb,
|
||||||
|
"is_read" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_preferences" (
|
||||||
|
"user_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"friend_requests" boolean DEFAULT true,
|
||||||
|
"friend_accepted" boolean DEFAULT true,
|
||||||
|
"find_liked" boolean DEFAULT true,
|
||||||
|
"find_commented" boolean DEFAULT true,
|
||||||
|
"push_enabled" boolean DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_subscription" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"endpoint" text NOT NULL,
|
||||||
|
"p256dh_key" text NOT NULL,
|
||||||
|
"auth_key" text NOT NULL,
|
||||||
|
"user_agent" text,
|
||||||
|
"is_active" boolean DEFAULT true,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_subscription" ADD CONSTRAINT "notification_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
425
drizzle/meta/0003_snapshot.json
Normal file
425
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
{
|
||||||
|
"id": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
|
||||||
|
"prevId": "277e5a0d-b5f7-40e3-aaf9-c6cd5fe3d0a0",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
437
drizzle/meta/0004_snapshot.json
Normal file
437
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
{
|
||||||
|
"id": "eaa0fec3-527f-4569-9c01-a4802700b646",
|
||||||
|
"prevId": "d3b3c2de-0d5f-4743-9283-6ac2292e8dac",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
443
drizzle/meta/0005_snapshot.json
Normal file
443
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
{
|
||||||
|
"id": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||||
|
"prevId": "eaa0fec3-527f-4569-9c01-a4802700b646",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"profile_picture_url": {
|
||||||
|
"name": "profile_picture_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
521
drizzle/meta/0006_snapshot.json
Normal file
521
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||||
|
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_comment": {
|
||||||
|
"name": "find_comment",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_comment_find_id_find_id_fk": {
|
||||||
|
"name": "find_comment_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_comment_user_id_user_id_fk": {
|
||||||
|
"name": "find_comment_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"profile_picture_url": {
|
||||||
|
"name": "profile_picture_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
764
drizzle/meta/0007_snapshot.json
Normal file
764
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
{
|
||||||
|
"id": "1dbab94c-004e-4d34-b171-408bb1d36c91",
|
||||||
|
"prevId": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_comment": {
|
||||||
|
"name": "find_comment",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_comment_find_id_find_id_fk": {
|
||||||
|
"name": "find_comment_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_comment_user_id_user_id_fk": {
|
||||||
|
"name": "find_comment_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification": {
|
||||||
|
"name": "notification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_read": {
|
||||||
|
"name": "is_read",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_user_id_user_id_fk": {
|
||||||
|
"name": "notification_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification_preferences": {
|
||||||
|
"name": "notification_preferences",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_requests": {
|
||||||
|
"name": "friend_requests",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"friend_accepted": {
|
||||||
|
"name": "friend_accepted",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"find_liked": {
|
||||||
|
"name": "find_liked",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"find_commented": {
|
||||||
|
"name": "find_commented",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"push_enabled": {
|
||||||
|
"name": "push_enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_preferences_user_id_user_id_fk": {
|
||||||
|
"name": "notification_preferences_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification_preferences",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.notification_subscription": {
|
||||||
|
"name": "notification_subscription",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"p256dh_key": {
|
||||||
|
"name": "p256dh_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"auth_key": {
|
||||||
|
"name": "auth_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notification_subscription_user_id_user_id_fk": {
|
||||||
|
"name": "notification_subscription_user_id_user_id_fk",
|
||||||
|
"tableFrom": "notification_subscription",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"profile_picture_url": {
|
||||||
|
"name": "profile_picture_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,41 @@
|
|||||||
"when": 1759502119139,
|
"when": 1759502119139,
|
||||||
"tag": "0002_robust_firedrake",
|
"tag": "0002_robust_firedrake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1760092217884,
|
||||||
|
"tag": "0003_woozy_lily_hollister",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1760456880877,
|
||||||
|
"tag": "0004_large_doctor_strange",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1760631798851,
|
||||||
|
"tag": "0005_rapid_warpath",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762428302491,
|
||||||
|
"tag": "0006_strange_firebird",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762522687342,
|
||||||
|
"tag": "0007_grey_dark_beast",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,17 @@ export default defineConfig(
|
|||||||
rules: {
|
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: '^_'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
BIN
logs/20251007-after-optimalizations/home.pdf
Normal file
BIN
logs/20251007-after-optimalizations/home.pdf
Normal file
Binary file not shown.
BIN
logs/20251007-after-optimalizations/login.pdf
Normal file
BIN
logs/20251007-after-optimalizations/login.pdf
Normal file
Binary file not shown.
BIN
logs/20251014/login.pdf
Normal file
BIN
logs/20251014/login.pdf
Normal file
Binary file not shown.
278
logs/logboek.md
278
logs/logboek.md
@@ -2,23 +2,239 @@
|
|||||||
|
|
||||||
## Oktober 2025
|
## Oktober 2025
|
||||||
|
|
||||||
### 7 Oktober 2025 (Maandag) - 4 uren
|
### 4 November 2025 - 1 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **UI Consistency & Media Layout Improvements**
|
||||||
|
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
|
||||||
|
- Media layout en sizing verbeteringen voor betere responsive design
|
||||||
|
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
|
||||||
|
- Component refactoring voor consistentere UI across applicatie
|
||||||
|
- Loading states en fallback styling geconsolideerd
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- d3adac8 - add:ProfilePicture component and replace Avatar
|
||||||
|
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
|
||||||
|
- Avatar componenten vervangen door ProfilePicture voor consistentie
|
||||||
|
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
|
||||||
|
- Max-height van 600px toegevoegd voor images en videos met height:auto
|
||||||
|
- Object-fit: contain toegepast voor media images in preview
|
||||||
|
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
|
||||||
|
- Loading states UI geconsolideerd in ProfilePicture component
|
||||||
|
- Component library verder uitgebreid met herbruikbare UI elementen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 27-29 Oktober 2025 - 10 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Phase 3: Google Places Integration & Sync Service**
|
||||||
|
- Complete implementatie van sync-service voor API data synchronisatie
|
||||||
|
- Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||||
|
- Location tracking optimalisaties met continuous watching
|
||||||
|
- CSP (Content Security Policy) fixes voor verbeterde security
|
||||||
|
- Child subscription fixes voor data consistency
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- 4c973c4 - fix:some csp issues
|
||||||
|
- d7fe909 - fix:new child subscription
|
||||||
|
- 6620cc6 - feat:implement a sync-service
|
||||||
|
- 3b3ebc2 - fix:continuously watch location
|
||||||
|
- fef7c16 - feat:use GMaps places api for searching poi's
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Complete sync-service architectuur voor real-time data synchronisatie
|
||||||
|
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
|
||||||
|
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
|
||||||
|
- Security verbeteringen door CSP issues op te lossen
|
||||||
|
- Data consistency verbeteringen met child subscription fixes
|
||||||
|
- Enhanced POI search capabilities met Google Maps integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 21 Oktober 2025 (Maandag) - 4 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **UI Refinement & Bug Fixes**
|
||||||
|
- Create Find Modal UI refactoring met verbeterde layout
|
||||||
|
- Finds list en header layout updates voor betere UX
|
||||||
|
- Friends filtering logica fixes
|
||||||
|
- Friends en users search functionaliteit verbeteringen
|
||||||
|
- Modal interface optimalisaties
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- aa9ed77 - UI:Refactor create find modal UI and update finds list/header layout
|
||||||
|
- e1c5846 - fix:friends filtering
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Verbeterde modal interface met consistente styling
|
||||||
|
- Fixed filtering logica voor vriendensysteem
|
||||||
|
- Enhanced search functionaliteit voor gebruikers en vrienden
|
||||||
|
- UI/UX verbeteringen voor betere gebruikerservaring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20 Oktober 2025 (Zondag) - 2 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Search Logic Improvements**
|
||||||
|
- Friends en users search logica geoptimaliseerd
|
||||||
|
- Filtering verbeteringen voor vriendschapssysteem
|
||||||
|
- Backend search algoritmes verfijnd
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- 634ce8a - fix:logic of friends and users search
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Verbeterde zoekalgoritmes voor gebruikers
|
||||||
|
- Geoptimaliseerde filtering voor vriendensysteem
|
||||||
|
- Backend logica verfijning voor betere performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16 Oktober 2025 (Woensdag) - 6 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Phase 2D: Friends & Privacy System - Volledige implementatie**
|
||||||
|
- Complete vriendschapssysteem geïmplementeerd (verzenden/accepteren/weigeren/verwijderen)
|
||||||
|
- Friends management pagina ontwikkeld met gebruikerszoekfunctionaliteit
|
||||||
|
- Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||||
|
- API endpoints voor vriendschapsbeheer (/api/friends, /api/friends/[friendshipId])
|
||||||
|
- Gebruikerszoek API met vriendschapsstatus integratie (/api/users)
|
||||||
|
- FindsFilter component met 4 filteropties (All/Public/Friends/Mine)
|
||||||
|
- Hoofdpagina uitgebreid met geïntegreerde filteringfunctionaliteit
|
||||||
|
- ProfilePanel uitgebreid met Friends navigatielink
|
||||||
|
- Type-safe implementatie met volledige error handling
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- f547ee5 - add:logs
|
||||||
|
- a01d183 - feat:friends
|
||||||
|
- fdbd495 - add:cache for r2 storage
|
||||||
|
- e54c4fb - feat:profile pictures
|
||||||
|
- bee03a5 - feat:use dynamic sheet for findpreview
|
||||||
|
- aea3249 - fix:likes;UI:find card&list
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Complete sociale verbindingssysteem voor gebruikers
|
||||||
|
- Real-time filtering van finds op basis van privacy instellingen
|
||||||
|
- SHADCN componenten gebruikt voor consistente UI (Cards, Badges, Avatars, Dropdowns)
|
||||||
|
- Svelte 5 patterns toegepast met $state, $derived, en $props runes
|
||||||
|
- Bestaande friendship table schema optimaal benut zonder wijzigingen
|
||||||
|
- Comprehensive authentication en authorization op alle endpoints
|
||||||
|
- Mobile-responsive design met aangepaste styling voor kleinere schermen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14 Oktober 2025 (Maandag) - 8 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Phase 2A & 2C: Modern Media + Social Features**
|
||||||
|
- Video support met custom VideoPlayer component geïmplementeerd
|
||||||
|
- WebP image processing met JPEG fallbacks toegevoegd
|
||||||
|
- Like/unlike systeem met optimistic UI updates
|
||||||
|
- Database schema uitgebreid met fallback media URLs
|
||||||
|
- LikeButton component met animaties ontwikkeld
|
||||||
|
- API endpoints voor like functionality (/api/finds/[findId]/like)
|
||||||
|
- Media processor uitgebreid voor moderne formaten
|
||||||
|
- CSP headers bijgewerkt voor video support
|
||||||
|
- Volledige UI integratie in FindCard en FindPreview componenten
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
|
||||||
|
- 067e228 - feat:video player, like button, and media fallbacks
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Complete video playback systeem met custom controls
|
||||||
|
- Modern WebP/JPEG image processing pipeline
|
||||||
|
- Social interaction systeem met real-time like counts
|
||||||
|
- Enhanced media carousel met video support
|
||||||
|
- Type-safe interfaces voor alle nieuwe functionaliteit
|
||||||
|
- Backward compatibility behouden voor bestaande media
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13 Oktober 2025 (Zondag) - 4 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **API architectuur verbetering**
|
||||||
|
- Finds weergave op homepage geïmplementeerd
|
||||||
|
- API logic verplaatst van page servers naar dedicated API routes
|
||||||
|
- Code organisatie en separation of concerns verbeterd
|
||||||
|
- Homepage uitgebreid met Finds functionaliteit
|
||||||
|
|
||||||
|
**Commits:** 2 commits (b95c7da, 88ed74f)
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Betere API structuur volgens SvelteKit best practices
|
||||||
|
- Finds integratie op hoofdpagina
|
||||||
|
- Code cleanup en organisatie
|
||||||
|
- Improved separation tussen frontend en API logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10 Oktober 2025 (Donderdag) - 6 uren
|
||||||
|
|
||||||
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
|
- **Finds feature implementatie**
|
||||||
|
- Media upload functionaliteit met Cloudflare R2 storage
|
||||||
|
- Signed URLs voor veilige media toegang
|
||||||
|
- R2 bucket configuratie en integratie
|
||||||
|
- Overscroll behavior verbeteringen
|
||||||
|
- Code refactoring en cleanup
|
||||||
|
|
||||||
|
**Commits:** 3 commits (c454b66, e0f5595, 1d858e4)
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
|
||||||
|
- Complete media upload systeem met R2
|
||||||
|
- Veilige URL signing voor uploaded bestanden
|
||||||
|
- Finds feature als kernfunctionaliteit
|
||||||
|
- Storage optimalisaties
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7 Oktober 2025 (Maandag) - 5 uren
|
||||||
|
|
||||||
**Werk uitgevoerd:**
|
**Werk uitgevoerd:**
|
||||||
|
|
||||||
- **Grote SEO, PWA en performance optimalisaties**
|
- **Grote SEO, PWA en performance optimalisaties**
|
||||||
- Logo padding gefixed
|
- Logo padding gefixed
|
||||||
- Favicon bestanden opgeruimd (verwijderd oude favicon bestanden)
|
- Favicon bestanden opgeruimd (verwijderd oude favicon bestanden)
|
||||||
- Manifest.json geoptimaliseerd
|
- Manifest.json geoptimaliseerd en naming fixes
|
||||||
- Sitemap.xml automatisch gegenereerd
|
- Sitemap.xml automatisch gegenereerd
|
||||||
- Service worker uitgebreid voor caching
|
- Service worker uitgebreid voor caching
|
||||||
- Meta tags en Open Graph voor SEO
|
- Meta tags en Open Graph voor SEO
|
||||||
- Background afbeelding gecomprimeerd (50% kleiner)
|
- Background afbeelding gecomprimeerd (50% kleiner)
|
||||||
- Performance logs/PDFs opgeslagen voor vergelijking
|
- Performance logs/PDFs opgeslagen (voor én na optimalisaties)
|
||||||
- Vite config optimalisaties
|
- Vite config optimalisaties
|
||||||
- Build issues opgelost
|
- Build issues opgelost
|
||||||
|
- CSP (Content Security Policy) issues gefixed
|
||||||
|
- Lighthouse performance logs toegevoegd
|
||||||
|
|
||||||
**Commits:** 3 commits (8d3922e, 716c05c, 5f0cae6)
|
**Commits:** 6 commits (c8bae0c, b2d1457, 63f7e0c, a806664, 8d3922e, 716c05c, 5f0cae6)
|
||||||
|
|
||||||
**Details:**
|
**Details:**
|
||||||
|
|
||||||
@@ -27,6 +243,8 @@
|
|||||||
- Performance optimalisaties door image compression
|
- Performance optimalisaties door image compression
|
||||||
- Automatische sitemap generatie
|
- Automatische sitemap generatie
|
||||||
- Service worker caching voor offline functionaliteit
|
- Service worker caching voor offline functionaliteit
|
||||||
|
- CSP security verbeteringen
|
||||||
|
- Performance monitoring met Lighthouse logs (voor/na vergelijking)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,9 +381,9 @@
|
|||||||
|
|
||||||
## Totaal Overzicht
|
## Totaal Overzicht
|
||||||
|
|
||||||
**Totale geschatte uren:** 36 uren
|
**Totale geschatte uren:** 80 uren
|
||||||
**Werkdagen:** 6 dagen
|
**Werkdagen:** 14 dagen
|
||||||
**Gemiddelde uren per dag:** 6 uur
|
**Gemiddelde uren per dag:** 5.8 uur
|
||||||
|
|
||||||
### Project Milestones:
|
### Project Milestones:
|
||||||
|
|
||||||
@@ -175,17 +393,41 @@
|
|||||||
4. **29 Sept**: Component architectuur verbetering
|
4. **29 Sept**: Component architectuur verbetering
|
||||||
5. **2-3 Okt**: Maps en location features
|
5. **2-3 Okt**: Maps en location features
|
||||||
6. **7 Okt**: SEO, PWA en performance optimalisaties
|
6. **7 Okt**: SEO, PWA en performance optimalisaties
|
||||||
|
7. **10 Okt**: Finds feature en media upload systeem
|
||||||
|
8. **13 Okt**: API architectuur verbetering
|
||||||
|
9. **14 Okt**: Modern media support en social interactions
|
||||||
|
10. **16 Okt**: Friends & Privacy System implementatie
|
||||||
|
11. **20 Okt**: Search logic improvements
|
||||||
|
12. **21 Okt**: UI refinement en bug fixes
|
||||||
|
13. **27-29 Okt**: Google Places Integration & Sync Service
|
||||||
|
14. **4 Nov**: UI Consistency & Media Layout Improvements
|
||||||
|
|
||||||
### Hoofdfunctionaliteiten geïmplementeerd:
|
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||||
|
|
||||||
- ✅ Gebruikersauthenticatie (Lucia + Google OAuth)
|
- [x] Gebruikersauthenticatie (Lucia + Google OAuth)
|
||||||
- ✅ Responsive UI met custom componenten
|
- [x] Responsive UI met custom componenten
|
||||||
- ✅ Real-time locatie tracking
|
- [x] Real-time locatie tracking
|
||||||
- ✅ Interactive maps (MapLibre GL JS)
|
- [x] Interactive maps (MapLibre GL JS)
|
||||||
- ✅ PWA functionaliteit
|
- [x] PWA functionaliteit
|
||||||
- ✅ Docker deployment
|
- [x] Docker deployment
|
||||||
- ✅ Database (PostgreSQL + Drizzle ORM)
|
- [x] Database (PostgreSQL + Drizzle ORM)
|
||||||
- ✅ Toast notifications
|
- [x] Toast notifications
|
||||||
- ✅ Loading states en error handling
|
- [x] Loading states en error handling
|
||||||
- ✅ SEO optimalisatie (meta tags, Open Graph, sitemap)
|
- [x] SEO optimalisatie (meta tags, Open Graph, sitemap)
|
||||||
- ✅ Performance optimalisaties (image compression, caching)
|
- [x] Performance optimalisaties (image compression, caching)
|
||||||
|
- [x] Finds feature met media upload
|
||||||
|
- [x] Cloudflare R2 storage integratie
|
||||||
|
- [x] Signed URLs voor veilige media toegang
|
||||||
|
- [x] API architectuur verbetering
|
||||||
|
- [x] Video support met custom VideoPlayer component
|
||||||
|
- [x] WebP image processing met JPEG fallbacks
|
||||||
|
- [x] Like/unlike systeem met real-time updates
|
||||||
|
- [x] Social interactions en animated UI components
|
||||||
|
- [x] Friends & Privacy System met vriendschapsverzoeken
|
||||||
|
- [x] Privacy-bewuste find filtering met vriendenspecifieke zichtbaarheid
|
||||||
|
- [x] Friends management pagina met gebruikerszoekfunctionaliteit
|
||||||
|
- [x] Real-time find filtering op basis van privacy instellingen
|
||||||
|
- [x] Google Maps Places API integratie voor POI zoekfunctionaliteit
|
||||||
|
- [x] Sync-service voor API data synchronisatie
|
||||||
|
- [x] Continuous location watching voor nauwkeurige tracking
|
||||||
|
- [x] CSP security verbeteringen
|
||||||
|
|||||||
@@ -54,11 +54,17 @@
|
|||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.907.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.907.0",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
|
"lucide-svelte": "^0.553.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"svelte-maplibre": "^1.2.1"
|
"sharp": "^0.34.4",
|
||||||
|
"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!');
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
"worker-src 'self' blob:; " +
|
"worker-src 'self' blob:; " +
|
||||||
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
|
||||||
"font-src 'self' fonts.gstatic.com; " +
|
"font-src 'self' fonts.gstatic.com; " +
|
||||||
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org; " +
|
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
"connect-src 'self' *.openstreetmap.org; " +
|
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
|
||||||
|
"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';"
|
||||||
|
|||||||
141
src/lib/components/Comment.svelte
Normal file
141
src/lib/components/Comment.svelte
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import { Trash2 } from '@lucide/svelte';
|
||||||
|
import type { CommentState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
|
interface CommentProps {
|
||||||
|
comment: CommentState;
|
||||||
|
showDeleteButton?: boolean;
|
||||||
|
onDelete?: (commentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete(comment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string): string {
|
||||||
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - dateObj.getTime();
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (minutes < 1) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}d`;
|
||||||
|
} else {
|
||||||
|
return dateObj.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<ProfilePicture
|
||||||
|
username={comment.user.username}
|
||||||
|
profilePictureUrl={comment.user.profilePictureUrl}
|
||||||
|
class="avatar-small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-content">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-username">@{comment.user.username}</span>
|
||||||
|
<span class="comment-time">{formatDate(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-text">
|
||||||
|
{comment.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteButton}
|
||||||
|
<div class="comment-actions">
|
||||||
|
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar-small) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delete-button) {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
padding: 0.25rem;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delete-button:hover) {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
background: hsl(var(--destructive) / 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
src/lib/components/CommentForm.svelte
Normal file
119
src/lib/components/CommentForm.svelte
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { Input } from '$lib/components/input';
|
||||||
|
import { Send } from '@lucide/svelte';
|
||||||
|
|
||||||
|
interface CommentFormProps {
|
||||||
|
onSubmit: (content: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSubmit, placeholder = 'Add a comment...', disabled = false }: CommentFormProps = $props();
|
||||||
|
|
||||||
|
let content = $state('');
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (!trimmedContent || isSubmitting || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedContent.length > 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
onSubmit(trimmedContent);
|
||||||
|
content = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting comment:', error);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
content.trim().length > 0 && content.length <= 500 && !isSubmitting && !disabled
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="comment-form" onsubmit={handleSubmit}>
|
||||||
|
<div class="input-container">
|
||||||
|
<Input
|
||||||
|
bind:value={content}
|
||||||
|
{placeholder}
|
||||||
|
disabled={disabled || isSubmitting}
|
||||||
|
class="comment-input"
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="ghost" size="sm" disabled={!canSubmit} class="submit-button">
|
||||||
|
<Send size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if content.length > 450}
|
||||||
|
<div class="character-count" class:warning={content.length > 500}>
|
||||||
|
{content.length}/500
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comment-form {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.comment-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.submit-button) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.submit-button:not(:disabled)) {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.submit-button:not(:disabled):hover) {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-count.warning {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
243
src/lib/components/CommentsList.svelte
Normal file
243
src/lib/components/CommentsList.svelte
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Comment from '$lib/components/Comment.svelte';
|
||||||
|
import CommentForm from '$lib/components/CommentForm.svelte';
|
||||||
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
import { apiSync } from '$lib/stores/api-sync';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface CommentsListProps {
|
||||||
|
findId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
maxComments?: number;
|
||||||
|
showCommentForm?: boolean;
|
||||||
|
isScrollable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
findId,
|
||||||
|
currentUserId,
|
||||||
|
collapsed = true,
|
||||||
|
maxComments,
|
||||||
|
showCommentForm = true,
|
||||||
|
isScrollable = false
|
||||||
|
}: CommentsListProps = $props();
|
||||||
|
|
||||||
|
let isExpanded = $state(!collapsed);
|
||||||
|
let hasLoadedComments = $state(false);
|
||||||
|
|
||||||
|
const commentsState = apiSync.subscribeFindComments(findId);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (isExpanded && !hasLoadedComments) {
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
if (hasLoadedComments) return;
|
||||||
|
|
||||||
|
hasLoadedComments = true;
|
||||||
|
await apiSync.loadComments(findId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
isExpanded = !isExpanded;
|
||||||
|
if (isExpanded && !hasLoadedComments) {
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddComment(content: string) {
|
||||||
|
await apiSync.addComment(findId, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteComment(commentId: string) {
|
||||||
|
await apiSync.deleteComment(commentId, findId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeleteComment(comment: { user: { id: string } }): boolean {
|
||||||
|
return Boolean(
|
||||||
|
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet loadingSkeleton()}
|
||||||
|
<div class="loading-skeleton">
|
||||||
|
{#each Array(3) as _, index (index)}
|
||||||
|
<div class="comment-skeleton">
|
||||||
|
<Skeleton class="avatar-skeleton" />
|
||||||
|
<div class="content-skeleton">
|
||||||
|
<Skeleton class="header-skeleton" />
|
||||||
|
<Skeleton class="text-skeleton" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="comments-list">
|
||||||
|
{#if collapsed}
|
||||||
|
<button class="toggle-button" onclick={toggleExpanded}>
|
||||||
|
{#if isExpanded}
|
||||||
|
Hide comments ({$commentsState.commentCount})
|
||||||
|
{:else if $commentsState.commentCount > 0}
|
||||||
|
View comments ({$commentsState.commentCount})
|
||||||
|
{:else}
|
||||||
|
Add comment
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="comments-container">
|
||||||
|
{#if $commentsState.isLoading && !hasLoadedComments}
|
||||||
|
{@render loadingSkeleton()}
|
||||||
|
{:else if $commentsState.error}
|
||||||
|
<div class="error-message">
|
||||||
|
Failed to load comments.
|
||||||
|
<button class="retry-button" onclick={loadComments}> Try again </button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="comments" class:scrollable={isScrollable}>
|
||||||
|
{#each maxComments ? $commentsState.comments.slice(0, maxComments) : $commentsState.comments as comment (comment.id)}
|
||||||
|
<Comment
|
||||||
|
{comment}
|
||||||
|
showDeleteButton={canDeleteComment(comment)}
|
||||||
|
onDelete={handleDeleteComment}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
|
{/each}
|
||||||
|
{#if maxComments && $commentsState.comments.length > maxComments}
|
||||||
|
<div class="see-more">
|
||||||
|
<div class="see-more-text">
|
||||||
|
+{$commentsState.comments.length - maxComments} more comments
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCommentForm}
|
||||||
|
<CommentForm onSubmit={handleAddComment} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comments-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-container {
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments.scrollable {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.see-more {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.see-more-text {
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
color: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-skeleton {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar-skeleton) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-skeleton {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.header-skeleton) {
|
||||||
|
width: 120px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.text-skeleton) {
|
||||||
|
width: 80%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
615
src/lib/components/CreateFindModal.svelte
Normal file
615
src/lib/components/CreateFindModal.svelte
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||||
|
import { Input } from '$lib/components/input';
|
||||||
|
import { Label } from '$lib/components/label';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import POISearch from './POISearch.svelte';
|
||||||
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onFindCreated: (event: CustomEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, onClose, onFindCreated }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let latitude = $state('');
|
||||||
|
let longitude = $state('');
|
||||||
|
let locationName = $state('');
|
||||||
|
let category = $state('cafe');
|
||||||
|
let isPublic = $state(true);
|
||||||
|
let selectedFiles = $state<FileList | null>(null);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
|
||||||
|
let useManualLocation = $state(false);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ value: 'cafe', label: 'Café' },
|
||||||
|
{ value: 'restaurant', label: 'Restaurant' },
|
||||||
|
{ value: 'park', label: 'Park' },
|
||||||
|
{ value: 'landmark', label: 'Landmark' },
|
||||||
|
{ value: 'shop', label: 'Shop' },
|
||||||
|
{ value: 'museum', label: 'Museum' },
|
||||||
|
{ value: 'other', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let showModal = $state(true);
|
||||||
|
let isMobile = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile = window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!showModal) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showModal && $coordinates) {
|
||||||
|
latitude = $coordinates.latitude.toString();
|
||||||
|
longitude = $coordinates.longitude.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
selectedFiles = target.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMedia(): Promise<void> {
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Array.from(selectedFiles).forEach((file) => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch('/api/finds/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload media');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
uploadedMedia = result.media;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const lat = parseFloat(latitude);
|
||||||
|
const lng = parseFloat(longitude);
|
||||||
|
|
||||||
|
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
await uploadMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/finds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
locationName: locationName.trim() || null,
|
||||||
|
category,
|
||||||
|
isPublic,
|
||||||
|
media: uploadedMedia
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create find');
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
showModal = false;
|
||||||
|
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating find:', error);
|
||||||
|
alert('Failed to create find. Please try again.');
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlaceSelected(place: PlaceResult) {
|
||||||
|
locationName = place.name;
|
||||||
|
latitude = place.latitude.toString();
|
||||||
|
longitude = place.longitude.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocationMode() {
|
||||||
|
useManualLocation = !useManualLocation;
|
||||||
|
if (!useManualLocation && $coordinates) {
|
||||||
|
latitude = $coordinates.latitude.toString();
|
||||||
|
longitude = $coordinates.longitude.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
title = '';
|
||||||
|
description = '';
|
||||||
|
locationName = '';
|
||||||
|
latitude = '';
|
||||||
|
longitude = '';
|
||||||
|
category = 'cafe';
|
||||||
|
isPublic = true;
|
||||||
|
selectedFiles = null;
|
||||||
|
uploadedMedia = [];
|
||||||
|
useManualLocation = false;
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
resetForm();
|
||||||
|
showModal = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||||
|
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Create Find</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
class="form"
|
||||||
|
>
|
||||||
|
<div class="form-content">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="title">What did you find?</Label>
|
||||||
|
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label for="description">Tell us about it</Label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
placeholder="The best cappuccino in town..."
|
||||||
|
maxlength="500"
|
||||||
|
bind:value={description}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-section">
|
||||||
|
<div class="location-header">
|
||||||
|
<Label>Location</Label>
|
||||||
|
<button type="button" onclick={toggleLocationMode} class="toggle-button">
|
||||||
|
{useManualLocation ? 'Use Search' : 'Manual Entry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if useManualLocation}
|
||||||
|
<div class="field">
|
||||||
|
<Label for="location-name">Location name</Label>
|
||||||
|
<Input
|
||||||
|
name="location-name"
|
||||||
|
placeholder="Café Central, Brussels"
|
||||||
|
bind:value={locationName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<POISearch
|
||||||
|
onPlaceSelected={handlePlaceSelected}
|
||||||
|
placeholder="Search for cafés, restaurants, landmarks..."
|
||||||
|
label=""
|
||||||
|
showNearbyButton={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="category">Category</Label>
|
||||||
|
<select name="category" bind:value={category} class="select">
|
||||||
|
{#each categories as cat (cat.value)}
|
||||||
|
<option value={cat.value}>{cat.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Label>Privacy</Label>
|
||||||
|
<label class="privacy-toggle">
|
||||||
|
<input type="checkbox" bind:checked={isPublic} />
|
||||||
|
<span>{isPublic ? 'Public' : 'Private'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<Label for="media-files">Add photo or video</Label>
|
||||||
|
<div class="file-upload">
|
||||||
|
<input
|
||||||
|
id="media-files"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||||
|
onchange={handleFileChange}
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="file-content">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 4V10H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Click to upload</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if selectedFiles && selectedFiles.length > 0}
|
||||||
|
<div class="file-selected">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 4V10H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{selectedFiles[0].name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if useManualLocation || (!latitude && !longitude)}
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="latitude">Latitude</Label>
|
||||||
|
<Input name="latitude" type="text" required bind:value={latitude} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Label for="longitude">Longitude</Label>
|
||||||
|
<Input name="longitude" type="text" required bind:value={longitude} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if latitude && longitude}
|
||||||
|
<div class="coordinates-display">
|
||||||
|
<Label>Selected coordinates</Label>
|
||||||
|
<div class="coordinates-info">
|
||||||
|
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
|
||||||
|
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (useManualLocation = true)}
|
||||||
|
class="edit-coords-button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !title.trim()}>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create Find'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.create-find-sheet) {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:global(.create-find-sheet) {
|
||||||
|
height: 90vh;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.field-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-toggle:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-toggle input[type='checkbox'] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload {
|
||||||
|
position: relative;
|
||||||
|
border: 2px dashed hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content span {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected svg {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions :global(button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-coords-button:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-content {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions :global(button) {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
337
src/lib/components/FindCard.svelte
Normal file
337
src/lib/components/FindCard.svelte
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { Badge } from '$lib/components/badge';
|
||||||
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import CommentsList from '$lib/components/CommentsList.svelte';
|
||||||
|
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
||||||
|
|
||||||
|
interface FindCardProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
locationName?: string;
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
};
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
likeCount?: number;
|
||||||
|
isLiked?: boolean;
|
||||||
|
commentCount?: number;
|
||||||
|
currentUserId?: string;
|
||||||
|
onExplore?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
locationName,
|
||||||
|
user,
|
||||||
|
media,
|
||||||
|
likeCount = 0,
|
||||||
|
isLiked = false,
|
||||||
|
commentCount = 0,
|
||||||
|
currentUserId,
|
||||||
|
onExplore
|
||||||
|
}: FindCardProps = $props();
|
||||||
|
|
||||||
|
let showComments = $state(false);
|
||||||
|
|
||||||
|
function handleExplore() {
|
||||||
|
onExplore?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComments() {
|
||||||
|
showComments = !showComments;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="find-card">
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="post-header">
|
||||||
|
<div class="user-info">
|
||||||
|
<ProfilePicture
|
||||||
|
username={user.username}
|
||||||
|
profilePictureUrl={user.profilePictureUrl}
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="username">@{user.username}</div>
|
||||||
|
{#if locationName}
|
||||||
|
<div class="location">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<span>{locationName}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" class="more-button">
|
||||||
|
<Ellipsis size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
<div class="post-content">
|
||||||
|
<div class="content-header">
|
||||||
|
<h3 class="post-title">{title}</h3>
|
||||||
|
{#if category}
|
||||||
|
<Badge variant="secondary" class="category-badge">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if description}
|
||||||
|
<p class="post-description">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media -->
|
||||||
|
{#if media && media.length > 0}
|
||||||
|
<div class="post-media">
|
||||||
|
{#if media[0].type === 'photo'}
|
||||||
|
<img
|
||||||
|
src={media[0].thumbnailUrl || media[0].url}
|
||||||
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
|
class="media-image"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<VideoPlayer
|
||||||
|
src={media[0].url}
|
||||||
|
poster={media[0].thumbnailUrl}
|
||||||
|
muted={true}
|
||||||
|
autoplay={false}
|
||||||
|
controls={true}
|
||||||
|
class="media-video"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Post Actions -->
|
||||||
|
<div class="post-actions">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
||||||
|
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
|
||||||
|
<MessageCircle size={16} />
|
||||||
|
<span>{commentCount || 'comment'}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" class="action-button">
|
||||||
|
<Share size={16} />
|
||||||
|
<span>share</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onclick={handleExplore} class="explore-button">
|
||||||
|
explore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
{#if showComments}
|
||||||
|
<div class="comments-section">
|
||||||
|
<CommentsList
|
||||||
|
findId={id}
|
||||||
|
{currentUserId}
|
||||||
|
collapsed={false}
|
||||||
|
maxComments={5}
|
||||||
|
showCommentForm={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.find-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post Header */
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1rem 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar-fallback) {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.more-button) {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post Content */
|
||||||
|
.post-content {
|
||||||
|
padding: 0 1rem 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.category-badge) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media */
|
||||||
|
.post-media {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 600px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.media-video) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post Actions */
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.action-button) {
|
||||||
|
gap: 0.375rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.action-button:hover) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.explore-button) {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments Section */
|
||||||
|
.comments-section {
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.post-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.explore-button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
616
src/lib/components/FindPreview.svelte
Normal file
616
src/lib/components/FindPreview.svelte
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
||||||
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import CommentsList from '$lib/components/CommentsList.svelte';
|
||||||
|
|
||||||
|
interface Find {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
};
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
likeCount?: number;
|
||||||
|
isLiked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
find: Find | null;
|
||||||
|
onClose: () => void;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { find, onClose, currentUserId }: Props = $props();
|
||||||
|
|
||||||
|
let showModal = $state(true);
|
||||||
|
let currentMediaIndex = $state(0);
|
||||||
|
let isMobile = $state(false);
|
||||||
|
|
||||||
|
// Detect screen size
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
isMobile = window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal when showModal changes to false
|
||||||
|
$effect(() => {
|
||||||
|
if (!showModal) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function nextMedia() {
|
||||||
|
if (!find?.media) return;
|
||||||
|
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMedia() {
|
||||||
|
if (!find?.media) return;
|
||||||
|
currentMediaIndex = currentMediaIndex === 0 ? find.media.length - 1 : currentMediaIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirections() {
|
||||||
|
if (!find) return;
|
||||||
|
|
||||||
|
const lat = parseFloat(find.latitude);
|
||||||
|
const lng = parseFloat(find.longitude);
|
||||||
|
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareFindUrl() {
|
||||||
|
if (!find) return;
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/finds/${find.id}`;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: find.title,
|
||||||
|
text: find.description || `Check out this find: ${find.title}`,
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
alert('Find URL copied to clipboard!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if find}
|
||||||
|
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||||
|
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
||||||
|
<SheetHeader class="sheet-header">
|
||||||
|
<div class="user-section">
|
||||||
|
<ProfilePicture
|
||||||
|
username={find.user.username}
|
||||||
|
profilePictureUrl={find.user.profilePictureUrl}
|
||||||
|
class="user-avatar"
|
||||||
|
/>
|
||||||
|
<div class="user-info">
|
||||||
|
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
||||||
|
<div class="find-meta">
|
||||||
|
<span class="username">@{find.user.username}</span>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="date">{formatDate(find.createdAt)}</span>
|
||||||
|
{#if find.category}
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="category">{find.category}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div class="sheet-body">
|
||||||
|
{#if find.media && find.media.length > 0}
|
||||||
|
<div class="media-container">
|
||||||
|
<div class="media-viewer">
|
||||||
|
{#if find.media[currentMediaIndex].type === 'photo'}
|
||||||
|
<img src={find.media[currentMediaIndex].url} alt={find.title} class="media-image" />
|
||||||
|
{:else}
|
||||||
|
<VideoPlayer
|
||||||
|
src={find.media[currentMediaIndex].url}
|
||||||
|
poster={find.media[currentMediaIndex].thumbnailUrl}
|
||||||
|
class="media-video"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if find.media.length > 1}
|
||||||
|
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M15 18L9 12L15 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M9 18L15 12L9 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if find.media.length > 1}
|
||||||
|
<div class="media-indicators">
|
||||||
|
{#each find.media, index (index)}
|
||||||
|
<button
|
||||||
|
class="indicator"
|
||||||
|
class:active={index === currentMediaIndex}
|
||||||
|
onclick={() => (currentMediaIndex = index)}
|
||||||
|
aria-label={`View media ${index + 1}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="content-section">
|
||||||
|
{#if find.description}
|
||||||
|
<p class="description">{find.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if find.locationName}
|
||||||
|
<div class="location-info">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<span>{find.locationName}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<LikeButton
|
||||||
|
findId={find.id}
|
||||||
|
isLiked={find.isLiked || false}
|
||||||
|
likeCount={find.likeCount || 0}
|
||||||
|
size="default"
|
||||||
|
class="like-action"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="action-button primary" onclick={getDirections}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M3 11L22 2L13 21L11 13L3 11Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Directions
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="action-button secondary" onclick={shareFindUrl}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="16,6 12,2 8,6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<CommentsList
|
||||||
|
findId={find.id}
|
||||||
|
{currentUserId}
|
||||||
|
isScrollable={true}
|
||||||
|
showCommentForm={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Base styles for sheet content */
|
||||||
|
:global(.sheet-content) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop styles (side sheet) */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
:global(.sheet-content) {
|
||||||
|
width: 80vw !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile styles (bottom sheet) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:global(.sheet-content) {
|
||||||
|
height: 50vh !important;
|
||||||
|
border-radius: 16px 16px 0 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sheet-header) {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.user-avatar) {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar-fallback) {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-title) {
|
||||||
|
font-family: 'Washington', serif !important;
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
color: hsl(var(--foreground)) !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop media viewer - maximize available space */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sheet-body {
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-container {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile media viewer - maximize available space */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sheet-body {
|
||||||
|
height: calc(80vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-container {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.media-video) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-nav:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-nav.prev {
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-nav.next {
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.active {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin: 0 0 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.like-action) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary:hover {
|
||||||
|
background: hsl(var(--primary) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.secondary {
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
color: hsl(var(--secondary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.secondary:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop comments section */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.comments-section {
|
||||||
|
height: calc(100vh - 400px);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile comments section */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.comments-section {
|
||||||
|
height: calc(80vh - 350px);
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile specific adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:global(.sheet-header) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-section {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.user-avatar) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-title) {
|
||||||
|
font-size: 1.125rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
height: calc(80vh - 380px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
src/lib/components/FindsFilter.svelte
Normal file
125
src/lib/components/FindsFilter.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '$lib/components/dropdown-menu';
|
||||||
|
import { Badge } from '$lib/components/badge';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentFilter: string;
|
||||||
|
onFilterChange: (filter: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentFilter, onFilterChange }: Props = $props();
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ value: 'all', label: 'All Finds', description: 'Public, friends, and your finds' },
|
||||||
|
{ value: 'public', label: 'Public Only', description: 'Publicly visible finds' },
|
||||||
|
{ value: 'friends', label: 'Friends Only', description: 'Finds from your friends' },
|
||||||
|
{ value: 'mine', label: 'My Finds', description: 'Only your finds' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentOption = $derived(
|
||||||
|
filterOptions.find((option) => option.value === currentFilter) || filterOptions[0]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button variant="outline" class="filter-trigger">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||||
|
<path
|
||||||
|
d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v2.586a1 1 0 0 1-.293.707l-6.414 6.414a1 1 0 0 0-.293.707V17l-4 4v-6.586a1 1 0 0 0-.293-.707L3.293 7.293A1 1 0 0 1 3 6.586V4z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{currentOption.label}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" class="filter-dropdown">
|
||||||
|
{#each filterOptions as option (option.value)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="filter-option"
|
||||||
|
onclick={() => onFilterChange(option.value)}
|
||||||
|
data-selected={currentFilter === option.value}
|
||||||
|
>
|
||||||
|
<div class="option-content">
|
||||||
|
<div class="option-header">
|
||||||
|
<span class="option-label">{option.label}</span>
|
||||||
|
{#if currentFilter === option.value}
|
||||||
|
<Badge variant="default" class="selected-badge">Selected</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="option-description">{option.description}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.filter-trigger) {
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 120px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.filter-dropdown) {
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.filter-option) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.filter-option:hover) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.filter-option[data-selected='true']) {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.selected-badge) {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
207
src/lib/components/FindsList.svelte
Normal file
207
src/lib/components/FindsList.svelte
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FindCard from './FindCard.svelte';
|
||||||
|
|
||||||
|
interface Find {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
locationName?: string;
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
};
|
||||||
|
likeCount?: number;
|
||||||
|
isLiked?: boolean;
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FindsListProps {
|
||||||
|
finds: Find[];
|
||||||
|
onFindExplore?: (id: string) => void;
|
||||||
|
title?: string;
|
||||||
|
showEmpty?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
finds,
|
||||||
|
onFindExplore,
|
||||||
|
title = 'Finds',
|
||||||
|
showEmpty = true,
|
||||||
|
emptyMessage = 'No finds to display',
|
||||||
|
hideTitle = false
|
||||||
|
}: FindsListProps = $props();
|
||||||
|
|
||||||
|
function handleFindExplore(id: string) {
|
||||||
|
onFindExplore?.(id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="finds-feed">
|
||||||
|
{#if !hideTitle}
|
||||||
|
<div class="feed-header">
|
||||||
|
<h2 class="feed-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if finds.length > 0}
|
||||||
|
<div class="feed-container">
|
||||||
|
{#each finds as find (find.id)}
|
||||||
|
<FindCard
|
||||||
|
id={find.id}
|
||||||
|
title={find.title}
|
||||||
|
description={find.description}
|
||||||
|
category={find.category}
|
||||||
|
locationName={find.locationName}
|
||||||
|
user={find.user}
|
||||||
|
media={find.media}
|
||||||
|
likeCount={find.likeCount}
|
||||||
|
isLiked={find.isLiked}
|
||||||
|
onExplore={handleFindExplore}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if showEmpty}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-title">No finds discovered yet</h3>
|
||||||
|
<p class="empty-message">{emptyMessage}</p>
|
||||||
|
<div class="empty-action">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Start exploring to discover finds</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.finds-feed {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.feed-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scrolling for feed */
|
||||||
|
.feed-container {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add subtle animation for new posts */
|
||||||
|
:global(.find-card) {
|
||||||
|
animation: fadeInUp 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<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;
|
||||||
username: string;
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { user }: { user: User } = $props();
|
let { user }: { user: User } = $props();
|
||||||
@@ -11,9 +13,13 @@
|
|||||||
|
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1 class="app-title">Serengo</h1>
|
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
<ProfilePanel username={user.username} id={user.id} />
|
<ProfilePanel
|
||||||
|
username={user.username}
|
||||||
|
id={user.id}
|
||||||
|
profilePictureUrl={user.profilePictureUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
119
src/lib/components/LikeButton.svelte
Normal file
119
src/lib/components/LikeButton.svelte
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { Heart } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
findId: string;
|
||||||
|
isLiked?: boolean;
|
||||||
|
likeCount?: number;
|
||||||
|
size?: 'sm' | 'default' | 'lg';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
findId,
|
||||||
|
isLiked = false,
|
||||||
|
likeCount = 0,
|
||||||
|
size = 'default',
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Local state stores for this like button - start with props but will be overridden by global state
|
||||||
|
const likeState = writable({
|
||||||
|
isLiked: isLiked,
|
||||||
|
likeCount: likeCount,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
let apiSync: typeof import('$lib/stores/api-sync').apiSync | null = null;
|
||||||
|
|
||||||
|
// Initialize API sync and subscribe to global state
|
||||||
|
onMount(async () => {
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
// Dynamically import the API sync
|
||||||
|
const module = await import('$lib/stores/api-sync');
|
||||||
|
apiSync = module.apiSync;
|
||||||
|
|
||||||
|
// Check if global state already exists for this find
|
||||||
|
const existingState = apiSync.getEntityState('find', findId);
|
||||||
|
|
||||||
|
if (existingState) {
|
||||||
|
// Use existing global state - it's more current than props
|
||||||
|
console.log(`Using existing global state for find ${findId}`, existingState);
|
||||||
|
} else {
|
||||||
|
// Initialize with minimal data only if no global state exists
|
||||||
|
console.log(`Initializing new minimal state for find ${findId}`);
|
||||||
|
apiSync.setEntityState('find', findId, {
|
||||||
|
id: findId,
|
||||||
|
isLikedByUser: isLiked,
|
||||||
|
likeCount: likeCount,
|
||||||
|
// Minimal data needed for like functionality
|
||||||
|
title: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
isPublic: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: '',
|
||||||
|
username: '',
|
||||||
|
isFromFriend: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to global state for this find
|
||||||
|
const globalLikeState = apiSync.subscribeFindLikes(findId);
|
||||||
|
globalLikeState.subscribe(
|
||||||
|
(state: { isLiked: boolean; likeCount: number; isLoading: boolean }) => {
|
||||||
|
likeState.set({
|
||||||
|
isLiked: state.isLiked,
|
||||||
|
likeCount: state.likeCount,
|
||||||
|
isLoading: state.isLoading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize API sync:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleLike() {
|
||||||
|
if (!apiSync || !browser) return;
|
||||||
|
|
||||||
|
if ($likeState.isLoading) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiSync.toggleLike(findId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle like:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
{size}
|
||||||
|
class="group gap-1.5 {className}"
|
||||||
|
onclick={toggleLike}
|
||||||
|
disabled={$likeState.isLoading}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
|
||||||
|
? 'scale-110 fill-red-500 text-red-500'
|
||||||
|
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
|
||||||
|
? 'animate-pulse'
|
||||||
|
: ''}"
|
||||||
|
/>
|
||||||
|
{#if $likeState.likeCount > 0}
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-gray-500 group-hover:text-red-400'}"
|
||||||
|
>
|
||||||
|
{$likeState.likeCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
locationActions,
|
locationActions,
|
||||||
locationStatus,
|
locationStatus,
|
||||||
locationError,
|
locationError,
|
||||||
isLocationLoading
|
isLocationLoading,
|
||||||
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
|
|
||||||
@@ -22,26 +23,45 @@
|
|||||||
showLabel = true
|
showLabel = true
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
async function handleLocationClick() {
|
// Track if location watching is active from the derived store
|
||||||
const result = await locationActions.getCurrentLocation({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 15000,
|
|
||||||
maximumAge: 300000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result && $locationError) {
|
async function handleLocationClick() {
|
||||||
toast.error($locationError.message);
|
if ($isWatching) {
|
||||||
|
// Stop watching if currently active
|
||||||
|
locationActions.stopWatching();
|
||||||
|
toast.success('Location watching stopped');
|
||||||
|
} else {
|
||||||
|
// Try to get current location first, then start watching
|
||||||
|
const result = await locationActions.getCurrentLocation({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 300000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Start watching for continuous updates
|
||||||
|
locationActions.startWatching({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 15000,
|
||||||
|
maximumAge: 60000 // Update every minute
|
||||||
|
});
|
||||||
|
toast.success('Location watching started');
|
||||||
|
} else if ($locationError) {
|
||||||
|
toast.error($locationError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonText = $derived(() => {
|
const buttonText = $derived(() => {
|
||||||
if ($isLocationLoading) return 'Finding location...';
|
if ($isLocationLoading) return 'Finding location...';
|
||||||
if ($locationStatus === 'success') return 'Update location';
|
if ($isWatching) return 'Stop watching location';
|
||||||
|
if ($locationStatus === 'success') return 'Watch location';
|
||||||
return 'Find my location';
|
return 'Find my location';
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconClass = $derived(() => {
|
const iconClass = $derived(() => {
|
||||||
if ($isLocationLoading) return 'loading';
|
if ($isLocationLoading) return 'loading';
|
||||||
|
if ($isWatching) return 'watching';
|
||||||
if ($locationStatus === 'success') return 'success';
|
if ($locationStatus === 'success') return 'success';
|
||||||
if ($locationStatus === 'error') return 'error';
|
if ($locationStatus === 'error') return 'error';
|
||||||
return 'default';
|
return 'default';
|
||||||
@@ -59,6 +79,22 @@
|
|||||||
<div class="loading-skeleton">
|
<div class="loading-skeleton">
|
||||||
<Skeleton class="h-5 w-5 rounded-full" />
|
<Skeleton class="h-5 w-5 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if $isWatching}
|
||||||
|
<svg viewBox="0 0 24 24" class="watching-icon">
|
||||||
|
<path
|
||||||
|
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="9"
|
||||||
|
r="15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.3"
|
||||||
|
class="pulse-ring"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{:else if $locationStatus === 'success'}
|
{:else if $locationStatus === 'success'}
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -201,6 +237,11 @@
|
|||||||
fill: #10b981;
|
fill: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon.watching svg {
|
||||||
|
color: #f59e0b;
|
||||||
|
fill: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.icon.error svg {
|
.icon.error svg {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
fill: #ef4444;
|
fill: #ef4444;
|
||||||
@@ -219,6 +260,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watching-icon .pulse-ring {
|
||||||
|
animation: pulse-ring 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/lib/components/LocationManager.svelte
Normal file
81
src/lib/components/LocationManager.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
autoStart?: boolean;
|
||||||
|
enableHighAccuracy?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
maximumAge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
autoStart = true,
|
||||||
|
enableHighAccuracy = true,
|
||||||
|
timeout = 15000,
|
||||||
|
maximumAge = 60000
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Location watching options
|
||||||
|
const watchOptions = {
|
||||||
|
enableHighAccuracy,
|
||||||
|
timeout,
|
||||||
|
maximumAge
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser || !autoStart) return;
|
||||||
|
|
||||||
|
// Check if geolocation is supported
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
console.warn('Geolocation is not supported by this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have coordinates and aren't watching
|
||||||
|
if ($coordinates && !$isWatching) {
|
||||||
|
// Start watching immediately if we have previous coordinates
|
||||||
|
startLocationWatching();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no coordinates, try to get current location first
|
||||||
|
if (!$coordinates) {
|
||||||
|
getCurrentLocationThenWatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getCurrentLocationThenWatch() {
|
||||||
|
try {
|
||||||
|
const result = await locationActions.getCurrentLocation(watchOptions);
|
||||||
|
if (result) {
|
||||||
|
// Successfully got location, now start watching
|
||||||
|
startLocationWatching();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't get location due to permissions, don't auto-start watching
|
||||||
|
console.log('Could not get initial location, location watching not started automatically');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLocationWatching() {
|
||||||
|
if (!$isWatching) {
|
||||||
|
locationActions.startWatching(watchOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function to stop watching when component is destroyed
|
||||||
|
function cleanup() {
|
||||||
|
if ($isWatching) {
|
||||||
|
locationActions.stopWatching();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop watching when the component is destroyed
|
||||||
|
onMount(() => {
|
||||||
|
return cleanup;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- This component doesn't render anything, it just manages location watching -->
|
||||||
@@ -6,11 +6,34 @@
|
|||||||
getMapCenter,
|
getMapCenter,
|
||||||
getMapZoom,
|
getMapZoom,
|
||||||
shouldZoomToLocation,
|
shouldZoomToLocation,
|
||||||
locationActions
|
locationActions,
|
||||||
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import LocationButton from './LocationButton.svelte';
|
import LocationButton from './LocationButton.svelte';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
|
||||||
|
interface Find {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
isPublic: number;
|
||||||
|
createdAt: Date;
|
||||||
|
userId: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style?: StyleSpecification;
|
style?: StyleSpecification;
|
||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
@@ -18,6 +41,8 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
showLocationButton?: boolean;
|
showLocationButton?: boolean;
|
||||||
autoCenter?: boolean;
|
autoCenter?: boolean;
|
||||||
|
finds?: Find[];
|
||||||
|
onFindClick?: (find: Find) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -43,7 +68,9 @@
|
|||||||
zoom,
|
zoom,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
showLocationButton = true,
|
showLocationButton = true,
|
||||||
autoCenter = true
|
autoCenter = true,
|
||||||
|
finds = [],
|
||||||
|
onFindClick
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let mapLoaded = $state(false);
|
let mapLoaded = $state(false);
|
||||||
@@ -113,14 +140,44 @@
|
|||||||
>
|
>
|
||||||
{#if $coordinates}
|
{#if $coordinates}
|
||||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||||
<div class="location-marker">
|
<div class="location-marker" class:watching={$isWatching}>
|
||||||
<div class="marker-pulse"></div>
|
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||||
<div class="marker-outer">
|
<div class="marker-outer" class:watching={$isWatching}>
|
||||||
<div class="marker-inner"></div>
|
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if $isWatching}
|
||||||
|
<div class="watching-ring"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#each finds as find (find.id)}
|
||||||
|
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="find-marker"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => onFindClick?.(find)}
|
||||||
|
title={find.title}
|
||||||
|
>
|
||||||
|
<div class="find-marker-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{#if find.media && find.media.length > 0}
|
||||||
|
<div class="find-marker-preview">
|
||||||
|
<img src={find.media[0].thumbnailUrl} alt={find.title} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
{/each}
|
||||||
</MapLibre>
|
</MapLibre>
|
||||||
|
|
||||||
{#if showLocationButton}
|
{#if showLocationButton}
|
||||||
@@ -190,6 +247,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marker-outer.watching) {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.marker-inner) {
|
:global(.marker-inner) {
|
||||||
@@ -197,6 +261,12 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marker-inner.watching) {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.marker-pulse) {
|
:global(.marker-pulse) {
|
||||||
@@ -210,6 +280,22 @@
|
|||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.marker-pulse.watching) {
|
||||||
|
border-color: rgba(245, 158, 11, 0.6);
|
||||||
|
animation: pulse-watching 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.watching-ring) {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: -8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid rgba(245, 158, 11, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: expand-ring 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -221,6 +307,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-watching {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expand-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Find marker styles */
|
||||||
|
:global(.find-marker) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-marker:hover) {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-marker-icon) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #ff6b35;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-marker-preview) {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.find-marker-preview img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.map-container {
|
.map-container {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
|
|||||||
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>
|
||||||
402
src/lib/components/POISearch.svelte
Normal file
402
src/lib/components/POISearch.svelte
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '$lib/components/input';
|
||||||
|
import { Label } from '$lib/components/label';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onPlaceSelected: (place: PlaceResult) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
showNearbyButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
onPlaceSelected,
|
||||||
|
placeholder = 'Search for a place or address...',
|
||||||
|
label = 'Search location',
|
||||||
|
showNearbyButton = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
|
||||||
|
let nearbyPlaces = $state<PlaceResult[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
let showNearby = $state(false);
|
||||||
|
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
async function searchPlaces(query: string) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
suggestions = [];
|
||||||
|
showSuggestions = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const searchParams = new URL('/api/places', window.location.origin).searchParams;
|
||||||
|
searchParams.set('action', 'autocomplete');
|
||||||
|
searchParams.set('query', query.trim());
|
||||||
|
|
||||||
|
if ($coordinates) {
|
||||||
|
searchParams.set('lat', $coordinates.latitude.toString());
|
||||||
|
searchParams.set('lng', $coordinates.longitude.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${searchParams}`);
|
||||||
|
if (response.ok) {
|
||||||
|
suggestions = await response.json();
|
||||||
|
showSuggestions = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching places:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPlace(placeId: string, description: string) {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'details',
|
||||||
|
placeId
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const place: PlaceResult = await response.json();
|
||||||
|
onPlaceSelected(place);
|
||||||
|
searchQuery = description;
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting place details:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findNearbyPlaces() {
|
||||||
|
if (!$coordinates) {
|
||||||
|
alert('Please enable location access first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
showNearby = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'nearby',
|
||||||
|
lat: $coordinates.latitude.toString(),
|
||||||
|
lng: $coordinates.longitude.toString(),
|
||||||
|
radius: '2000' // 2km radius
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/places?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
nearbyPlaces = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding nearby places:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
searchQuery = target.value;
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
searchPlaces(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNearbyPlace(place: PlaceResult) {
|
||||||
|
onPlaceSelected(place);
|
||||||
|
searchQuery = place.name;
|
||||||
|
showNearby = false;
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: Event) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.poi-search-container')) {
|
||||||
|
showSuggestions = false;
|
||||||
|
showNearby = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="poi-search-container">
|
||||||
|
<div class="field">
|
||||||
|
<Label for="poi-search">{label}</Label>
|
||||||
|
<div class="search-input-container">
|
||||||
|
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
|
||||||
|
{#if showNearbyButton && $coordinates}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={findNearbyPlaces}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
Nearby
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
|
<div class="suggestions-dropdown">
|
||||||
|
<div class="suggestions-header">Search Results</div>
|
||||||
|
{#each suggestions as suggestion (suggestion.placeId)}
|
||||||
|
<button
|
||||||
|
class="suggestion-item"
|
||||||
|
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<div class="suggestion-content">
|
||||||
|
<span class="suggestion-name">{suggestion.description}</span>
|
||||||
|
<div class="suggestion-types">
|
||||||
|
{#each suggestion.types.slice(0, 2) as type, index (index)}
|
||||||
|
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNearby && nearbyPlaces.length > 0}
|
||||||
|
<div class="suggestions-dropdown">
|
||||||
|
<div class="suggestions-header">Nearby Places</div>
|
||||||
|
{#each nearbyPlaces as place (place.placeId)}
|
||||||
|
<button
|
||||||
|
class="suggestion-item"
|
||||||
|
onclick={() => selectNearbyPlace(place)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<div class="suggestion-content">
|
||||||
|
<span class="suggestion-name">{place.name}</span>
|
||||||
|
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
|
||||||
|
{#if place.rating}
|
||||||
|
<div class="suggestion-rating">
|
||||||
|
<span class="rating-stars">★</span>
|
||||||
|
<span>{place.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
|
||||||
|
<path
|
||||||
|
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="32"
|
||||||
|
stroke-dashoffset="32"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Searching...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.poi-search-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:hover:not(:disabled) {
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-address {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-types {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-type {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-stars {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-icon {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.search-input-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
@@ -7,32 +8,43 @@
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from './dropdown-menu';
|
} from './dropdown-menu';
|
||||||
import { Avatar, AvatarFallback } from './avatar';
|
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from './skeleton';
|
||||||
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||||
|
import NotificationSettingsSheet from './NotificationSettingsSheet.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string;
|
username: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { username, id, loading = false }: Props = $props();
|
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||||
|
|
||||||
// Get the first letter of username for avatar
|
let showProfilePictureSheet = $state(false);
|
||||||
const initial = username.charAt(0).toUpperCase();
|
let showNotificationSettingsSheet = $state(false);
|
||||||
|
|
||||||
|
function openProfilePictureSheet() {
|
||||||
|
showProfilePictureSheet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfilePictureSheet() {
|
||||||
|
showProfilePictureSheet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNotificationSettingsSheet() {
|
||||||
|
showNotificationSettingsSheet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNotificationSettingsSheet() {
|
||||||
|
showNotificationSettingsSheet = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger class="profile-trigger">
|
<DropdownMenuTrigger class="profile-trigger">
|
||||||
{#if loading}
|
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
{:else}
|
|
||||||
<Avatar class="profile-avatar">
|
|
||||||
<AvatarFallback class="profile-avatar-fallback">
|
|
||||||
{initial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
<DropdownMenuContent align="end" class="profile-dropdown-content">
|
||||||
@@ -65,6 +77,20 @@
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem class="profile-picture-item" onclick={openProfilePictureSheet}>
|
||||||
|
Profile Picture
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem class="friends-item">
|
||||||
|
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettingsSheet}>
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<div class="user-info-item">
|
<div class="user-info-item">
|
||||||
<span class="info-label">Username</span>
|
<span class="info-label">Username</span>
|
||||||
<span class="info-value">{username}</span>
|
<span class="info-value">{username}</span>
|
||||||
@@ -86,6 +112,19 @@
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{#if showProfilePictureSheet}
|
||||||
|
<ProfilePictureSheet
|
||||||
|
userId={id}
|
||||||
|
{username}
|
||||||
|
{profilePictureUrl}
|
||||||
|
onClose={closeProfilePictureSheet}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNotificationSettingsSheet}
|
||||||
|
<NotificationSettingsSheet onClose={closeNotificationSettingsSheet} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.profile-trigger) {
|
:global(.profile-trigger) {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -110,15 +149,6 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.profile-avatar-fallback) {
|
|
||||||
background: black;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.profile-dropdown-content) {
|
:global(.profile-dropdown-content) {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
@@ -156,6 +186,45 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.profile-picture-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.profile-picture-item:hover) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.friends-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.friends-item:hover) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.notification-settings-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.notification-settings-item:hover) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.logout-item) {
|
:global(.logout-item) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
|
|||||||
59
src/lib/components/ProfilePicture.svelte
Normal file
59
src/lib/components/ProfilePicture.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
class?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
username,
|
||||||
|
profilePictureUrl,
|
||||||
|
size = 'md',
|
||||||
|
class: className,
|
||||||
|
loading = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const initial = username.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-8 w-8',
|
||||||
|
md: 'h-10 w-10',
|
||||||
|
lg: 'h-16 w-16',
|
||||||
|
xl: 'h-24 w-24'
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackSizeClasses = {
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-base',
|
||||||
|
lg: 'text-xl',
|
||||||
|
xl: 'text-3xl'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class={cn('animate-pulse rounded-full bg-gray-200', sizeClasses[size], className)}></div>
|
||||||
|
{:else}
|
||||||
|
<Avatar class={cn(sizeClasses[size], className)}>
|
||||||
|
{#if profilePictureUrl}
|
||||||
|
<AvatarImage src={profilePictureUrl} alt={username} />
|
||||||
|
{/if}
|
||||||
|
<AvatarFallback class={cn('profile-picture-fallback', fallbackSizeClasses[size])}>
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.profile-picture-fallback) {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
src/lib/components/ProfilePictureSheet.svelte
Normal file
255
src/lib/components/ProfilePictureSheet.svelte
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
||||||
|
import { Button } from './button';
|
||||||
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { userId, username, profilePictureUrl, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let selectedFile = $state<File | null>(null);
|
||||||
|
let isUploading = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
|
let showModal = $state(true);
|
||||||
|
|
||||||
|
// Close modal when showModal changes to false
|
||||||
|
$effect(() => {
|
||||||
|
if (!showModal && onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.files && target.files[0]) {
|
||||||
|
const file = target.files[0];
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB max)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('File size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
isUploading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('userId', userId);
|
||||||
|
|
||||||
|
const response = await fetch('/api/profile-picture/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = null;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
await invalidateAll();
|
||||||
|
showModal = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Failed to upload profile picture');
|
||||||
|
} finally {
|
||||||
|
isUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!profilePictureUrl) return;
|
||||||
|
|
||||||
|
isDeleting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/profile-picture/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Delete failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
showModal = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error);
|
||||||
|
alert('Failed to delete profile picture');
|
||||||
|
} finally {
|
||||||
|
isDeleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
||||||
|
<SheetContent side="bottom" class="profile-picture-sheet">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Profile Picture</SheetTitle>
|
||||||
|
<SheetDescription>Upload, edit, or delete your profile picture</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div class="profile-picture-content">
|
||||||
|
<div class="current-avatar">
|
||||||
|
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-section">
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if selectedFile}
|
||||||
|
<div class="selected-file">
|
||||||
|
<p class="file-name">{selectedFile.name}</p>
|
||||||
|
<p class="file-size">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
{#if selectedFile}
|
||||||
|
<Button onclick={handleUpload} disabled={isUploading} class="upload-button">
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload Picture'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if profilePictureUrl}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onclick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
class="delete-button"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete Picture'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.profile-picture-sheet) {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-avatar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.large-avatar) {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.upload-button) {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.upload-button:hover) {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delete-button) {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
:global(.profile-picture-sheet) {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.upload-button),
|
||||||
|
:global(.delete-button) {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
227
src/lib/components/VideoPlayer.svelte
Normal file
227
src/lib/components/VideoPlayer.svelte
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { Play, Pause, Volume2, VolumeX, Maximize } from '@lucide/svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
poster?: string;
|
||||||
|
class?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
controls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
class: className = '',
|
||||||
|
autoplay = false,
|
||||||
|
muted = false,
|
||||||
|
controls = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let videoElement: HTMLVideoElement;
|
||||||
|
let isPlaying = $state(false);
|
||||||
|
let isMuted = $state(muted);
|
||||||
|
let currentTime = $state(0);
|
||||||
|
let duration = $state(0);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let showControls = $state(true);
|
||||||
|
let controlsTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function togglePlayPause() {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoElement.pause();
|
||||||
|
} else {
|
||||||
|
videoElement.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
videoElement.muted = !videoElement.muted;
|
||||||
|
isMuted = videoElement.muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimeUpdate() {
|
||||||
|
currentTime = videoElement.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoadedMetadata() {
|
||||||
|
duration = videoElement.duration;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlay() {
|
||||||
|
isPlaying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePause() {
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeek(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const time = (parseFloat(target.value) / 100) * duration;
|
||||||
|
videoElement.currentTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove() {
|
||||||
|
if (!controls) return;
|
||||||
|
showControls = true;
|
||||||
|
clearTimeout(controlsTimeout);
|
||||||
|
controlsTimeout = setTimeout(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
showControls = false;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (videoElement.requestFullscreen) {
|
||||||
|
videoElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: number): string {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group relative overflow-hidden rounded-lg bg-black {className}"
|
||||||
|
role="application"
|
||||||
|
onmousemove={handleMouseMove}
|
||||||
|
onmouseleave={() => {
|
||||||
|
if (isPlaying && controls) showControls = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
{src}
|
||||||
|
{poster}
|
||||||
|
{autoplay}
|
||||||
|
muted={isMuted}
|
||||||
|
preload="metadata"
|
||||||
|
onplay={handlePlay}
|
||||||
|
onpause={handlePause}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onloadedmetadata={handleLoadedMetadata}
|
||||||
|
onclick={togglePlayPause}
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if controls && showControls}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<!-- Center play/pause button -->
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
class="h-16 w-16 rounded-full bg-black/30 text-white hover:bg-black/50"
|
||||||
|
onclick={togglePlayPause}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<Pause class="h-8 w-8" />
|
||||||
|
{:else}
|
||||||
|
<Play class="ml-1 h-8 w-8" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom controls -->
|
||||||
|
<div class="absolute right-0 bottom-0 left-0 p-4">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={duration > 0 ? (currentTime / duration) * 100 : 0}
|
||||||
|
oninput={handleSeek}
|
||||||
|
class="slider h-1 w-full cursor-pointer appearance-none rounded-lg bg-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons -->
|
||||||
|
<div class="flex items-center justify-between text-white">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-white hover:bg-white/20"
|
||||||
|
onclick={togglePlayPause}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<Pause class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Play class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-white hover:bg-white/20"
|
||||||
|
onclick={toggleMute}
|
||||||
|
>
|
||||||
|
{#if isMuted}
|
||||||
|
<VolumeX class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Volume2 class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span class="text-sm">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-white hover:bg-white/20"
|
||||||
|
onclick={toggleFullscreen}
|
||||||
|
>
|
||||||
|
<Maximize class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/lib/components/badge/badge.svelte
Normal file
32
src/lib/components/badge/badge.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variant = 'default', class: className, children }: Props = $props();
|
||||||
|
|
||||||
|
const badgeVariants = {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
|
||||||
|
badgeVariants[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
9
src/lib/components/badge/index.ts
Normal file
9
src/lib/components/badge/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Root from './badge.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Badge
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { BadgeVariant } from './badge.svelte';
|
||||||
@@ -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"
|
||||||
|
|||||||
36
src/lib/components/sheet/index.ts
Normal file
36
src/lib/components/sheet/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
import Trigger from './sheet-trigger.svelte';
|
||||||
|
import Close from './sheet-close.svelte';
|
||||||
|
import Overlay from './sheet-overlay.svelte';
|
||||||
|
import Content from './sheet-content.svelte';
|
||||||
|
import Header from './sheet-header.svelte';
|
||||||
|
import Footer from './sheet-footer.svelte';
|
||||||
|
import Title from './sheet-title.svelte';
|
||||||
|
import Description from './sheet-description.svelte';
|
||||||
|
|
||||||
|
const Root = SheetPrimitive.Root;
|
||||||
|
const Portal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Close,
|
||||||
|
Trigger,
|
||||||
|
Portal,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
//
|
||||||
|
Root as Sheet,
|
||||||
|
Close as SheetClose,
|
||||||
|
Trigger as SheetTrigger,
|
||||||
|
Portal as SheetPortal,
|
||||||
|
Overlay as SheetOverlay,
|
||||||
|
Content as SheetContent,
|
||||||
|
Header as SheetHeader,
|
||||||
|
Footer as SheetFooter,
|
||||||
|
Title as SheetTitle,
|
||||||
|
Description as SheetDescription
|
||||||
|
};
|
||||||
7
src/lib/components/sheet/sheet-close.svelte
Normal file
7
src/lib/components/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
|
||||||
60
src/lib/components/sheet/sheet-content.svelte
Normal file
60
src/lib/components/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
|
export const sheetVariants = tv({
|
||||||
|
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
bottom:
|
||||||
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
right:
|
||||||
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Side = VariantProps<typeof sheetVariants>['side'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import SheetOverlay from './sheet-overlay.svelte';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
side = 'right',
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SheetPrimitive.PortalProps;
|
||||||
|
side?: Side;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Portal {...portalProps}>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-content"
|
||||||
|
class={cn(sheetVariants({ side }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPrimitive.Portal>
|
||||||
17
src/lib/components/sheet/sheet-description.svelte
Normal file
17
src/lib/components/sheet/sheet-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-description"
|
||||||
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
src/lib/components/sheet/sheet-footer.svelte
Normal file
20
src/lib/components/sheet/sheet-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
src/lib/components/sheet/sheet-header.svelte
Normal file
20
src/lib/components/sheet/sheet-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sheet-header"
|
||||||
|
class={cn('flex flex-col gap-1.5 p-4', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
src/lib/components/sheet/sheet-overlay.svelte
Normal file
20
src/lib/components/sheet/sheet-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
class={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
17
src/lib/components/sheet/sheet-title.svelte
Normal file
17
src/lib/components/sheet/sheet-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-title"
|
||||||
|
class={cn('font-semibold text-foreground', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/lib/components/sheet/sheet-trigger.svelte
Normal file
7
src/lib/components/sheet/sheet-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
|
||||||
@@ -3,10 +3,19 @@ export { default as Input } from './components/Input.svelte';
|
|||||||
export { default as Button } from './components/Button.svelte';
|
export { default as Button } from './components/Button.svelte';
|
||||||
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
||||||
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
||||||
|
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
|
||||||
|
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
||||||
export { default as Header } from './components/Header.svelte';
|
export { default as Header } from './components/Header.svelte';
|
||||||
export { default as Modal } from './components/Modal.svelte';
|
export { default as Modal } from './components/Modal.svelte';
|
||||||
export { default as Map } from './components/Map.svelte';
|
export { default as Map } from './components/Map.svelte';
|
||||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||||
|
export { default as LocationManager } from './components/LocationManager.svelte';
|
||||||
|
export { default as 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 FindsList } from './components/FindsList.svelte';
|
||||||
|
|
||||||
// Skeleton Loading Components
|
// Skeleton Loading Components
|
||||||
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
||||||
@@ -21,6 +30,7 @@ export {
|
|||||||
locationError,
|
locationError,
|
||||||
isLocationLoading,
|
isLocationLoading,
|
||||||
hasLocationAccess,
|
hasLocationAccess,
|
||||||
|
isWatching,
|
||||||
getMapCenter,
|
getMapCenter,
|
||||||
getMapZoom
|
getMapZoom
|
||||||
} from './stores/location';
|
} from './stores/location';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
|||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
import { getSignedR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
@@ -31,7 +32,11 @@ export async function validateSessionToken(token: string) {
|
|||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
user: { id: table.user.id, username: table.user.username },
|
user: {
|
||||||
|
id: table.user.id,
|
||||||
|
username: table.user.username,
|
||||||
|
profilePictureUrl: table.user.profilePictureUrl
|
||||||
|
},
|
||||||
session: table.session
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -58,7 +63,25 @@ export async function validateSessionToken(token: string) {
|
|||||||
.where(eq(table.session.id, session.id));
|
.where(eq(table.session.id, session.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session, user };
|
// Generate signed URL for profile picture if it exists
|
||||||
|
let profilePictureUrl = user.profilePictureUrl;
|
||||||
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
|
// It's a path, generate signed URL
|
||||||
|
try {
|
||||||
|
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate signed URL for profile picture:', error);
|
||||||
|
profilePictureUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
profilePictureUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||||
@@ -95,7 +118,8 @@ export async function createUser(googleId: string, name: string) {
|
|||||||
username: name,
|
username: name,
|
||||||
googleId,
|
googleId,
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
age: null
|
age: null,
|
||||||
|
profilePictureUrl: null
|
||||||
};
|
};
|
||||||
await db.insert(table.user).values(user);
|
await db.insert(table.user).values(user);
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
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(),
|
||||||
age: integer('age'),
|
age: integer('age'),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash'),
|
passwordHash: text('password_hash'),
|
||||||
googleId: text('google_id').unique()
|
googleId: text('google_id').unique(),
|
||||||
|
profilePictureUrl: text('profile_picture_url')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable('session', {
|
export const session = pgTable('session', {
|
||||||
@@ -19,3 +20,130 @@ export const session = pgTable('session', {
|
|||||||
export type Session = typeof session.$inferSelect;
|
export type Session = typeof session.$inferSelect;
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect;
|
export type User = typeof user.$inferSelect;
|
||||||
|
|
||||||
|
// Finds feature tables
|
||||||
|
export const find = pgTable('find', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
latitude: text('latitude').notNull(), // Using text for precision
|
||||||
|
longitude: text('longitude').notNull(), // Using text for precision
|
||||||
|
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||||
|
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||||
|
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findMedia = pgTable('find_media', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
findId: text('find_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => find.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // 'photo' or 'video'
|
||||||
|
url: text('url').notNull(),
|
||||||
|
thumbnailUrl: text('thumbnail_url'),
|
||||||
|
fallbackUrl: text('fallback_url'), // JPEG fallback for WebP images
|
||||||
|
fallbackThumbnailUrl: text('fallback_thumbnail_url'), // JPEG fallback for WebP thumbnails
|
||||||
|
orderIndex: integer('order_index').default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findLike = pgTable('find_like', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
findId: text('find_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => find.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const friendship = pgTable('friendship', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
friendId: text('friend_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').notNull(), // 'pending', 'accepted', 'blocked'
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findComment = pgTable('find_comment', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
findId: text('find_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => find.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification system tables
|
||||||
|
export const notification = pgTable('notification', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||||
|
title: text('title').notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||||
|
isRead: boolean('is_read').default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationSubscription = pgTable('notification_subscription', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
endpoint: text('endpoint').notNull(),
|
||||||
|
p256dhKey: text('p256dh_key').notNull(),
|
||||||
|
authKey: text('auth_key').notNull(),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
isActive: boolean('is_active').default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationPreferences = pgTable('notification_preferences', {
|
||||||
|
userId: text('user_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
friendRequests: boolean('friend_requests').default(true),
|
||||||
|
friendAccepted: boolean('friend_accepted').default(true),
|
||||||
|
findLiked: boolean('find_liked').default(true),
|
||||||
|
findCommented: boolean('find_commented').default(true),
|
||||||
|
pushEnabled: boolean('push_enabled').default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports for the tables
|
||||||
|
export type Find = typeof find.$inferSelect;
|
||||||
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
|
export type FindLike = typeof findLike.$inferSelect;
|
||||||
|
export type FindComment = typeof findComment.$inferSelect;
|
||||||
|
export type Friendship = typeof friendship.$inferSelect;
|
||||||
|
export type Notification = typeof notification.$inferSelect;
|
||||||
|
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||||
|
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||||
|
|
||||||
|
export type FindInsert = typeof find.$inferInsert;
|
||||||
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
|
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||||
|
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||||
|
export type NotificationInsert = typeof notification.$inferInsert;
|
||||||
|
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||||
|
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||||
|
|||||||
207
src/lib/server/media-processor.ts
Normal file
207
src/lib/server/media-processor.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { uploadToR2 } from './r2';
|
||||||
|
|
||||||
|
const THUMBNAIL_SIZE = 400;
|
||||||
|
const MAX_IMAGE_SIZE = 1920;
|
||||||
|
|
||||||
|
export async function processAndUploadImage(
|
||||||
|
file: File,
|
||||||
|
findId: string,
|
||||||
|
index: number
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
fallbackUrl?: string;
|
||||||
|
fallbackThumbnailUrl?: string;
|
||||||
|
}> {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filename = `finds/${findId}/image-${index}-${timestamp}`;
|
||||||
|
|
||||||
|
// Process full-size image in WebP format (with JPEG fallback)
|
||||||
|
const processedWebP = await sharp(buffer)
|
||||||
|
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.webp({ quality: 85, effort: 4 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate JPEG fallback for older browsers
|
||||||
|
const processedJPEG = await sharp(buffer)
|
||||||
|
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85, progressive: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate thumbnail in WebP format
|
||||||
|
const thumbnailWebP = await sharp(buffer)
|
||||||
|
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.webp({ quality: 80, effort: 4 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate JPEG thumbnail fallback
|
||||||
|
const thumbnailJPEG = await sharp(buffer)
|
||||||
|
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Upload all variants to R2
|
||||||
|
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
|
||||||
|
type: 'image/webp'
|
||||||
|
});
|
||||||
|
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
|
||||||
|
type: 'image/jpeg'
|
||||||
|
});
|
||||||
|
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
|
||||||
|
type: 'image/webp'
|
||||||
|
});
|
||||||
|
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
|
||||||
|
type: 'image/jpeg'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
|
||||||
|
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
|
||||||
|
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
|
||||||
|
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
|
||||||
|
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
|
||||||
|
return {
|
||||||
|
url: webpPath,
|
||||||
|
thumbnailUrl: thumbWebPPath,
|
||||||
|
fallbackUrl: jpegPath,
|
||||||
|
fallbackThumbnailUrl: thumbJPEGPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAndUploadVideo(
|
||||||
|
file: File,
|
||||||
|
findId: string,
|
||||||
|
index: number
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
fallbackUrl?: string;
|
||||||
|
fallbackThumbnailUrl?: string;
|
||||||
|
}> {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const baseFilename = `finds/${findId}/video-${index}-${timestamp}`;
|
||||||
|
|
||||||
|
// Convert to MP4 if needed for better compatibility
|
||||||
|
let videoPath: string;
|
||||||
|
|
||||||
|
if (file.type === 'video/mp4') {
|
||||||
|
// Upload MP4 directly
|
||||||
|
videoPath = await uploadToR2(file, `${baseFilename}.mp4`, 'video/mp4');
|
||||||
|
} else {
|
||||||
|
// For other formats, upload as-is for now (future: convert with ffmpeg)
|
||||||
|
const extension = file.type === 'video/quicktime' ? '.mov' : '.mp4';
|
||||||
|
videoPath = await uploadToR2(file, `${baseFilename}${extension}`, file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a simple thumbnail using a static placeholder for now
|
||||||
|
// TODO: Implement proper video thumbnail extraction with ffmpeg or client-side canvas
|
||||||
|
const thumbnailUrl = '/video-placeholder.svg';
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: videoPath,
|
||||||
|
thumbnailUrl,
|
||||||
|
// For videos, we can return the same URL as fallback since MP4 has broad support
|
||||||
|
fallbackUrl: videoPath,
|
||||||
|
fallbackThumbnailUrl: thumbnailUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAndUploadProfilePicture(
|
||||||
|
file: File,
|
||||||
|
userId: string
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
fallbackUrl?: string;
|
||||||
|
fallbackThumbnailUrl?: string;
|
||||||
|
}> {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomId = Math.random().toString(36).substring(2, 15);
|
||||||
|
const filename = `users/${userId}/profile-${timestamp}-${randomId}`;
|
||||||
|
|
||||||
|
// Process full-size image in WebP format (with JPEG fallback)
|
||||||
|
const processedWebP = await sharp(buffer)
|
||||||
|
.resize(400, 400, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.webp({ quality: 85, effort: 4 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate JPEG fallback for older browsers
|
||||||
|
const processedJPEG = await sharp(buffer)
|
||||||
|
.resize(400, 400, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85, progressive: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate smaller thumbnail in WebP format
|
||||||
|
const thumbnailWebP = await sharp(buffer)
|
||||||
|
.resize(150, 150, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.webp({ quality: 80, effort: 4 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Generate JPEG thumbnail fallback
|
||||||
|
const thumbnailJPEG = await sharp(buffer)
|
||||||
|
.resize(150, 150, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Upload all variants to R2
|
||||||
|
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
|
||||||
|
type: 'image/webp'
|
||||||
|
});
|
||||||
|
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
|
||||||
|
type: 'image/jpeg'
|
||||||
|
});
|
||||||
|
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
|
||||||
|
type: 'image/webp'
|
||||||
|
});
|
||||||
|
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
|
||||||
|
type: 'image/jpeg'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
|
||||||
|
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
|
||||||
|
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
|
||||||
|
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
|
||||||
|
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
|
||||||
|
return {
|
||||||
|
url: webpPath,
|
||||||
|
thumbnailUrl: thumbWebPPath,
|
||||||
|
fallbackUrl: jpegPath,
|
||||||
|
fallbackThumbnailUrl: thumbJPEGPath
|
||||||
|
};
|
||||||
|
}
|
||||||
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 };
|
||||||
74
src/lib/server/r2.ts
Normal file
74
src/lib/server/r2.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
// Environment variables with validation
|
||||||
|
const R2_ACCOUNT_ID = env.R2_ACCOUNT_ID;
|
||||||
|
const R2_ACCESS_KEY_ID = env.R2_ACCESS_KEY_ID;
|
||||||
|
const R2_SECRET_ACCESS_KEY = env.R2_SECRET_ACCESS_KEY;
|
||||||
|
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
function validateR2Config() {
|
||||||
|
if (!R2_ACCOUNT_ID) throw new Error('R2_ACCOUNT_ID environment variable is required');
|
||||||
|
if (!R2_ACCESS_KEY_ID) throw new Error('R2_ACCESS_KEY_ID environment variable is required');
|
||||||
|
if (!R2_SECRET_ACCESS_KEY)
|
||||||
|
throw new Error('R2_SECRET_ACCESS_KEY environment variable is required');
|
||||||
|
if (!R2_BUCKET_NAME) throw new Error('R2_BUCKET_NAME environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const r2Client = new S3Client({
|
||||||
|
region: 'auto',
|
||||||
|
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: R2_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: R2_SECRET_ACCESS_KEY!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const R2_PUBLIC_URL = `https://pub-${R2_ACCOUNT_ID}.r2.dev`;
|
||||||
|
|
||||||
|
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
|
||||||
|
validateR2Config();
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
await r2Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: R2_BUCKET_NAME,
|
||||||
|
Key: path,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: contentType,
|
||||||
|
CacheControl: 'public, max-age=31536000, immutable'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFromR2(path: string): Promise<void> {
|
||||||
|
validateR2Config();
|
||||||
|
|
||||||
|
await r2Client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: R2_BUCKET_NAME,
|
||||||
|
Key: path
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
|
||||||
|
validateR2Config();
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: R2_BUCKET_NAME,
|
||||||
|
Key: path
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||||
|
}
|
||||||
727
src/lib/stores/api-sync.ts
Normal file
727
src/lib/stores/api-sync.ts
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
import { writable, derived, type Readable, type Writable } from 'svelte/store';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
// Core types for the API sync system
|
||||||
|
export interface EntityState<T = unknown> {
|
||||||
|
data: T;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueuedOperation {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
operation: 'create' | 'update' | 'delete';
|
||||||
|
action?: string;
|
||||||
|
data?: unknown;
|
||||||
|
retry: number;
|
||||||
|
maxRetries: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific entity state types
|
||||||
|
export interface FindLikeState {
|
||||||
|
isLiked: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindState {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string;
|
||||||
|
media?: Array<{
|
||||||
|
id: string;
|
||||||
|
findId: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
orderIndex: number | null;
|
||||||
|
}>;
|
||||||
|
isLikedByUser: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
isFromFriend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentState {
|
||||||
|
id: string;
|
||||||
|
findId: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindCommentsState {
|
||||||
|
comments: CommentState[];
|
||||||
|
commentCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique operation IDs
|
||||||
|
function generateOperationId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create operation key for deduplication
|
||||||
|
function createOperationKey(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
operation: string,
|
||||||
|
action?: string
|
||||||
|
): string {
|
||||||
|
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class APISync {
|
||||||
|
// Entity stores - each entity type has its own store
|
||||||
|
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
|
||||||
|
|
||||||
|
// Operation queue for API calls
|
||||||
|
private operationQueue = new Map<string, QueuedOperation>();
|
||||||
|
private processingQueue = false;
|
||||||
|
|
||||||
|
// Cleanup tracking for memory management
|
||||||
|
private subscriptions = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize core entity stores
|
||||||
|
this.initializeEntityStore('find');
|
||||||
|
this.initializeEntityStore('user');
|
||||||
|
this.initializeEntityStore('friendship');
|
||||||
|
this.initializeEntityStore('comment');
|
||||||
|
|
||||||
|
// Start processing queue
|
||||||
|
this.startQueueProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeEntityStore(entityType: string): void {
|
||||||
|
if (!this.entityStores.has(entityType)) {
|
||||||
|
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
|
||||||
|
this.initializeEntityStore(entityType);
|
||||||
|
return this.entityStores.get(entityType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a specific entity's state
|
||||||
|
*/
|
||||||
|
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
|
||||||
|
return derived(store, ($entities) => {
|
||||||
|
const entity = $entities.get(entityId);
|
||||||
|
if (!entity) {
|
||||||
|
// Return default state if entity doesn't exist
|
||||||
|
return {
|
||||||
|
data: null as T,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return entity as EntityState<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe specifically to find like state
|
||||||
|
*/
|
||||||
|
subscribeFindLikes(findId: string): Readable<FindLikeState> {
|
||||||
|
const store = this.getEntityStore('find');
|
||||||
|
|
||||||
|
return derived(store, ($entities) => {
|
||||||
|
const entity = $entities.get(findId);
|
||||||
|
if (!entity || !entity.data) {
|
||||||
|
return {
|
||||||
|
isLiked: false,
|
||||||
|
likeCount: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const findData = entity.data as FindState;
|
||||||
|
return {
|
||||||
|
isLiked: findData.isLikedByUser,
|
||||||
|
likeCount: findData.likeCount,
|
||||||
|
isLoading: entity.isLoading,
|
||||||
|
error: entity.error
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to find comments state
|
||||||
|
*/
|
||||||
|
subscribeFindComments(findId: string): Readable<FindCommentsState> {
|
||||||
|
const store = this.getEntityStore('comment');
|
||||||
|
|
||||||
|
return derived(store, ($entities) => {
|
||||||
|
const entity = $entities.get(findId);
|
||||||
|
if (!entity || !entity.data) {
|
||||||
|
return {
|
||||||
|
comments: [],
|
||||||
|
commentCount: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentsData = entity.data as CommentState[];
|
||||||
|
return {
|
||||||
|
comments: commentsData,
|
||||||
|
commentCount: commentsData.length,
|
||||||
|
isLoading: entity.isLoading,
|
||||||
|
error: entity.error
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if entity state exists
|
||||||
|
*/
|
||||||
|
hasEntityState(entityType: string, entityId: string): boolean {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
let exists = false;
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe(($entities) => {
|
||||||
|
exists = $entities.has(entityId);
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current entity state
|
||||||
|
*/
|
||||||
|
getEntityState<T>(entityType: string, entityId: string): T | null {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
let currentState: T | null = null;
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe(($entities) => {
|
||||||
|
const entity = $entities.get(entityId);
|
||||||
|
if (entity?.data) {
|
||||||
|
currentState = entity.data as T;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize entity state with server data (only if no existing state)
|
||||||
|
*/
|
||||||
|
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
newEntities.set(entityId, {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize entity state only if it doesn't exist yet
|
||||||
|
*/
|
||||||
|
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
|
||||||
|
if (!this.hasEntityState(entityType, entityId)) {
|
||||||
|
this.setEntityState(entityType, entityId, data, isLoading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update entity loading state
|
||||||
|
*/
|
||||||
|
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(entityId);
|
||||||
|
if (existing) {
|
||||||
|
newEntities.set(entityId, {
|
||||||
|
...existing,
|
||||||
|
isLoading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update entity error state
|
||||||
|
*/
|
||||||
|
private setEntityError(entityType: string, entityId: string, error: string): void {
|
||||||
|
const store = this.getEntityStore(entityType);
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(entityId);
|
||||||
|
if (existing) {
|
||||||
|
newEntities.set(entityId, {
|
||||||
|
...existing,
|
||||||
|
isLoading: false,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue an operation for processing
|
||||||
|
*/
|
||||||
|
async queueOperation(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
operation: 'create' | 'update' | 'delete',
|
||||||
|
action?: string,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const operationKey = createOperationKey(entityType, entityId, operation, action);
|
||||||
|
|
||||||
|
// Check if same operation is already queued
|
||||||
|
if (this.operationQueue.has(operationKey)) {
|
||||||
|
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedOperation: QueuedOperation = {
|
||||||
|
id: generateOperationId(),
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
operation,
|
||||||
|
action,
|
||||||
|
data,
|
||||||
|
retry: 0,
|
||||||
|
maxRetries: 3,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.operationQueue.set(operationKey, queuedOperation);
|
||||||
|
|
||||||
|
// Set entity to loading state
|
||||||
|
this.setEntityLoading(entityType, entityId, true);
|
||||||
|
|
||||||
|
// Process queue if not already processing
|
||||||
|
if (!this.processingQueue) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the operation queue
|
||||||
|
*/
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
if (this.processingQueue || this.operationQueue.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingQueue = true;
|
||||||
|
|
||||||
|
const operations = Array.from(this.operationQueue.entries());
|
||||||
|
|
||||||
|
for (const [operationKey, operation] of operations) {
|
||||||
|
try {
|
||||||
|
await this.executeOperation(operation);
|
||||||
|
this.operationQueue.delete(operationKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Operation ${operationKey} failed:`, error);
|
||||||
|
|
||||||
|
if (operation.retry < operation.maxRetries) {
|
||||||
|
operation.retry++;
|
||||||
|
console.log(
|
||||||
|
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
|
||||||
|
this.operationQueue.delete(operationKey);
|
||||||
|
this.setEntityError(
|
||||||
|
operation.entityType,
|
||||||
|
operation.entityId,
|
||||||
|
'Operation failed after multiple retries'
|
||||||
|
);
|
||||||
|
toast.error('Failed to sync changes. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingQueue = false;
|
||||||
|
|
||||||
|
// If more operations were added while processing, process again
|
||||||
|
if (this.operationQueue.size > 0) {
|
||||||
|
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a specific operation
|
||||||
|
*/
|
||||||
|
private async executeOperation(operation: QueuedOperation): Promise<void> {
|
||||||
|
const { entityType, entityId, operation: op, action, data } = operation;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
if (entityType === 'find' && action === 'like') {
|
||||||
|
// Handle like operations
|
||||||
|
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
|
||||||
|
response = await fetch(`/api/finds/${entityId}/like`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (entityType === 'comment' && op === 'create') {
|
||||||
|
// Handle comment creation
|
||||||
|
response = await fetch(`/api/finds/${entityId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else if (entityType === 'comment' && op === 'delete') {
|
||||||
|
// Handle comment deletion
|
||||||
|
response = await fetch(`/api/finds/comments/${entityId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Update entity state with successful result
|
||||||
|
if (entityType === 'find' && action === 'like') {
|
||||||
|
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
||||||
|
} else if (entityType === 'comment' && op === 'create') {
|
||||||
|
this.addCommentToState(result.data.findId, result.data);
|
||||||
|
} else if (entityType === 'comment' && op === 'delete') {
|
||||||
|
this.removeCommentFromState(entityId, data as { findId: string });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update find like state after successful API call
|
||||||
|
*/
|
||||||
|
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
|
||||||
|
const store = this.getEntityStore('find');
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(findId);
|
||||||
|
|
||||||
|
if (existing && existing.data) {
|
||||||
|
const findData = existing.data as FindState;
|
||||||
|
newEntities.set(findId, {
|
||||||
|
...existing,
|
||||||
|
data: {
|
||||||
|
...findData,
|
||||||
|
isLikedByUser: isLiked,
|
||||||
|
likeCount: likeCount
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the queue processor
|
||||||
|
*/
|
||||||
|
private startQueueProcessor(): void {
|
||||||
|
// Process queue every 100ms
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.operationQueue.size > 0 && !this.processingQueue) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle like for a find
|
||||||
|
*/
|
||||||
|
async toggleLike(findId: string): Promise<void> {
|
||||||
|
// Get current state for optimistic update
|
||||||
|
const store = this.getEntityStore('find');
|
||||||
|
let currentState: FindState | null = null;
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe(($entities) => {
|
||||||
|
const entity = $entities.get(findId);
|
||||||
|
if (entity?.data) {
|
||||||
|
currentState = entity.data as FindState;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
if (!currentState) {
|
||||||
|
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const findState = currentState as FindState;
|
||||||
|
const newIsLiked = !findState.isLikedByUser;
|
||||||
|
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
|
||||||
|
|
||||||
|
// Update state optimistically
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(findId);
|
||||||
|
|
||||||
|
if (existing && existing.data) {
|
||||||
|
const findData = existing.data as FindState;
|
||||||
|
newEntities.set(findId, {
|
||||||
|
...existing,
|
||||||
|
data: {
|
||||||
|
...findData,
|
||||||
|
isLikedByUser: newIsLiked,
|
||||||
|
likeCount: newLikeCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue the operation
|
||||||
|
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add comment to find comments state
|
||||||
|
*/
|
||||||
|
private addCommentToState(findId: string, comment: CommentState): void {
|
||||||
|
const store = this.getEntityStore('comment');
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(findId);
|
||||||
|
|
||||||
|
const comments = existing?.data ? (existing.data as CommentState[]) : [];
|
||||||
|
|
||||||
|
// If this is a real comment from server, remove any temporary comment with the same content
|
||||||
|
let updatedComments = comments;
|
||||||
|
if (!comment.id.startsWith('temp-')) {
|
||||||
|
updatedComments = comments.filter(
|
||||||
|
(c) => !(c.id.startsWith('temp-') && c.content === comment.content)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedComments = [comment, ...updatedComments];
|
||||||
|
|
||||||
|
newEntities.set(findId, {
|
||||||
|
data: updatedComments,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update find comment count only for temp comments (optimistic updates)
|
||||||
|
if (comment.id.startsWith('temp-')) {
|
||||||
|
this.updateFindCommentCount(findId, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove comment from find comments state
|
||||||
|
*/
|
||||||
|
private removeCommentFromState(commentId: string, data: { findId: string }): void {
|
||||||
|
const store = this.getEntityStore('comment');
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(data.findId);
|
||||||
|
|
||||||
|
if (existing?.data) {
|
||||||
|
const comments = existing.data as CommentState[];
|
||||||
|
const updatedComments = comments.filter((c) => c.id !== commentId);
|
||||||
|
|
||||||
|
newEntities.set(data.findId, {
|
||||||
|
...existing,
|
||||||
|
data: updatedComments,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update find comment count
|
||||||
|
this.updateFindCommentCount(data.findId, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update find comment count
|
||||||
|
*/
|
||||||
|
private updateFindCommentCount(findId: string, delta: number): void {
|
||||||
|
const store = this.getEntityStore('find');
|
||||||
|
|
||||||
|
store.update(($entities) => {
|
||||||
|
const newEntities = new Map($entities);
|
||||||
|
const existing = newEntities.get(findId);
|
||||||
|
|
||||||
|
if (existing && existing.data) {
|
||||||
|
const findData = existing.data as FindState;
|
||||||
|
newEntities.set(findId, {
|
||||||
|
...existing,
|
||||||
|
data: {
|
||||||
|
...findData,
|
||||||
|
commentCount: Math.max(0, findData.commentCount + delta)
|
||||||
|
},
|
||||||
|
lastUpdated: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntities;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load comments for a find
|
||||||
|
*/
|
||||||
|
async loadComments(findId: string): Promise<void> {
|
||||||
|
if (this.hasEntityState('comment', findId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setEntityLoading('comment', findId, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/finds/${findId}/comments`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
this.setEntityState('comment', findId, result.data, false);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to load comments');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading comments:', error);
|
||||||
|
this.setEntityError('comment', findId, 'Failed to load comments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a comment to a find
|
||||||
|
*/
|
||||||
|
async addComment(findId: string, content: string): Promise<void> {
|
||||||
|
// Optimistic update: add temporary comment
|
||||||
|
const tempComment: CommentState = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
findId,
|
||||||
|
content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
user: {
|
||||||
|
id: 'current-user',
|
||||||
|
username: 'You',
|
||||||
|
profilePictureUrl: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addCommentToState(findId, tempComment);
|
||||||
|
|
||||||
|
// Queue the operation
|
||||||
|
await this.queueOperation('comment', findId, 'create', undefined, { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a comment
|
||||||
|
*/
|
||||||
|
async deleteComment(commentId: string, findId: string): Promise<void> {
|
||||||
|
// Optimistic update: remove comment
|
||||||
|
this.removeCommentFromState(commentId, { findId });
|
||||||
|
|
||||||
|
// Queue the operation
|
||||||
|
await this.queueOperation('comment', commentId, 'delete', undefined, { findId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize find data from server (only if no existing state)
|
||||||
|
*/
|
||||||
|
initializeFindData(finds: FindState[]): void {
|
||||||
|
for (const find of finds) {
|
||||||
|
this.initializeEntityState('find', find.id, find);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize comments data for a find
|
||||||
|
*/
|
||||||
|
initializeCommentsData(findId: string, comments: CommentState[]): void {
|
||||||
|
this.initializeEntityState('comment', findId, comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup unused subscriptions (call this when components unmount)
|
||||||
|
*/
|
||||||
|
cleanup(entityType: string, entityId: string): void {
|
||||||
|
const key = `${entityType}:${entityId}`;
|
||||||
|
const subscriptions = this.subscriptions.get(key);
|
||||||
|
if (subscriptions) {
|
||||||
|
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||||
|
this.subscriptions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const apiSync = new APISync();
|
||||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
|||||||
locationStore,
|
locationStore,
|
||||||
($location) => $location.shouldZoomToLocation
|
($location) => $location.shouldZoomToLocation
|
||||||
);
|
);
|
||||||
|
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||||
|
|
||||||
// Location actions
|
// Location actions
|
||||||
export const locationActions = {
|
export const locationActions = {
|
||||||
|
|||||||
194
src/lib/utils/places.ts
Normal file
194
src/lib/utils/places.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export interface PlaceResult {
|
||||||
|
placeId: string;
|
||||||
|
name: string;
|
||||||
|
formattedAddress: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
types: string[];
|
||||||
|
vicinity?: string;
|
||||||
|
rating?: number;
|
||||||
|
priceLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GooglePlacesPrediction {
|
||||||
|
place_id: string;
|
||||||
|
description: string;
|
||||||
|
types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GooglePlacesResult {
|
||||||
|
place_id: string;
|
||||||
|
name: string;
|
||||||
|
formatted_address?: string;
|
||||||
|
vicinity?: string;
|
||||||
|
geometry: {
|
||||||
|
location: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
types?: string[];
|
||||||
|
rating?: number;
|
||||||
|
price_level?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaceSearchOptions {
|
||||||
|
query?: string;
|
||||||
|
location?: { lat: number; lng: number };
|
||||||
|
radius?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GooglePlacesService {
|
||||||
|
private apiKey: string;
|
||||||
|
private baseUrl = 'https://maps.googleapis.com/maps/api/place';
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for places using text query
|
||||||
|
*/
|
||||||
|
async searchPlaces(
|
||||||
|
query: string,
|
||||||
|
location?: { lat: number; lng: number }
|
||||||
|
): Promise<PlaceResult[]> {
|
||||||
|
const url = new URL(`${this.baseUrl}/textsearch/json`);
|
||||||
|
url.searchParams.set('query', query);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', '50000'); // 50km radius
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.results.map(this.formatPlaceResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching places:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get place autocomplete suggestions
|
||||||
|
*/
|
||||||
|
async getAutocompleteSuggestions(
|
||||||
|
input: string,
|
||||||
|
location?: { lat: number; lng: number }
|
||||||
|
): Promise<Array<{ placeId: string; description: string; types: string[] }>> {
|
||||||
|
const url = new URL(`${this.baseUrl}/autocomplete/json`);
|
||||||
|
url.searchParams.set('input', input);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', '50000');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.predictions?.map((prediction: GooglePlacesPrediction) => ({
|
||||||
|
placeId: prediction.place_id,
|
||||||
|
description: prediction.description,
|
||||||
|
types: prediction.types
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting autocomplete suggestions:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a place
|
||||||
|
*/
|
||||||
|
async getPlaceDetails(placeId: string): Promise<PlaceResult> {
|
||||||
|
const url = new URL(`${this.baseUrl}/details/json`);
|
||||||
|
url.searchParams.set('place_id', placeId);
|
||||||
|
url.searchParams.set(
|
||||||
|
'fields',
|
||||||
|
'place_id,name,formatted_address,geometry,types,vicinity,rating,price_level'
|
||||||
|
);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatPlaceResult(data.result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting place details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find nearby places
|
||||||
|
*/
|
||||||
|
async findNearbyPlaces(
|
||||||
|
location: { lat: number; lng: number },
|
||||||
|
radius: number = 5000,
|
||||||
|
type?: string
|
||||||
|
): Promise<PlaceResult[]> {
|
||||||
|
const url = new URL(`${this.baseUrl}/nearbysearch/json`);
|
||||||
|
url.searchParams.set('location', `${location.lat},${location.lng}`);
|
||||||
|
url.searchParams.set('radius', radius.toString());
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
url.searchParams.set('type', type);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
|
||||||
|
throw new Error(`Places API error: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.results?.map(this.formatPlaceResult) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding nearby places:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPlaceResult = (place: GooglePlacesResult): PlaceResult => {
|
||||||
|
return {
|
||||||
|
placeId: place.place_id,
|
||||||
|
name: place.name,
|
||||||
|
formattedAddress: place.formatted_address || place.vicinity || '',
|
||||||
|
latitude: place.geometry.location.lat,
|
||||||
|
longitude: place.geometry.location.lng,
|
||||||
|
types: place.types || [],
|
||||||
|
vicinity: place.vicinity,
|
||||||
|
rating: place.rating,
|
||||||
|
priceLevel: place.price_level
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlacesService(apiKey: string): GooglePlacesService {
|
||||||
|
return new GooglePlacesService(apiKey);
|
||||||
|
}
|
||||||
@@ -5,11 +5,22 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Toaster } from '$lib/components/sonner/index.js';
|
import { Toaster } from '$lib/components/sonner/index.js';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
import LocationManager from '$lib/components/LocationManager.svelte';
|
||||||
|
import NotificationManager from '$lib/components/NotificationManager.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||||
let showHeader = $derived(!isLoginRoute && data?.user);
|
let showHeader = $derived(!isLoginRoute && data?.user);
|
||||||
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
// Handle loading state only on client to prevent hydration mismatch
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
isLoading = !isLoginRoute && !data?.user && data !== null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -32,6 +43,12 @@
|
|||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
|
<!-- Auto-start location and notfication watching for authenticated users -->
|
||||||
|
{#if data?.user && !isLoginRoute}
|
||||||
|
<LocationManager autoStart={true} />
|
||||||
|
<NotificationManager />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showHeader && data.user}
|
{#if showHeader && data.user}
|
||||||
<Header user={data.user} />
|
<Header user={data.user} />
|
||||||
{:else if isLoading}
|
{:else if isLoading}
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||||
if (!event.locals.user) {
|
if (!locals.user) {
|
||||||
// if not logged in, redirect to login page
|
|
||||||
return redirect(302, '/login');
|
return redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Build API URL with query parameters
|
||||||
user: event.locals.user
|
const apiUrl = new URL('/api/finds', url.origin);
|
||||||
};
|
|
||||||
|
// Forward location filtering parameters
|
||||||
|
const lat = url.searchParams.get('lat');
|
||||||
|
const lng = url.searchParams.get('lng');
|
||||||
|
const radius = url.searchParams.get('radius') || '50';
|
||||||
|
|
||||||
|
if (lat) apiUrl.searchParams.set('lat', lat);
|
||||||
|
if (lng) apiUrl.searchParams.set('lng', lng);
|
||||||
|
apiUrl.searchParams.set('radius', radius);
|
||||||
|
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
|
||||||
|
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
|
||||||
|
apiUrl.searchParams.set('order', 'desc'); // Newest first
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl.toString(), {
|
||||||
|
headers: {
|
||||||
|
Cookie: request.headers.get('Cookie') || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finds = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
finds
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading finds:', err);
|
||||||
|
return {
|
||||||
|
finds: []
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,220 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Map } from '$lib';
|
import { Map } from '$lib';
|
||||||
|
import FindsList from '$lib/components/FindsList.svelte';
|
||||||
|
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
|
||||||
|
import FindPreview from '$lib/components/FindPreview.svelte';
|
||||||
|
import FindsFilter from '$lib/components/FindsFilter.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { coordinates } from '$lib/stores/location';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
|
// Server response type
|
||||||
|
interface ServerFind {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
isPublic: number;
|
||||||
|
createdAt: string; // Will be converted to Date type, but is a string from api
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
likeCount?: number;
|
||||||
|
isLikedByUser?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
|
media: Array<{
|
||||||
|
id: string;
|
||||||
|
findId: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
orderIndex: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map component type
|
||||||
|
interface MapFind {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
isPublic: number;
|
||||||
|
createdAt: Date;
|
||||||
|
userId: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
};
|
||||||
|
likeCount?: number;
|
||||||
|
isLiked?: boolean;
|
||||||
|
isFromFriend?: boolean;
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for FindPreview component
|
||||||
|
interface FindPreviewData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
locationName?: string;
|
||||||
|
category?: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl?: string | null;
|
||||||
|
};
|
||||||
|
likeCount?: number;
|
||||||
|
isLiked?: boolean;
|
||||||
|
media?: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
|
||||||
|
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let selectedFind: FindPreviewData | null = $state(null);
|
||||||
|
let currentFilter = $state('all');
|
||||||
|
|
||||||
|
// Initialize API sync with server data on mount
|
||||||
|
onMount(async () => {
|
||||||
|
if (browser && data.finds && data.finds.length > 0) {
|
||||||
|
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
||||||
|
id: serverFind.id,
|
||||||
|
title: serverFind.title,
|
||||||
|
description: serverFind.description,
|
||||||
|
latitude: serverFind.latitude,
|
||||||
|
longitude: serverFind.longitude,
|
||||||
|
locationName: serverFind.locationName,
|
||||||
|
category: serverFind.category,
|
||||||
|
isPublic: Boolean(serverFind.isPublic),
|
||||||
|
createdAt: new Date(serverFind.createdAt),
|
||||||
|
userId: serverFind.userId,
|
||||||
|
username: serverFind.username,
|
||||||
|
profilePictureUrl: serverFind.profilePictureUrl || undefined,
|
||||||
|
media: serverFind.media,
|
||||||
|
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
||||||
|
likeCount: serverFind.likeCount || 0,
|
||||||
|
commentCount: 0,
|
||||||
|
isFromFriend: Boolean(serverFind.isFromFriend)
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiSync.initializeFindData(findStates);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// All finds - convert server format to component format
|
||||||
|
let allFinds = $derived(
|
||||||
|
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
||||||
|
...serverFind,
|
||||||
|
createdAt: new Date(serverFind.createdAt), // Convert string to Date
|
||||||
|
user: {
|
||||||
|
id: serverFind.userId,
|
||||||
|
username: serverFind.username,
|
||||||
|
profilePictureUrl: serverFind.profilePictureUrl
|
||||||
|
},
|
||||||
|
likeCount: serverFind.likeCount,
|
||||||
|
isLiked: serverFind.isLikedByUser,
|
||||||
|
isFromFriend: serverFind.isFromFriend,
|
||||||
|
media: serverFind.media?.map(
|
||||||
|
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
|
||||||
|
type: m.type,
|
||||||
|
url: m.url,
|
||||||
|
thumbnailUrl: m.thumbnailUrl || m.url
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})) as MapFind[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filtered finds based on current filter
|
||||||
|
let finds = $derived.by(() => {
|
||||||
|
if (!data.user) return allFinds;
|
||||||
|
|
||||||
|
switch (currentFilter) {
|
||||||
|
case 'public':
|
||||||
|
return allFinds.filter((find) => find.isPublic === 1);
|
||||||
|
case 'friends':
|
||||||
|
return allFinds.filter((find) => find.isFromFriend === true);
|
||||||
|
case 'mine':
|
||||||
|
return allFinds.filter((find) => find.userId === data.user!.id);
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return allFinds;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFilterChange(filter: string) {
|
||||||
|
currentFilter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFindCreated(event: CustomEvent) {
|
||||||
|
// For now, just close modal and refresh page as in original implementation
|
||||||
|
showCreateModal = false;
|
||||||
|
if (event.detail?.reload) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFindClick(find: MapFind) {
|
||||||
|
// Convert MapFind to FindPreviewData format
|
||||||
|
selectedFind = {
|
||||||
|
id: find.id,
|
||||||
|
title: find.title,
|
||||||
|
description: find.description,
|
||||||
|
latitude: find.latitude,
|
||||||
|
longitude: find.longitude,
|
||||||
|
locationName: find.locationName,
|
||||||
|
category: find.category,
|
||||||
|
createdAt: find.createdAt.toISOString(),
|
||||||
|
user: find.user,
|
||||||
|
likeCount: find.likeCount,
|
||||||
|
isLiked: find.isLiked,
|
||||||
|
media: find.media?.map((m) => ({
|
||||||
|
type: m.type,
|
||||||
|
url: m.url,
|
||||||
|
thumbnailUrl: m.thumbnailUrl || m.url
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFindExplore(id: string) {
|
||||||
|
// Find the specific find and show preview
|
||||||
|
const find = finds.find((f) => f.id === id);
|
||||||
|
if (find) {
|
||||||
|
handleFindClick(find);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFindPreview() {
|
||||||
|
selectedFind = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
showCreateModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
showCreateModal = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -25,14 +240,61 @@
|
|||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="map-section">
|
<div class="map-section">
|
||||||
<Map showLocationButton={true} autoCenter={true} />
|
<Map
|
||||||
|
showLocationButton={true}
|
||||||
|
autoCenter={true}
|
||||||
|
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||||
|
{finds}
|
||||||
|
onFindClick={handleFindClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="finds-section">
|
||||||
|
<div class="finds-sticky-header">
|
||||||
|
<div class="finds-header-content">
|
||||||
|
<div class="finds-title-section">
|
||||||
|
<h2 class="finds-title">Finds</h2>
|
||||||
|
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||||
|
</div>
|
||||||
|
<Button onclick={openCreateModal} class="create-find-button">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
Create Find
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Floating action button for mobile -->
|
||||||
|
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<CreateFindModal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onClose={closeCreateModal}
|
||||||
|
onFindCreated={handleFindCreated}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedFind}
|
||||||
|
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.home-container {
|
||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -56,16 +318,120 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finds-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-sticky-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
padding: 24px 24px 16px 24px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-title-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-title {
|
||||||
|
font-family: 'Washington', serif;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.create-find-button) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finds-sticky-header {
|
||||||
|
padding: 16px 16px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-title-section {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finds-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.create-find-button) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section :global(.map-container) {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finds-sticky-header {
|
||||||
|
padding: 12px 12px 8px 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
278
src/routes/api/finds/+server.ts
Normal file
278
src/routes/api/finds/+server.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { getSignedR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
|
function generateFindId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
|
return encodeBase64url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = url.searchParams.get('lat');
|
||||||
|
const lng = url.searchParams.get('lng');
|
||||||
|
const radius = url.searchParams.get('radius') || '50';
|
||||||
|
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||||
|
const order = url.searchParams.get('order') || 'desc';
|
||||||
|
|
||||||
|
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user's friends if needed
|
||||||
|
let friendIds: string[] = [];
|
||||||
|
if (includeFriends || includePrivate) {
|
||||||
|
const friendships = await db
|
||||||
|
.select({
|
||||||
|
userId: friendship.userId,
|
||||||
|
friendId: friendship.friendId
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.status, 'accepted'),
|
||||||
|
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build privacy conditions
|
||||||
|
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||||
|
|
||||||
|
if (includePrivate) {
|
||||||
|
// Include user's own finds (both public and private)
|
||||||
|
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeFriends && friendIds.length > 0) {
|
||||||
|
// Include friends' finds (both public and private)
|
||||||
|
conditions.push(
|
||||||
|
sql`${find.userId} IN (${sql.join(
|
||||||
|
friendIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||||
|
|
||||||
|
let whereConditions = baseCondition;
|
||||||
|
|
||||||
|
// Add location filtering if coordinates provided
|
||||||
|
if (lat && lng) {
|
||||||
|
const radiusKm = parseFloat(radius);
|
||||||
|
const latOffset = radiusKm / 111;
|
||||||
|
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
|
||||||
|
|
||||||
|
const locationConditions = and(
|
||||||
|
baseCondition,
|
||||||
|
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
|
||||||
|
parseFloat(lat) + latOffset
|
||||||
|
}`,
|
||||||
|
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
|
||||||
|
parseFloat(lng) + lngOffset
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (locationConditions) {
|
||||||
|
whereConditions = locationConditions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all finds with filtering, like counts, and user's liked status
|
||||||
|
const finds = await db
|
||||||
|
.select({
|
||||||
|
id: find.id,
|
||||||
|
title: find.title,
|
||||||
|
description: find.description,
|
||||||
|
latitude: find.latitude,
|
||||||
|
longitude: find.longitude,
|
||||||
|
locationName: find.locationName,
|
||||||
|
category: find.category,
|
||||||
|
isPublic: find.isPublic,
|
||||||
|
createdAt: find.createdAt,
|
||||||
|
userId: find.userId,
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl,
|
||||||
|
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||||
|
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
|
||||||
|
SELECT 1 FROM ${findLike}
|
||||||
|
WHERE ${findLike.findId} = ${find.id}
|
||||||
|
AND ${findLike.userId} = ${locals.user.id}
|
||||||
|
) THEN 1 ELSE 0 END`,
|
||||||
|
isFromFriend: sql<boolean>`CASE WHEN ${
|
||||||
|
friendIds.length > 0
|
||||||
|
? sql`${find.userId} IN (${sql.join(
|
||||||
|
friendIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
: sql`FALSE`
|
||||||
|
} THEN 1 ELSE 0 END`
|
||||||
|
})
|
||||||
|
.from(find)
|
||||||
|
.innerJoin(user, eq(find.userId, user.id))
|
||||||
|
.leftJoin(findLike, eq(find.id, findLike.findId))
|
||||||
|
.where(whereConditions)
|
||||||
|
.groupBy(find.id, user.username, user.profilePictureUrl)
|
||||||
|
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
// Get media for all finds
|
||||||
|
const findIds = finds.map((f) => f.id);
|
||||||
|
let media: Array<{
|
||||||
|
id: string;
|
||||||
|
findId: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
orderIndex: number | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (findIds.length > 0) {
|
||||||
|
media = await db
|
||||||
|
.select({
|
||||||
|
id: findMedia.id,
|
||||||
|
findId: findMedia.findId,
|
||||||
|
type: findMedia.type,
|
||||||
|
url: findMedia.url,
|
||||||
|
thumbnailUrl: findMedia.thumbnailUrl,
|
||||||
|
orderIndex: findMedia.orderIndex
|
||||||
|
})
|
||||||
|
.from(findMedia)
|
||||||
|
.where(
|
||||||
|
sql`${findMedia.findId} IN (${sql.join(
|
||||||
|
findIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
.orderBy(findMedia.orderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group media by find
|
||||||
|
const mediaByFind = media.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (!acc[item.findId]) {
|
||||||
|
acc[item.findId] = [];
|
||||||
|
}
|
||||||
|
acc[item.findId].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof media>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine finds with their media and generate signed URLs
|
||||||
|
const findsWithMedia = await Promise.all(
|
||||||
|
finds.map(async (findItem) => {
|
||||||
|
const findMedia = mediaByFind[findItem.id] || [];
|
||||||
|
|
||||||
|
// Generate signed URLs for all media items
|
||||||
|
const mediaWithSignedUrls = await Promise.all(
|
||||||
|
findMedia.map(async (mediaItem) => {
|
||||||
|
// URLs in database are now paths, generate signed URLs directly
|
||||||
|
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
||||||
|
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
||||||
|
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||||
|
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
||||||
|
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mediaItem,
|
||||||
|
url: signedUrl,
|
||||||
|
thumbnailUrl: signedThumbnailUrl
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate signed URL for user profile picture if it exists
|
||||||
|
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||||
|
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate signed URL for user profile picture:', error);
|
||||||
|
userProfilePictureUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...findItem,
|
||||||
|
profilePictureUrl: userProfilePictureUrl,
|
||||||
|
media: mediaWithSignedUrls,
|
||||||
|
isLikedByUser: Boolean(findItem.isLikedByUser),
|
||||||
|
isFromFriend: Boolean(findItem.isFromFriend)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(findsWithMedia);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading finds:', err);
|
||||||
|
throw error(500, 'Failed to load finds');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json();
|
||||||
|
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
|
||||||
|
|
||||||
|
if (!title || !latitude || !longitude) {
|
||||||
|
throw error(400, 'Title, latitude, and longitude are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length > 100) {
|
||||||
|
throw error(400, 'Title must be 100 characters or less');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description && description.length > 500) {
|
||||||
|
throw error(400, 'Description must be 500 characters or less');
|
||||||
|
}
|
||||||
|
|
||||||
|
const findId = generateFindId();
|
||||||
|
|
||||||
|
// Create find
|
||||||
|
const newFind = await db
|
||||||
|
.insert(find)
|
||||||
|
.values({
|
||||||
|
id: findId,
|
||||||
|
userId: locals.user.id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
latitude: latitude.toString(),
|
||||||
|
longitude: longitude.toString(),
|
||||||
|
locationName,
|
||||||
|
category,
|
||||||
|
isPublic: isPublic ? 1 : 0
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Create media records if provided
|
||||||
|
if (media && media.length > 0) {
|
||||||
|
const mediaRecords = media.map(
|
||||||
|
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
|
||||||
|
id: generateFindId(),
|
||||||
|
findId,
|
||||||
|
type: item.type,
|
||||||
|
url: item.url,
|
||||||
|
thumbnailUrl: item.thumbnailUrl,
|
||||||
|
orderIndex: index
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(findMedia).values(mediaRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, find: newFind[0] });
|
||||||
|
};
|
||||||
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
172
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { findComment, user, find } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const session = locals.session;
|
||||||
|
if (!session) {
|
||||||
|
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const findId = params.findId;
|
||||||
|
if (!findId) {
|
||||||
|
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comments = await db
|
||||||
|
.select({
|
||||||
|
id: findComment.id,
|
||||||
|
findId: findComment.findId,
|
||||||
|
content: findComment.content,
|
||||||
|
createdAt: findComment.createdAt,
|
||||||
|
updatedAt: findComment.updatedAt,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.from(findComment)
|
||||||
|
.innerJoin(user, eq(findComment.userId, user.id))
|
||||||
|
.where(eq(findComment.findId, findId))
|
||||||
|
.orderBy(desc(findComment.createdAt));
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
data: comments,
|
||||||
|
count: comments.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
return json({ success: false, error: 'Failed to fetch comments' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals, request }) => {
|
||||||
|
const session = locals.session;
|
||||||
|
if (!session) {
|
||||||
|
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const findId = params.findId;
|
||||||
|
if (!findId) {
|
||||||
|
return json({ success: false, error: 'Find ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { content } = body;
|
||||||
|
|
||||||
|
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||||
|
return json({ success: false, error: 'Comment content is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length > 500) {
|
||||||
|
return json(
|
||||||
|
{ success: false, error: 'Comment too long (max 500 characters)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentId = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(findComment)
|
||||||
|
.values({
|
||||||
|
id: commentId,
|
||||||
|
findId,
|
||||||
|
userId: session.userId,
|
||||||
|
content: content.trim(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const commentWithUser = await db
|
||||||
|
.select({
|
||||||
|
id: findComment.id,
|
||||||
|
findId: findComment.findId,
|
||||||
|
content: findComment.content,
|
||||||
|
createdAt: findComment.createdAt,
|
||||||
|
updatedAt: findComment.updatedAt,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.from(findComment)
|
||||||
|
.innerJoin(user, eq(findComment.userId, user.id))
|
||||||
|
.where(eq(findComment.id, commentId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (commentWithUser.length === 0) {
|
||||||
|
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification to find owner if not self-comment
|
||||||
|
const findData = await db.select().from(find).where(eq(find.id, findId)).limit(1);
|
||||||
|
|
||||||
|
if (findData.length > 0 && findData[0].userId !== session.userId) {
|
||||||
|
const findOwner = findData[0];
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(
|
||||||
|
findOwner.userId,
|
||||||
|
'find_commented'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get commenter's username
|
||||||
|
const commenterUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, session.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const commenterUsername = commenterUser[0]?.username || 'Someone';
|
||||||
|
const findTitle = findOwner.title || 'your find';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: findOwner.userId,
|
||||||
|
type: 'find_commented',
|
||||||
|
title: 'New comment on your find',
|
||||||
|
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
commentId,
|
||||||
|
commenterId: session.userId,
|
||||||
|
commenterUsername,
|
||||||
|
findTitle,
|
||||||
|
commentContent: content.trim().substring(0, 100)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(findOwner.userId, {
|
||||||
|
title: 'New comment on your find',
|
||||||
|
message: `${commenterUsername} commented on: ${findTitle}`,
|
||||||
|
url: `/?find=${findOwner.id}`,
|
||||||
|
tag: 'find_commented',
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
commentId,
|
||||||
|
commenterId: session.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
data: commentWithUser[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating comment:', error);
|
||||||
|
return json({ success: false, error: 'Failed to create comment' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
150
src/routes/api/finds/[findId]/like/+server.ts
Normal file
150
src/routes/api/finds/[findId]/like/+server.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { findLike, find, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
|
function generateLikeId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
|
return encodeBase64url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({
|
||||||
|
params,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
params: { findId: string };
|
||||||
|
locals: { user: { id: string } };
|
||||||
|
}) {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const findId = params.findId;
|
||||||
|
|
||||||
|
if (!findId) {
|
||||||
|
throw error(400, 'Find ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if find exists
|
||||||
|
const existingFind = await db.select().from(find).where(eq(find.id, findId)).limit(1);
|
||||||
|
if (existingFind.length === 0) {
|
||||||
|
throw error(404, 'Find not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already liked this find
|
||||||
|
const existingLike = await db
|
||||||
|
.select()
|
||||||
|
.from(findLike)
|
||||||
|
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingLike.length > 0) {
|
||||||
|
throw error(409, 'Find already liked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new like
|
||||||
|
const likeId = generateLikeId();
|
||||||
|
await db.insert(findLike).values({
|
||||||
|
id: likeId,
|
||||||
|
findId,
|
||||||
|
userId: locals.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated like count
|
||||||
|
const likeCountResult = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(findLike)
|
||||||
|
.where(eq(findLike.findId, findId));
|
||||||
|
|
||||||
|
const likeCount = likeCountResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
// Send notification to find owner if not self-like
|
||||||
|
const findOwner = existingFind[0];
|
||||||
|
if (findOwner.userId !== locals.user.id) {
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_liked');
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get liker's username
|
||||||
|
const likerUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, locals.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const likerUsername = likerUser[0]?.username || 'Someone';
|
||||||
|
const findTitle = findOwner.title || 'your find';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: findOwner.userId,
|
||||||
|
type: 'find_liked',
|
||||||
|
title: 'Someone liked your find',
|
||||||
|
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
likerId: locals.user.id,
|
||||||
|
likerUsername,
|
||||||
|
findTitle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(findOwner.userId, {
|
||||||
|
title: 'Someone liked your find',
|
||||||
|
message: `${likerUsername} liked your find: ${findTitle}`,
|
||||||
|
url: `/?find=${findOwner.id}`,
|
||||||
|
tag: 'find_liked',
|
||||||
|
data: {
|
||||||
|
findId: findOwner.id,
|
||||||
|
likerId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
likeId,
|
||||||
|
isLiked: true,
|
||||||
|
likeCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({
|
||||||
|
params,
|
||||||
|
locals
|
||||||
|
}: {
|
||||||
|
params: { findId: string };
|
||||||
|
locals: { user: { id: string } };
|
||||||
|
}) {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const findId = params.findId;
|
||||||
|
|
||||||
|
if (!findId) {
|
||||||
|
throw error(400, 'Find ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove like
|
||||||
|
await db
|
||||||
|
.delete(findLike)
|
||||||
|
.where(and(eq(findLike.findId, findId), eq(findLike.userId, locals.user.id)));
|
||||||
|
|
||||||
|
// Get updated like count
|
||||||
|
const likeCountResult = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(findLike)
|
||||||
|
.where(eq(findLike.findId, findId));
|
||||||
|
|
||||||
|
const likeCount = likeCountResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
isLiked: false,
|
||||||
|
likeCount
|
||||||
|
});
|
||||||
|
}
|
||||||
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { findComment } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const session = locals.session;
|
||||||
|
if (!session) {
|
||||||
|
return json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentId = params.commentId;
|
||||||
|
if (!commentId) {
|
||||||
|
return json({ success: false, error: 'Comment ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingComment = await db
|
||||||
|
.select()
|
||||||
|
.from(findComment)
|
||||||
|
.where(eq(findComment.id, commentId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingComment.length === 0) {
|
||||||
|
return json({ success: false, error: 'Comment not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingComment[0].userId !== session.userId) {
|
||||||
|
return json(
|
||||||
|
{ success: false, error: 'Not authorized to delete this comment' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(findComment).where(eq(findComment.id, commentId));
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
data: { id: commentId }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting comment:', error);
|
||||||
|
return json({ success: false, error: 'Failed to delete comment' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
58
src/routes/api/finds/upload/+server.ts
Normal file
58
src/routes/api/finds/upload/+server.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
const MAX_FILES = 5;
|
||||||
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime'];
|
||||||
|
|
||||||
|
function generateFindId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
|
return encodeBase64url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const files = formData.getAll('files') as File[];
|
||||||
|
const findId = (formData.get('findId') as string) || generateFindId(); // Generate if creating new Find
|
||||||
|
|
||||||
|
if (files.length === 0 || files.length > MAX_FILES) {
|
||||||
|
throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedMedia: Array<{
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
fallbackUrl?: string;
|
||||||
|
fallbackThumbnailUrl?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw error(400, `File ${file.name} exceeds maximum size of 100MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process based on type
|
||||||
|
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
const result = await processAndUploadImage(file, findId, i);
|
||||||
|
uploadedMedia.push({ type: 'photo', ...result });
|
||||||
|
} else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||||
|
const result = await processAndUploadVideo(file, findId, i);
|
||||||
|
uploadedMedia.push({ type: 'video', ...result });
|
||||||
|
} else {
|
||||||
|
throw error(400, `File type ${file.type} not allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ findId, media: uploadedMedia });
|
||||||
|
};
|
||||||
221
src/routes/api/friends/+server.ts
Normal file
221
src/routes/api/friends/+server.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { friendship, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, or } from 'drizzle-orm';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { getSignedR2Url } from '$lib/server/r2';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
|
function generateFriendshipId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
|
return encodeBase64url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = url.searchParams.get('status') || 'accepted';
|
||||||
|
const type = url.searchParams.get('type') || 'friends'; // 'friends', 'sent', 'received'
|
||||||
|
|
||||||
|
try {
|
||||||
|
let friendships;
|
||||||
|
|
||||||
|
if (type === 'friends') {
|
||||||
|
// Get accepted friendships where user is either sender or receiver
|
||||||
|
const friendshipsRaw = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
userId: friendship.userId,
|
||||||
|
friendId: friendship.friendId,
|
||||||
|
status: friendship.status,
|
||||||
|
createdAt: friendship.createdAt
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.status, status),
|
||||||
|
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get friend details for each friendship
|
||||||
|
friendships = await Promise.all(
|
||||||
|
friendshipsRaw.map(async (f) => {
|
||||||
|
const friendUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||||
|
const friendUser = await db
|
||||||
|
.select({
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, friendUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: f.id,
|
||||||
|
userId: f.userId,
|
||||||
|
friendId: f.friendId,
|
||||||
|
status: f.status,
|
||||||
|
createdAt: f.createdAt,
|
||||||
|
friendUsername: friendUser[0]?.username || '',
|
||||||
|
friendProfilePictureUrl: friendUser[0]?.profilePictureUrl || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (type === 'sent') {
|
||||||
|
// Get friend requests sent by current user
|
||||||
|
friendships = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
userId: friendship.userId,
|
||||||
|
friendId: friendship.friendId,
|
||||||
|
status: friendship.status,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
friendUsername: user.username,
|
||||||
|
friendProfilePictureUrl: user.profilePictureUrl
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.friendId, user.id))
|
||||||
|
.where(and(eq(friendship.userId, locals.user.id), eq(friendship.status, status)));
|
||||||
|
} else if (type === 'received') {
|
||||||
|
// Get friend requests received by current user
|
||||||
|
friendships = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
userId: friendship.userId,
|
||||||
|
friendId: friendship.friendId,
|
||||||
|
status: friendship.status,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
friendUsername: user.username,
|
||||||
|
friendProfilePictureUrl: user.profilePictureUrl
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.userId, user.id))
|
||||||
|
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signed URLs for profile pictures
|
||||||
|
const friendshipsWithSignedUrls = await Promise.all(
|
||||||
|
(friendships || []).map(async (friendship) => {
|
||||||
|
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||||
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate signed URL for profile picture:', error);
|
||||||
|
profilePictureUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...friendship,
|
||||||
|
friendProfilePictureUrl: profilePictureUrl
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(friendshipsWithSignedUrls);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading friendships:', err);
|
||||||
|
throw error(500, 'Failed to load friendships');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json();
|
||||||
|
const { friendId } = data;
|
||||||
|
|
||||||
|
if (!friendId) {
|
||||||
|
throw error(400, 'Friend ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (friendId === locals.user.id) {
|
||||||
|
throw error(400, 'Cannot send friend request to yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if friend exists
|
||||||
|
const friendExists = await db.select().from(user).where(eq(user.id, friendId)).limit(1);
|
||||||
|
if (friendExists.length === 0) {
|
||||||
|
throw error(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if friendship already exists
|
||||||
|
const existingFriendship = await db
|
||||||
|
.select()
|
||||||
|
.from(friendship)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(friendship.userId, locals.user.id), eq(friendship.friendId, friendId)),
|
||||||
|
and(eq(friendship.userId, friendId), eq(friendship.friendId, locals.user.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingFriendship.length > 0) {
|
||||||
|
const status = existingFriendship[0].status;
|
||||||
|
if (status === 'accepted') {
|
||||||
|
throw error(400, 'Already friends');
|
||||||
|
} else if (status === 'pending') {
|
||||||
|
throw error(400, 'Friend request already sent');
|
||||||
|
} else if (status === 'blocked') {
|
||||||
|
throw error(400, 'Cannot send friend request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new friend request
|
||||||
|
const newFriendship = await db
|
||||||
|
.insert(friendship)
|
||||||
|
.values({
|
||||||
|
id: generateFriendshipId(),
|
||||||
|
userId: locals.user.id,
|
||||||
|
friendId,
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Send notification to friend
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(friendId, 'friend_request');
|
||||||
|
if (shouldNotify) {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: friendId,
|
||||||
|
type: 'friend_request',
|
||||||
|
title: 'New friend request',
|
||||||
|
message: `${locals.user.username} sent you a friend request`,
|
||||||
|
data: {
|
||||||
|
friendshipId: newFriendship[0].id,
|
||||||
|
senderId: locals.user.id,
|
||||||
|
senderUsername: locals.user.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(friendId, {
|
||||||
|
title: 'New friend request',
|
||||||
|
message: `${locals.user.username} sent you a friend request`,
|
||||||
|
url: '/friends',
|
||||||
|
tag: 'friend_request',
|
||||||
|
data: {
|
||||||
|
friendshipId: newFriendship[0].id,
|
||||||
|
senderId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, friendship: newFriendship[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending friend request:', err);
|
||||||
|
if (err instanceof Error && 'status' in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw error(500, 'Failed to send friend request');
|
||||||
|
}
|
||||||
|
};
|
||||||
172
src/routes/api/friends/[friendshipId]/+server.ts
Normal file
172
src/routes/api/friends/[friendshipId]/+server.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { friendship, user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { notificationService } from '$lib/server/notifications';
|
||||||
|
import { pushService } from '$lib/server/push';
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { friendshipId } = params;
|
||||||
|
const data = await request.json();
|
||||||
|
const { action } = data; // 'accept', 'decline', 'block'
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
throw error(400, 'Friendship ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action || !['accept', 'decline', 'block'].includes(action)) {
|
||||||
|
throw error(400, 'Valid action is required (accept, decline, block)');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the friendship
|
||||||
|
const existingFriendship = await db
|
||||||
|
.select()
|
||||||
|
.from(friendship)
|
||||||
|
.where(eq(friendship.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingFriendship.length === 0) {
|
||||||
|
throw error(404, 'Friendship not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendshipRecord = existingFriendship[0];
|
||||||
|
|
||||||
|
// Check if user is authorized to modify this friendship
|
||||||
|
// User can only accept/decline requests sent TO them, or cancel requests they sent
|
||||||
|
const isReceiver = friendshipRecord.friendId === locals.user.id;
|
||||||
|
const isSender = friendshipRecord.userId === locals.user.id;
|
||||||
|
|
||||||
|
if (!isReceiver && !isSender) {
|
||||||
|
throw error(403, 'Not authorized to modify this friendship');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only receivers can accept or decline pending requests
|
||||||
|
if ((action === 'accept' || action === 'decline') && !isReceiver) {
|
||||||
|
throw error(403, 'Only the recipient can accept or decline friend requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept pending requests
|
||||||
|
if (action === 'accept' && friendshipRecord.status !== 'pending') {
|
||||||
|
throw error(400, 'Can only accept pending friend requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
let newStatus: string;
|
||||||
|
if (action === 'accept') {
|
||||||
|
newStatus = 'accepted';
|
||||||
|
} else if (action === 'decline') {
|
||||||
|
newStatus = 'declined';
|
||||||
|
} else if (action === 'block') {
|
||||||
|
newStatus = 'blocked';
|
||||||
|
} else {
|
||||||
|
throw error(400, 'Invalid action');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the friendship status
|
||||||
|
const updatedFriendship = await db
|
||||||
|
.update(friendship)
|
||||||
|
.set({ status: newStatus })
|
||||||
|
.where(eq(friendship.id, friendshipId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Send notification if friend request was accepted
|
||||||
|
if (action === 'accept') {
|
||||||
|
const senderId = friendshipRecord.userId;
|
||||||
|
const shouldNotify = await notificationService.shouldNotify(senderId, 'friend_accepted');
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
// Get accepter's username
|
||||||
|
const accepterUser = await db
|
||||||
|
.select({ username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, locals.user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const accepterUsername = accepterUser[0]?.username || 'Someone';
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
userId: senderId,
|
||||||
|
type: 'friend_accepted',
|
||||||
|
title: 'Friend request accepted',
|
||||||
|
message: `${accepterUsername} accepted your friend request`,
|
||||||
|
data: {
|
||||||
|
friendshipId: friendshipRecord.id,
|
||||||
|
accepterId: locals.user.id,
|
||||||
|
accepterUsername
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification
|
||||||
|
await pushService.sendPushNotification(senderId, {
|
||||||
|
title: 'Friend request accepted',
|
||||||
|
message: `${accepterUsername} accepted your friend request`,
|
||||||
|
url: '/friends',
|
||||||
|
tag: 'friend_accepted',
|
||||||
|
data: {
|
||||||
|
friendshipId: friendshipRecord.id,
|
||||||
|
accepterId: locals.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, friendship: updatedFriendship[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating friendship:', err);
|
||||||
|
if (err instanceof Error && 'status' in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw error(500, 'Failed to update friendship');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { friendshipId } = params;
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
throw error(400, 'Friendship ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the friendship
|
||||||
|
const existingFriendship = await db
|
||||||
|
.select()
|
||||||
|
.from(friendship)
|
||||||
|
.where(eq(friendship.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingFriendship.length === 0) {
|
||||||
|
throw error(404, 'Friendship not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendshipRecord = existingFriendship[0];
|
||||||
|
|
||||||
|
// Check if user is part of this friendship
|
||||||
|
if (
|
||||||
|
friendshipRecord.userId !== locals.user.id &&
|
||||||
|
friendshipRecord.friendId !== locals.user.id
|
||||||
|
) {
|
||||||
|
throw error(403, 'Not authorized to delete this friendship');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the friendship
|
||||||
|
await db.delete(friendship).where(eq(friendship.id, friendshipId));
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting friendship:', err);
|
||||||
|
if (err instanceof Error && 'status' in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw error(500, 'Failed to delete friendship');
|
||||||
|
}
|
||||||
|
};
|
||||||
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
83
src/routes/api/places/+server.ts
Normal file
83
src/routes/api/places/+server.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { createPlacesService } from '$lib/utils/places';
|
||||||
|
|
||||||
|
const getPlacesService = () => {
|
||||||
|
const apiKey = env.GOOGLE_MAPS_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set');
|
||||||
|
}
|
||||||
|
return createPlacesService(apiKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = url.searchParams.get('action');
|
||||||
|
const query = url.searchParams.get('query');
|
||||||
|
const placeId = url.searchParams.get('placeId');
|
||||||
|
const lat = url.searchParams.get('lat');
|
||||||
|
const lng = url.searchParams.get('lng');
|
||||||
|
const radius = url.searchParams.get('radius');
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const placesService = getPlacesService();
|
||||||
|
|
||||||
|
let location;
|
||||||
|
if (lat && lng) {
|
||||||
|
location = { lat: parseFloat(lat), lng: parseFloat(lng) };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'search': {
|
||||||
|
if (!query) {
|
||||||
|
throw error(400, 'Query parameter is required for search');
|
||||||
|
}
|
||||||
|
const searchResults = await placesService.searchPlaces(query, location);
|
||||||
|
return json(searchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'autocomplete': {
|
||||||
|
if (!query) {
|
||||||
|
throw error(400, 'Query parameter is required for autocomplete');
|
||||||
|
}
|
||||||
|
const suggestions = await placesService.getAutocompleteSuggestions(query, location);
|
||||||
|
return json(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'details': {
|
||||||
|
if (!placeId) {
|
||||||
|
throw error(400, 'PlaceId parameter is required for details');
|
||||||
|
}
|
||||||
|
const placeDetails = await placesService.getPlaceDetails(placeId);
|
||||||
|
return json(placeDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nearby': {
|
||||||
|
if (!location) {
|
||||||
|
throw error(400, 'Location parameters (lat, lng) are required for nearby search');
|
||||||
|
}
|
||||||
|
const radiusNum = radius ? parseInt(radius) : 5000;
|
||||||
|
const nearbyResults = await placesService.findNearbyPlaces(
|
||||||
|
location,
|
||||||
|
radiusNum,
|
||||||
|
type || undefined
|
||||||
|
);
|
||||||
|
return json(nearbyResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw error(400, 'Invalid action parameter');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Places API error:', err);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw error(500, err.message);
|
||||||
|
}
|
||||||
|
throw error(500, 'Failed to fetch places data');
|
||||||
|
}
|
||||||
|
};
|
||||||
51
src/routes/api/profile-picture/delete/+server.ts
Normal file
51
src/routes/api/profile-picture/delete/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { deleteFromR2 } from '$lib/server/r2';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const { userId } = await request.json();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return json({ error: 'userId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user to find profile picture URL
|
||||||
|
const currentUser = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||||
|
|
||||||
|
if (!currentUser.length) {
|
||||||
|
return json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRecord = currentUser[0];
|
||||||
|
|
||||||
|
if (!userRecord.profilePictureUrl) {
|
||||||
|
return json({ error: 'No profile picture to delete' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the base path from the stored URL (should be like users/123/profile-1234567890-abcdef.webp)
|
||||||
|
const basePath = userRecord.profilePictureUrl.replace('.webp', '');
|
||||||
|
|
||||||
|
// Delete all variants (WebP and JPEG, main and thumbnails)
|
||||||
|
const deletePromises = [
|
||||||
|
deleteFromR2(`${basePath}.webp`),
|
||||||
|
deleteFromR2(`${basePath}.jpg`),
|
||||||
|
deleteFromR2(`${basePath}-thumb.webp`),
|
||||||
|
deleteFromR2(`${basePath}-thumb.jpg`)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Execute all deletions, but don't fail if some files don't exist
|
||||||
|
await Promise.allSettled(deletePromises);
|
||||||
|
|
||||||
|
// Update user profile picture URL in database
|
||||||
|
await db.update(user).set({ profilePictureUrl: null }).where(eq(user.id, userId));
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile picture delete error:', error);
|
||||||
|
return json({ error: 'Failed to delete profile picture' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
45
src/routes/api/profile-picture/upload/+server.ts
Normal file
45
src/routes/api/profile-picture/upload/+server.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { processAndUploadProfilePicture } from '$lib/server/media-processor';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
const userId = formData.get('userId') as string;
|
||||||
|
|
||||||
|
if (!file || !userId) {
|
||||||
|
return json({ error: 'File and userId are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return json({ error: 'File must be an image' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB max)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
return json({ error: 'File size must be less than 5MB' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process and upload profile picture
|
||||||
|
const result = await processAndUploadProfilePicture(file, userId);
|
||||||
|
|
||||||
|
// Update user profile picture URL in database (store the WebP path)
|
||||||
|
await db.update(user).set({ profilePictureUrl: result.url }).where(eq(user.id, userId));
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
profilePictureUrl: result.url,
|
||||||
|
thumbnailUrl: result.thumbnailUrl,
|
||||||
|
fallbackUrl: result.fallbackUrl,
|
||||||
|
fallbackThumbnailUrl: result.fallbackThumbnailUrl
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile picture upload error:', error);
|
||||||
|
return json({ error: 'Failed to upload profile picture' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
94
src/routes/api/users/+server.ts
Normal file
94
src/routes/api/users/+server.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user, friendship } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
||||||
|
import { getSignedR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = url.searchParams.get('q');
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||||
|
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
throw error(400, 'Search query must be at least 2 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Search for users by username, excluding current user
|
||||||
|
const users = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profilePictureUrl: user.profilePictureUrl
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(and(ilike(user.username, `%${query.trim()}%`), ne(user.id, locals.user.id)))
|
||||||
|
.limit(Math.min(limit, 50));
|
||||||
|
|
||||||
|
// Get existing friendships to determine relationship status
|
||||||
|
const userIds = users.map((u) => u.id);
|
||||||
|
let existingFriendships: Array<{
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
// Use a simpler approach: get all friendships involving the current user
|
||||||
|
// and then filter for the relevant user IDs
|
||||||
|
const allUserFriendships = await db
|
||||||
|
.select({
|
||||||
|
userId: friendship.userId,
|
||||||
|
friendId: friendship.friendId,
|
||||||
|
status: friendship.status
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.where(or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id)));
|
||||||
|
|
||||||
|
// Filter to only include friendships with users in our search results
|
||||||
|
existingFriendships = allUserFriendships.filter((f) => {
|
||||||
|
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||||
|
return userIds.includes(otherUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of friendship statuses
|
||||||
|
const friendshipStatusMap = new Map<string, string>();
|
||||||
|
existingFriendships.forEach((f) => {
|
||||||
|
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||||
|
friendshipStatusMap.set(otherUserId, f.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate signed URLs and add friendship status
|
||||||
|
const usersWithSignedUrls = await Promise.all(
|
||||||
|
users.map(async (userItem) => {
|
||||||
|
let profilePictureUrl = userItem.profilePictureUrl;
|
||||||
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate signed URL for profile picture:', error);
|
||||||
|
profilePictureUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userItem,
|
||||||
|
profilePictureUrl,
|
||||||
|
friendshipStatus
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(usersWithSignedUrls);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching users:', err);
|
||||||
|
throw error(500, 'Failed to search users');
|
||||||
|
}
|
||||||
|
};
|
||||||
36
src/routes/friends/+page.server.ts
Normal file
36
src/routes/friends/+page.server.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch friends, sent requests, and received requests in parallel
|
||||||
|
const [friendsResponse, sentResponse, receivedResponse] = await Promise.all([
|
||||||
|
fetch('/api/friends?type=friends&status=accepted'),
|
||||||
|
fetch('/api/friends?type=sent&status=pending'),
|
||||||
|
fetch('/api/friends?type=received&status=pending')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!friendsResponse.ok || !sentResponse.ok || !receivedResponse.ok) {
|
||||||
|
throw error(500, 'Failed to load friends data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [friends, sentRequests, receivedRequests] = await Promise.all([
|
||||||
|
friendsResponse.json(),
|
||||||
|
sentResponse.json(),
|
||||||
|
receivedResponse.json()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
friends,
|
||||||
|
sentRequests,
|
||||||
|
receivedRequests
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading friends page:', err);
|
||||||
|
throw error(500, 'Failed to load friends');
|
||||||
|
}
|
||||||
|
};
|
||||||
421
src/routes/friends/+page.svelte
Normal file
421
src/routes/friends/+page.svelte
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import { Input } from '$lib/components/input';
|
||||||
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import { Badge } from '$lib/components/badge';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
type Friendship = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
friendUsername: string;
|
||||||
|
friendProfilePictureUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
profilePictureUrl: string | null;
|
||||||
|
friendshipStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<SearchUser[]>([]);
|
||||||
|
let isSearching = $state(false);
|
||||||
|
let activeTab = $state('friends');
|
||||||
|
|
||||||
|
let friends = $state<Friendship[]>(data.friends);
|
||||||
|
let sentRequests = $state<Friendship[]>(data.sentRequests);
|
||||||
|
let receivedRequests = $state<Friendship[]>(data.receivedRequests);
|
||||||
|
|
||||||
|
async function searchUsers() {
|
||||||
|
if (searchQuery.trim().length < 2) {
|
||||||
|
searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to search users');
|
||||||
|
}
|
||||||
|
searchResults = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching users:', error);
|
||||||
|
toast.error('Failed to search users');
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFriendRequest(friendId: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/friends', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ friendId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to send friend request');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Friend request sent!');
|
||||||
|
// Update the search results to reflect the new status
|
||||||
|
searchResults = searchResults.map((user) =>
|
||||||
|
user.id === friendId ? { ...user, friendshipStatus: 'pending' } : user
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending friend request:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to send friend request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondToFriendRequest(friendshipId: string, action: 'accept' | 'decline') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/friends/${friendshipId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to ${action} friend request`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Friend request ${action}ed!`);
|
||||||
|
|
||||||
|
// Remove from received requests
|
||||||
|
receivedRequests = receivedRequests.filter((req: Friendship) => req.id !== friendshipId);
|
||||||
|
|
||||||
|
// If accepted, refetch friends list
|
||||||
|
if (action === 'accept') {
|
||||||
|
const friendsResponse = await fetch('/api/friends?type=friends&status=accepted');
|
||||||
|
if (friendsResponse.ok) {
|
||||||
|
friends = await friendsResponse.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error ${action}ing friend request:`, error);
|
||||||
|
toast.error(`Failed to ${action} friend request`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFriend(friendshipId: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/friends/${friendshipId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to remove friend');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Friend removed');
|
||||||
|
friends = friends.filter((friend: Friendship) => friend.id !== friendshipId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing friend:', error);
|
||||||
|
toast.error('Failed to remove friend');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search users when query changes with debounce
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
$effect(() => {
|
||||||
|
if (searchQuery) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(searchUsers, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Friends - Serengo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="friends-page">
|
||||||
|
<div class="friends-container">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="tabs">
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'friends' ? 'default' : 'outline'}
|
||||||
|
onclick={() => (activeTab = 'friends')}
|
||||||
|
>
|
||||||
|
Friends ({friends.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'requests' ? 'default' : 'outline'}
|
||||||
|
onclick={() => (activeTab = 'requests')}
|
||||||
|
>
|
||||||
|
Requests ({receivedRequests.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'search' ? 'default' : 'outline'}
|
||||||
|
onclick={() => (activeTab = 'search')}
|
||||||
|
>
|
||||||
|
Find Friends
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Friends List -->
|
||||||
|
{#if activeTab === 'friends'}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Friends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if friends.length === 0}
|
||||||
|
<p class="empty-state">
|
||||||
|
No friends yet. Use the "Find Friends" tab to search for people!
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="user-grid">
|
||||||
|
{#each friends as friend (friend.id)}
|
||||||
|
<div class="user-card">
|
||||||
|
<ProfilePicture
|
||||||
|
username={friend.friendUsername}
|
||||||
|
profilePictureUrl={friend.friendProfilePictureUrl}
|
||||||
|
/>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="username">{friend.friendUsername}</span>
|
||||||
|
<Badge variant="secondary">Friend</Badge>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive" size="sm" onclick={() => removeFriend(friend.id)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Friend Requests -->
|
||||||
|
{#if activeTab === 'requests'}
|
||||||
|
<div class="requests-section">
|
||||||
|
<!-- Received Requests -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Friend Requests ({receivedRequests.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if receivedRequests.length === 0}
|
||||||
|
<p class="empty-state">No pending friend requests</p>
|
||||||
|
{:else}
|
||||||
|
<div class="user-grid">
|
||||||
|
{#each receivedRequests as request (request.id)}
|
||||||
|
<div class="user-card">
|
||||||
|
<ProfilePicture
|
||||||
|
username={request.friendUsername}
|
||||||
|
profilePictureUrl={request.friendProfilePictureUrl}
|
||||||
|
/>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="username">{request.friendUsername}</span>
|
||||||
|
<Badge variant="outline">Pending</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="request-actions">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => respondToFriendRequest(request.id, 'accept')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => respondToFriendRequest(request.id, 'decline')}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Sent Requests -->
|
||||||
|
{#if sentRequests.length > 0}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sent Requests ({sentRequests.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="user-grid">
|
||||||
|
{#each sentRequests as request (request.id)}
|
||||||
|
<div class="user-card">
|
||||||
|
<ProfilePicture
|
||||||
|
username={request.friendUsername}
|
||||||
|
profilePictureUrl={request.friendProfilePictureUrl}
|
||||||
|
/>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="username">{request.friendUsername}</span>
|
||||||
|
<Badge variant="secondary">Sent</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Search Users -->
|
||||||
|
{#if activeTab === 'search'}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Find Friends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="search-section">
|
||||||
|
<Input bind:value={searchQuery} placeholder="Search for users by username..." />
|
||||||
|
|
||||||
|
{#if isSearching}
|
||||||
|
<p class="loading">Searching...</p>
|
||||||
|
{:else if searchQuery.trim().length >= 2}
|
||||||
|
{#if searchResults.length === 0}
|
||||||
|
<p class="empty-state">No users found matching "{searchQuery}"</p>
|
||||||
|
{:else}
|
||||||
|
<div class="user-grid">
|
||||||
|
{#each searchResults as user (user.id)}
|
||||||
|
<div class="user-card">
|
||||||
|
<ProfilePicture
|
||||||
|
username={user.username}
|
||||||
|
profilePictureUrl={user.profilePictureUrl}
|
||||||
|
/>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="username">{user.username}</span>
|
||||||
|
{#if user.friendshipStatus === 'accepted'}
|
||||||
|
<Badge variant="secondary">Friend</Badge>
|
||||||
|
{:else if user.friendshipStatus === 'pending'}
|
||||||
|
<Badge variant="outline">Request Sent</Badge>
|
||||||
|
{:else if user.friendshipStatus === 'blocked'}
|
||||||
|
<Badge variant="destructive">Blocked</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if user.friendshipStatus === 'none'}
|
||||||
|
<Button onclick={() => sendFriendRequest(user.id)} size="sm">
|
||||||
|
Add Friend
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if searchQuery.trim().length > 0}
|
||||||
|
<p class="hint">Please enter at least 2 characters to search</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.friends-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friends-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.loading,
|
||||||
|
.hint {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.friends-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,6 +19,7 @@ const self = globalThis.self as unknown as ServiceWorkerGlobalScope;
|
|||||||
const CACHE = `cache-${version}`;
|
const CACHE = `cache-${version}`;
|
||||||
const RUNTIME_CACHE = `runtime-${version}`;
|
const RUNTIME_CACHE = `runtime-${version}`;
|
||||||
const IMAGE_CACHE = `images-${version}`;
|
const IMAGE_CACHE = `images-${version}`;
|
||||||
|
const R2_CACHE = `r2-${version}`;
|
||||||
|
|
||||||
const ASSETS = [
|
const ASSETS = [
|
||||||
...build, // the app itself
|
...build, // the app itself
|
||||||
@@ -69,7 +70,7 @@ self.addEventListener('install', (event) => {
|
|||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
// Remove previous cached data from disk
|
// Remove previous cached data from disk
|
||||||
async function deleteOldCaches() {
|
async function deleteOldCaches() {
|
||||||
const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE];
|
const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE, R2_CACHE];
|
||||||
for (const key of await caches.keys()) {
|
for (const key of await caches.keys()) {
|
||||||
if (!currentCaches.includes(key)) {
|
if (!currentCaches.includes(key)) {
|
||||||
await caches.delete(key);
|
await caches.delete(key);
|
||||||
@@ -91,6 +92,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||||
const imageCache = await caches.open(IMAGE_CACHE);
|
const imageCache = await caches.open(IMAGE_CACHE);
|
||||||
|
const r2Cache = await caches.open(R2_CACHE);
|
||||||
|
|
||||||
// `build`/`files` can always be served from the cache
|
// `build`/`files` can always be served from the cache
|
||||||
if (ASSETS.includes(url.pathname)) {
|
if (ASSETS.includes(url.pathname)) {
|
||||||
@@ -128,6 +130,45 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle R2 resources with cache-first strategy
|
||||||
|
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
||||||
|
const cachedResponse = await r2Cache.match(event.request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
if (response.ok) {
|
||||||
|
// Cache R2 resources for a long time as they're typically immutable
|
||||||
|
r2Cache.put(event.request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
// Return cached version if available, or fall through to other cache checks
|
||||||
|
return cachedResponse || new Response('R2 resource not available', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OpenStreetMap tiles with cache-first strategy
|
||||||
|
if (url.hostname === 'tile.openstreetmap.org') {
|
||||||
|
const cachedResponse = await r2Cache.match(event.request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
if (response.ok) {
|
||||||
|
// Cache map tiles for a long time
|
||||||
|
r2Cache.put(event.request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return cachedResponse || new Response('Map tile not available', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle API and dynamic content with network-first strategy
|
// Handle API and dynamic content with network-first strategy
|
||||||
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
|
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +207,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const cachedResponse =
|
const cachedResponse =
|
||||||
(await cache.match(event.request)) ||
|
(await cache.match(event.request)) ||
|
||||||
(await runtimeCache.match(event.request)) ||
|
(await runtimeCache.match(event.request)) ||
|
||||||
(await imageCache.match(event.request));
|
(await imageCache.match(event.request)) ||
|
||||||
|
(await r2Cache.match(event.request));
|
||||||
|
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
@@ -180,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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"short_name": "Serengo",
|
"short_name": "Serengo",
|
||||||
"name": "Serengo - meet the unexpected.",
|
"name": "Serengo",
|
||||||
"description": "Discover unexpected places and experiences with Serengo's interactive map. Find hidden gems and explore your surroundings like never before.",
|
"description": "Serengo - meet the unexpected.",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/logo.svg",
|
"src": "/logo.svg",
|
||||||
|
|||||||
6
static/video-placeholder.svg
Normal file
6
static/video-placeholder.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="400" height="300" viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="400" height="300" fill="#1F2937"/>
|
||||||
|
<circle cx="200" cy="150" r="30" fill="#374151"/>
|
||||||
|
<path d="M190 135L215 150L190 165V135Z" fill="#9CA3AF"/>
|
||||||
|
<text x="200" y="200" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="14">Video</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
Reference in New Issue
Block a user