29 Commits

Author SHA1 Message Date
1c31e2cdda add:sidebar toggle and fix overscroll behavior 2025-11-17 11:57:09 +01:00
d8cab06e90 fix:overflow of findlist 2025-11-17 11:53:09 +01:00
d4d23ed46d ui:find preview better ui 2025-11-17 11:12:26 +01:00
ab8b0ee982 ui:create find better ui 2025-11-17 11:09:21 +01:00
dabc732f4b fix:styling for mobile createfind 2025-11-17 10:57:47 +01:00
1f0e8141be ui:remove mobile + button and use same as desktop 2025-11-17 10:53:30 +01:00
96a173b73b feat:use local proxy for media
use local proxy for media so that media doesnt need to be requested from
r2 everytime but can be cached locally. this also fixes some csp issues
ive been having.
2025-11-17 10:48:40 +01:00
08f7e77a86 ui:big ui update
refreshed the ui by making the map full screen and adding the finds as a
side-sheet on top of the map.
2025-11-17 10:46:17 +01:00
ae339d68e1 chore:linting,formatting,type fixing, .... 2025-11-08 14:39:33 +01:00
0754d62d0e fix:push notification UI, settings and API
Introduce NotificationManager, NotificationPrompt, NotificationSettings,
NotificationSettingsSheet and integrate into the profile panel. Add
server GET/POST endpoints for notification preferences. Add
lucide-svelte dependency and update CSP connect-src to allow
fcm.googleapis.com and android.googleapis.com
2025-11-08 14:27:21 +01:00
e27b2498b7 fix:notifications 2025-11-08 13:45:11 +01:00
4d288347ab fix:notificationmanager 2025-11-08 13:41:24 +01:00
d7f803c782 fix:add NotificationManager and enable in layout 2025-11-08 13:12:16 +01:00
df675640c2 feat:add Web Push notification system 2025-11-08 12:07:48 +01:00
2efd4969e7 add:enhance comments list with scroll, limit, and styling
- Add maxComments, showCommentForm and isScrollable props to
CommentsList
to limit displayed comments, toggle the form, and enable a scrollable
comments container. Show a "+N more comments" indicator when limited.
- Adjust CommentForm and list styles (padding, background, flex
behavior, responsive heights). Pass currentUserId and new props from
FindPreview and FindCard where applicable.
2025-11-06 12:47:35 +01:00
b8c88d7a58 feat:comments
added comments feature.
- this makes sure the user has the ability to comment on a certain find.
it includes functionality to the api-sync layer so that multiple
components can stay in sync with the comments state.
- it also added the needed db migrations to the finds
- it adds some ui components needed to create, list and view comments
2025-11-06 12:38:32 +01:00
af49ed6237 logs:update logs 2025-11-04 12:25:21 +01:00
d3adac8acc add:ProfilePicture component and replace Avatar
Move avatar initials, fallback styling, and loading/loading-state UI
into ProfilePicture and update components to use it. Export
ProfilePicture from the lib index.
2025-11-04 12:23:08 +01:00
9800be0147 fix:Adjust media layout, sizing, and mobile sheet
Render media container only when media exists and remove the no-media
placeholder. Constrain images and videos with a max-height of 600px
(height:auto) and set images to display:block. In preview, use
object-fit: contain for media images and reduce mobile sheet height from
80vh to 50vh.
2025-11-04 12:09:05 +01:00
4c973c4e7d fix:some csp issues 2025-10-29 00:23:26 +01:00
d7fe9091ce fix:new child subscription
this fix makes sure that when the client initializes a new child that
needs to subscribe for updates, it first checks whether this item
already has a currrent state and uses this, so that a new child always
starts in sync with the global state.
2025-10-29 00:14:26 +01:00
6620cc6078 feat:implement a sync-service
this new feature make sure that serverside actions are all in sync with
client. when a client is multiple times subscribed to the same server
side action it needs to be synced on all children. this implementation
gives access to one client synced status that will handle all api
actions in a queue.
2025-10-29 00:09:01 +01:00
3b3ebc2873 fix:continuously watch location 2025-10-27 15:07:24 +01:00
fef7c160e2 feat:use GMaps places api for searching poi's 2025-10-27 14:57:54 +01:00
43afa6dacc update:logboek 2025-10-21 14:49:55 +02:00
aa9ed77499 UI:Refactor create find modal UI and update finds list/header layout
- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
2025-10-21 14:44:25 +02:00
e1c5846fa4 fix:friends filtering 2025-10-21 14:11:28 +02:00
634ce8adf8 fix:logic of friends and users search 2025-10-20 11:35:44 +02:00
f547ee5a84 add:logs 2025-10-16 19:01:43 +02:00
65 changed files with 6839 additions and 1787 deletions

View File

@@ -35,3 +35,11 @@ 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"

View 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;

View 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;

View 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": {}
}
}

View 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": {}
}
}

View File

@@ -43,6 +43,20 @@
"when": 1760631798851,
"tag": "0005_rapid_warpath",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1762428302491,
"tag": "0006_strange_firebird",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1762522687342,
"tag": "0007_grey_dark_beast",
"breakpoints": true
}
]
}

View File

@@ -25,7 +25,17 @@ export default defineConfig(
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
'no-undef': 'off',
// Disable no-navigation-without-resolve as we're using resolveRoute from $app/paths
'svelte/no-navigation-without-resolve': 'off',
// Allow unused vars that start with underscore
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
]
}
},
{

641
finds.md
View File

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

View File

@@ -1,186 +0,0 @@
# Serengo Finds Feature Implementation Log
## Project Overview
Serengo is a location-based social discovery platform where users can save, share, and discover memorable places with media, reviews, and precise location data.
## Current Status: Phase 2A & 2C Complete + UI Integration ✅
### What Serengo Currently Has:
- Complete finds creation with photo uploads and location data
- Interactive map with find markers and detailed previews
- Responsive design with map/list view toggle
- **NEW**: Modern WebP image processing with JPEG fallbacks
- **NEW**: Full video support with custom VideoPlayer component
- **NEW**: Like/unlike system with optimistic UI updates
- R2 storage integration with enhanced media processing
- Full database schema with proper relationships and social features
- Type-safe API endpoints and error handling
- Mobile-optimized UI with floating action button
### Production Ready Features:
- Create/view finds with photos, descriptions, and categories
- Location-based filtering and discovery
- Media carousel with navigation (supports images and videos)
- **NEW**: Video playback with custom controls and fullscreen support
- **NEW**: Like/unlike finds with real-time count updates
- **NEW**: Animated like buttons with heart animations
- Share functionality with clipboard copy
- Real-time map markers with click-to-preview
- Grid layout for find browsing
---
## Phase 2 Implementation Plan (Updated October 14, 2025)
### Technical Requirements & Standards:
- **Media Formats**: Use modern WebP for images, WebM/AV1 for videos
- **UI Components**: Leverage existing SHADCN components for consistency
- **Code Quality**: Follow Svelte 5 best practices with clean, reusable components
- **POI Search**: Integrate Google Maps Places API for location search
- **Type Safety**: Maintain strict TypeScript throughout
### Phase 2A: Modern Media Support ✅ COMPLETE
**Goal**: Upgrade to modern file formats and video support
**Completed Tasks:**
- [x] Update media processor to output WebP images (with JPEG fallback)
- [x] Implement MP4 video processing with thumbnail generation
- [x] Create reusable VideoPlayer component using SHADCN
- [x] Enhanced database schema with fallback URL support
- [x] Optimize compression settings for web delivery
**Implementation Details:**
- Updated `media-processor.ts` to generate both WebP and JPEG versions
- Enhanced `findMedia` table with `fallbackUrl` and `fallbackThumbnailUrl` fields
- Created `VideoPlayer.svelte` with custom controls, progress bar, and fullscreen support
- Added video placeholder SVG for consistent UI
- Maintained backward compatibility with existing media
**Actual Effort**: ~12 hours
### Phase 2B: Enhanced Location & POI Search (Priority: High)
**Goal**: Google Maps integration for better location discovery
**Tasks:**
- [ ] Integrate Google Maps Places API for POI search
- [ ] Create location search component with autocomplete
- [ ] Add "Search nearby" functionality in CreateFindModal
- [ ] Implement reverse geocoding for address display
- [ ] Add place details (hours, ratings, etc.) from Google Places
**Estimated Effort**: 12-15 hours
### Phase 2C: Social Interactions ✅ COMPLETE
**Goal**: Like system and user engagement
**Completed Tasks:**
- [x] Implement like/unlike API using existing findLike table
- [x] Create reusable LikeButton component with animations
- [x] Add like counts and user's liked status to find queries
- [x] Add optimistic UI updates for instant feedback
- [x] **COMPLETED**: Full UI integration into FindCard and FindPreview components
- [x] **COMPLETED**: Updated all data interfaces to support like information
- [x] **COMPLETED**: Enhanced media carousel with VideoPlayer component integration
**Implementation Details:**
- Created `/api/finds/[findId]/like` endpoints for POST (like) and DELETE (unlike)
- Built `LikeButton.svelte` with optimistic UI updates and heart animations
- Enhanced find queries to include like counts and user's liked status via SQL aggregation
- Integrated LikeButton into both FindCard (list view) and FindPreview (modal view)
- Updated VideoPlayer usage throughout the application for consistent video playback
- Maintained type safety across all interfaces and data flows
**Future Task:**
- [ ] Build "My Liked Finds" collection page (moved to Phase 2G)
**Actual Effort**: ~15 hours (including UI integration)
### Phase 2D: Friends & Privacy System (Priority: Medium-High)
**Goal**: Social connections and privacy controls
**Tasks:**
- [ ] Build friend request system (send/accept/decline)
- [ ] Create Friends management page using SHADCN components
- [ ] Implement friend search with user suggestions
- [ ] Update find privacy logic to respect friendships
- [ ] Add friend-specific find visibility filters
**Estimated Effort**: 20-25 hours
### Phase 2E: Advanced Filtering & Discovery (Priority: Medium)
**Goal**: Better find discovery and organization
**Tasks:**
- [ ] Create FilterPanel component with category/distance/date filters
- [ ] Implement text search through find titles/descriptions
- [ ] Add sort options (recent, popular, nearest)
- [ ] Build infinite scroll for find feeds
- [ ] Add "Similar finds nearby" recommendations
**Estimated Effort**: 15-18 hours
### Phase 2F: Enhanced Sharing & Individual Pages (Priority: Medium)
**Goal**: Better sharing and find discoverability
**Tasks:**
- [ ] Create individual find detail pages (`/finds/[id]`)
- [ ] Add social media sharing with OpenGraph meta tags
- [ ] Implement "Get Directions" integration with map apps
- [ ] Build shareable find links with previews
- [ ] Add "Copy link" functionality
**Estimated Effort**: 12-15 hours
---
## Development Standards
### Component Architecture:
- Use composition pattern with reusable SHADCN components
- Implement proper TypeScript interfaces for all props
- Follow Svelte 5 runes pattern ($props, $derived, $effect)
- Create clean separation between UI and business logic
### Code Quality:
- Maintain existing formatting (tabs, single quotes, 100 char width)
- Use descriptive variable names and function signatures
- Implement proper error boundaries and loading states
- Add accessibility attributes (ARIA labels, keyboard navigation)
### Performance:
- Lazy load media content and heavy components
- Implement proper caching strategies for API calls
- Use virtual scrolling for long lists when needed
- Optimize images/videos for web delivery
**Total Phase 2 Estimated Effort**: 82-105 hours
**Expected Timeline**: 8-10 weeks (part-time development)
## Next Steps:
1. Begin with Phase 2A (Modern Media Support) for immediate impact
2. Implement Phase 2B (Google Maps POI) for better UX
3. Add Phase 2C (Social Interactions) for user engagement
4. Continue with remaining phases based on user feedback

View File

@@ -2,6 +2,147 @@
## Oktober 2025
### 4 November 2025 - 1 uren
**Werk uitgevoerd:**
- **UI Consistency & Media Layout Improvements**
- ProfilePicture component geïmplementeerd ter vervanging van Avatar componenten
- Media layout en sizing verbeteringen voor betere responsive design
- Mobile sheet optimalisaties voor verbeterde gebruikerservaring
- Component refactoring voor consistentere UI across applicatie
- Loading states en fallback styling geconsolideerd
**Commits:**
- d3adac8 - add:ProfilePicture component and replace Avatar
- 9800be0 - fix:Adjust media layout, sizing, and mobile sheet
**Details:**
- Nieuwe ProfilePicture component met geïntegreerde avatar initials en fallback styling
- Avatar componenten vervangen door ProfilePicture voor consistentie
- Media container rendering geoptimaliseerd (alleen tonen bij aanwezige media)
- Max-height van 600px toegevoegd voor images en videos met height:auto
- Object-fit: contain toegepast voor media images in preview
- Mobile sheet height gereduceerd van 80vh naar 50vh voor betere usability
- Loading states UI geconsolideerd in ProfilePicture component
- Component library verder uitgebreid met herbruikbare UI elementen
---
### 27-29 Oktober 2025 - 10 uren
**Werk uitgevoerd:**
- **Phase 3: Google Places Integration & Sync Service**
- Complete implementatie van sync-service voor API data synchronisatie
- Google Maps Places API integratie voor POI zoekfunctionaliteit
- Location tracking optimalisaties met continuous watching
- CSP (Content Security Policy) fixes voor verbeterde security
- Child subscription fixes voor data consistency
**Commits:**
- 4c973c4 - fix:some csp issues
- d7fe909 - fix:new child subscription
- 6620cc6 - feat:implement a sync-service
- 3b3ebc2 - fix:continuously watch location
- fef7c16 - feat:use GMaps places api for searching poi's
**Details:**
- Complete sync-service architectuur voor real-time data synchronisatie
- Google Places API integratie voor Point of Interest zoekfunctionaliteit
- Verbeterde location tracking met continuous watching voor nauwkeurigere positiebepaling
- Security verbeteringen door CSP issues op te lossen
- Data consistency verbeteringen met child subscription fixes
- Enhanced POI search capabilities met Google Maps integration
---
### 21 Oktober 2025 (Maandag) - 4 uren
**Werk uitgevoerd:**
- **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:**
@@ -17,7 +158,9 @@
- CSP headers bijgewerkt voor video support
- Volledige UI integratie in FindCard en FindPreview componenten
**Commits:** Nog niet gecommit (staged changes)
**Commits:**
- 067e228 - feat:video player, like button, and media fallbacks
**Details:**
@@ -238,9 +381,9 @@
## Totaal Overzicht
**Totale geschatte uren:** 55 uren
**Werkdagen:** 9 dagen
**Gemiddelde uren per dag:** 6.1 uur
**Totale geschatte uren:** 80 uren
**Werkdagen:** 14 dagen
**Gemiddelde uren per dag:** 5.8 uur
### Project Milestones:
@@ -253,6 +396,11 @@
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:
@@ -275,3 +423,11 @@
- [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

View File

@@ -59,9 +59,12 @@
"@node-rs/argon2": "^2.0.2",
"@sveltejs/adapter-vercel": "^5.10.2",
"arctic": "^3.7.0",
"lucide-svelte": "^0.553.0",
"nanoid": "^5.1.6",
"postgres": "^3.4.5",
"sharp": "^0.34.4",
"svelte-maplibre": "^1.2.1"
"svelte-maplibre": "^1.2.1",
"web-push": "^3.6.7"
},
"pnpm": {
"onlyBuiltDependencies": [

20
scripts/generate-vapid-keys.js Executable file
View 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!');

View File

@@ -88,6 +88,7 @@
box-sizing: border-box;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
body {

View File

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

View 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>

View 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>

View 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>

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { 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;
@@ -12,7 +14,6 @@
let { isOpen, onClose, onFindCreated }: Props = $props();
// Form state
let title = $state('');
let description = $state('');
let latitude = $state('');
@@ -23,8 +24,8 @@
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
// Categories
const categories = [
{ value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' },
@@ -35,7 +36,21 @@
{ value: 'other', label: 'Other' }
];
// Auto-fill location when modal opens
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 (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
@@ -43,13 +58,11 @@
}
});
// Handle file selection
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
// Upload media files
async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return;
@@ -71,7 +84,6 @@
uploadedMedia = result.media;
}
// Submit form
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
@@ -83,12 +95,10 @@
isSubmitting = true;
try {
// Upload media first if any
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
// Create the find
const response = await fetch('/api/finds', {
method: 'POST',
headers: {
@@ -110,12 +120,9 @@
throw new Error('Failed to create find');
}
// Reset form and close modal
resetForm();
onClose();
// Notify parent about new find creation
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
onClose();
} catch (error) {
console.error('Error creating find:', error);
alert('Failed to create find. Please try again.');
@@ -124,16 +131,32 @@
}
}
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;
// Reset file input
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
@@ -147,286 +170,547 @@
</script>
{#if isOpen}
<Modal bind:showModal={isOpen} positioning="center">
{#snippet header()}
<h2>Create Find</h2>
{/snippet}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Find</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="form-container">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="form-group">
<label for="title">Title *</label>
<Input name="title" placeholder="What did you find?" required bind:value={title} />
<span class="char-count">{title.length}/100</span>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
<div class="modal-body">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="form-group">
<label for="longitude">Longitude *</label>
<Input name="longitude" type="text" required bind:value={longitude} />
<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>
<div class="form-group">
<label for="category">Category</label>
<select name="category" bind:value={category}>
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
<div class="form-group">
<label for="media-files">Photos/Videos (max 5)</label>
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
multiple
max="5"
onchange={handleFileChange}
/>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-preview">
{#each Array.from(selectedFiles) as file (file.name)}
<span class="file-name">{file.name}</span>
{/each}
{#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="form-group">
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</button>
</Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</div>
</Modal>
</div>
{/if}
<style>
.form-container {
padding: 24px;
max-height: 70vh;
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.form-group {
margin-bottom: 20px;
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-row {
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
font-size: 0.9rem;
font-family: 'Washington', serif;
gap: 1rem;
}
textarea {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
resize: vertical;
min-height: 100px;
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;
line-height: 1.5;
}
textarea:focus {
background-color: #d5d5d5;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
textarea::placeholder {
color: #888;
color: hsl(var(--muted-foreground));
}
select {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
cursor: pointer;
}
select:focus {
background-color: #d5d5d5;
}
input[type='file'] {
width: 100%;
padding: 12px;
border: 2px dashed #ccc;
.select {
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background-color: #f9f9f9;
cursor: pointer;
background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
}
input[type='file']:hover {
border-color: #999;
}
.char-count {
font-size: 0.8rem;
color: #666;
text-align: right;
display: block;
margin-top: 4px;
}
.file-preview {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-name {
background-color: #f0f0f0;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #666;
.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-weight: normal;
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) / 0.5);
}
.privacy-toggle input[type='checkbox'] {
margin-right: 8px;
width: 18px;
height: 18px;
accent-color: hsl(var(--primary));
cursor: pointer;
}
.privacy-help {
font-size: 0.8rem;
color: #666;
margin-top: 4px;
margin-left: 26px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
}
.button {
flex: 1;
padding: 1.25rem 2rem;
border: none;
border-radius: 2rem;
font-size: 1rem;
font-weight: 500;
.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;
height: 3.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
.file-upload:hover {
border-color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
}
.button:disabled:hover {
transform: none;
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.button.secondary {
background-color: #6c757d;
color: white;
.file-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
}
.button.secondary:hover:not(:disabled) {
background-color: #5a6268;
transform: translateY(-1px);
.file-content span {
font-size: 0.875rem;
font-weight: 500;
}
@media (max-width: 640px) {
.form-container {
padding: 16px;
.file-selected {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
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;
color: hsl(var(--foreground));
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-footer :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
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;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.form-row {
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.file-content {
padding: 1.5rem 1rem;
}
.field-group {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>

View File

@@ -3,8 +3,9 @@
import { Badge } from '$lib/components/badge';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import CommentsList from '$lib/components/CommentsList.svelte';
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
interface FindCardProps {
id: string;
@@ -23,6 +24,8 @@
}>;
likeCount?: number;
isLiked?: boolean;
commentCount?: number;
currentUserId?: string;
onExplore?: (id: string) => void;
}
@@ -36,15 +39,19 @@
media,
likeCount = 0,
isLiked = false,
commentCount = 0,
currentUserId,
onExplore
}: FindCardProps = $props();
let showComments = $state(false);
function handleExplore() {
onExplore?.(id);
}
function getUserInitials(username: string): string {
return username.slice(0, 2).toUpperCase();
function toggleComments() {
showComments = !showComments;
}
</script>
@@ -52,14 +59,11 @@
<!-- Post Header -->
<div class="post-header">
<div class="user-info">
<Avatar class="avatar">
{#if user.profilePictureUrl}
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
{/if}
<AvatarFallback class="avatar-fallback">
{getUserInitials(user.username)}
</AvatarFallback>
</Avatar>
<ProfilePicture
username={user.username}
profilePictureUrl={user.profilePictureUrl}
class="avatar"
/>
<div class="user-details">
<div class="username">@{user.username}</div>
{#if locationName}
@@ -80,7 +84,7 @@
</div>
</div>
<Button variant="ghost" size="sm" class="more-button">
<MoreHorizontal size={16} />
<Ellipsis size={16} />
</Button>
</div>
@@ -101,8 +105,8 @@
</div>
<!-- Media -->
<div class="post-media">
{#if media && media.length > 0}
{#if media && media.length > 0}
<div class="post-media">
{#if media[0].type === 'photo'}
<img
src={media[0].thumbnailUrl || media[0].url}
@@ -120,29 +124,16 @@
class="media-video"
/>
{/if}
{:else}
<div class="no-media">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</div>
{/if}
</div>
</div>
{/if}
<!-- Post Actions -->
<div class="post-actions">
<div class="action-buttons">
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
<Button variant="ghost" size="sm" class="action-button">
<Button variant="ghost" size="sm" class="action-button" onclick={toggleComments}>
<MessageCircle size={16} />
<span>comment</span>
<span>{commentCount || 'comment'}</span>
</Button>
<Button variant="ghost" size="sm" class="action-button">
<Share size={16} />
@@ -153,20 +144,25 @@
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;
backdrop-filter: blur(10px);
margin-bottom: 1rem;
transition: box-shadow 0.2s ease;
}
.find-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Post Header */
@@ -258,33 +254,23 @@
/* Media */
.post-media {
width: 100%;
aspect-ratio: 16/10;
max-height: 600px;
overflow: hidden;
background: hsl(var(--muted));
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
width: 100%;
height: 100%;
height: auto;
max-height: 600px;
object-fit: cover;
display: block;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.no-media {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0.5;
height: auto;
max-height: 600px;
}
/* Post Actions */
@@ -300,12 +286,16 @@
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
min-width: 0;
}
:global(.action-button) {
gap: 0.375rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
flex-shrink: 1;
min-width: 0;
}
:global(.action-button:hover) {
@@ -317,6 +307,13 @@
font-size: 0.875rem;
}
/* Comments Section */
.comments-section {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
/* Mobile responsive */
@media (max-width: 768px) {
.post-actions {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import CommentsList from '$lib/components/CommentsList.svelte';
interface Find {
id: string;
@@ -30,11 +30,11 @@
interface Props {
find: Find | null;
onClose: () => void;
currentUserId?: string;
}
let { find, onClose }: Props = $props();
let { find, onClose, currentUserId }: Props = $props();
let showModal = $state(true);
let currentMediaIndex = $state(0);
let isMobile = $state(false);
@@ -52,13 +52,6 @@
return () => window.removeEventListener('resize', checkIsMobile);
});
// Close modal when showModal changes to false
$effect(() => {
if (!showModal) {
onClose();
}
});
function nextMedia() {
if (!find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
@@ -108,20 +101,17 @@
</script>
{#if find}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
<SheetHeader class="sheet-header">
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="user-section">
<Avatar class="user-avatar">
{#if find.user.profilePictureUrl}
<AvatarImage src={find.user.profilePictureUrl} alt={find.user.username} />
{/if}
<AvatarFallback class="avatar-fallback">
{find.user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<ProfilePicture
username={find.user.username}
profilePictureUrl={find.user.profilePictureUrl}
class="user-avatar"
/>
<div class="user-info">
<SheetTitle class="find-title">{find.title}</SheetTitle>
<h2 class="find-title">{find.title}</h2>
<div class="find-meta">
<span class="username">@{find.user.username}</span>
<span class="separator"></span>
@@ -133,9 +123,20 @@
</div>
</div>
</div>
</SheetHeader>
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="sheet-body">
<div class="modal-body">
{#if find.media && find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
@@ -254,38 +255,88 @@
</button>
</div>
</div>
<div class="comments-section">
<CommentsList
findId={find.id}
{currentUserId}
isScrollable={true}
showCommentForm={true}
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
{/if}
<style>
/* Base styles for sheet content */
:global(.sheet-content) {
padding: 0 !important;
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
/* Desktop styles (side sheet) */
@media (min-width: 768px) {
:global(.sheet-content) {
width: 80vw !important;
max-width: 600px !important;
height: 100vh !important;
border-radius: 0 !important;
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Mobile styles (bottom sheet) */
@media (max-width: 767px) {
:global(.sheet-content) {
height: 80vh !important;
border-radius: 16px 16px 0 0 !important;
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:global(.sheet-header) {
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
@@ -293,7 +344,8 @@
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
flex: 1;
min-width: 0;
}
:global(.user-avatar) {
@@ -314,13 +366,13 @@
min-width: 0;
}
:global(.find-title) {
font-family: 'Washington', serif !important;
font-size: 1.25rem !important;
font-weight: 600 !important;
margin: 0 !important;
color: hsl(var(--foreground)) !important;
line-height: 1.3 !important;
.find-title {
font-family: 'Washington', serif;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.find-meta {
@@ -351,7 +403,27 @@
text-transform: capitalize;
}
.sheet-body {
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 1;
overflow: hidden;
padding: 0;
@@ -379,8 +451,8 @@
/* Desktop media viewer - maximize available space */
@media (min-width: 768px) {
.sheet-body {
height: calc(100vh - 140px);
.modal-body {
height: calc(100vh - 180px);
}
.media-container {
@@ -396,8 +468,8 @@
/* Mobile media viewer - maximize available space */
@media (max-width: 767px) {
.sheet-body {
height: calc(80vh - 140px);
.modal-body {
height: calc(90vh - 180px);
}
.media-container {
@@ -414,7 +486,7 @@
.media-image {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
:global(.media-video) {
@@ -482,6 +554,7 @@
overflow-y: auto;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
}
.description {
@@ -545,9 +618,34 @@
background: hsl(var(--secondary) / 0.8);
}
.comments-section {
flex: 1;
min-height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
}
/* Desktop comments section */
@media (min-width: 768px) {
.comments-section {
height: calc(100vh - 500px);
min-height: 200px;
}
}
/* Mobile comments section */
@media (max-width: 767px) {
.comments-section {
height: calc(90vh - 450px);
min-height: 150px;
}
}
/* Mobile specific adjustments */
@media (max-width: 640px) {
:global(.sheet-header) {
.modal-header {
padding: 1rem;
}
@@ -560,8 +658,8 @@
height: 40px;
}
:global(.find-title) {
font-size: 1.125rem !important;
.find-title {
font-size: 1.125rem;
}
.content-section {
@@ -576,5 +674,9 @@
.action-button {
width: 100%;
}
.comments-section {
height: calc(90vh - 480px);
}
}
</style>

View File

@@ -26,6 +26,7 @@
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
@@ -33,7 +34,8 @@
onFindExplore,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display'
emptyMessage = 'No finds to display',
hideTitle = false
}: FindsListProps = $props();
function handleFindExplore(id: string) {
@@ -42,9 +44,11 @@
</script>
<section class="finds-feed">
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if finds.length > 0}
<div class="feed-container">
@@ -98,6 +102,7 @@
<style>
.finds-feed {
width: 100%;
padding: 0 24px 24px 24px;
}
.feed-header {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { ProfilePanel } from '$lib';
import { resolveRoute } from '$app/paths';
type User = {
id: string;
@@ -12,7 +13,7 @@
<header class="app-header">
<div class="header-content">
<h1 class="app-title"><a href="/">Serengo</a></h1>
<h1 class="app-title"><a href={resolveRoute('/')}>Serengo</a></h1>
<div class="profile-container">
<ProfilePanel
username={user.username}
@@ -27,8 +28,7 @@
.app-header {
width: 100%;
position: relative;
z-index: 10;
backdrop-filter: blur(10px);
z-index: 100;
}
.header-content {
@@ -37,7 +37,6 @@
justify-content: space-between;
padding: 16px 20px;
margin: 0 auto;
max-width: 1200px;
}
.profile-container {

View File

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

View File

@@ -1,237 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import {
locationActions,
locationStatus,
locationError,
isLocationLoading
} from '$lib/stores/location';
import { Skeleton } from './skeleton';
interface Props {
class?: string;
variant?: 'primary' | 'secondary' | 'icon';
size?: 'small' | 'medium' | 'large';
showLabel?: boolean;
}
let {
class: className = '',
variant = 'primary',
size = 'medium',
showLabel = true
}: Props = $props();
async function handleLocationClick() {
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
if (!result && $locationError) {
toast.error($locationError.message);
}
}
const buttonText = $derived(() => {
if ($isLocationLoading) return 'Finding location...';
if ($locationStatus === 'success') return 'Update location';
return 'Find my location';
});
const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading';
if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error';
return 'default';
});
</script>
<button
class="location-button {variant} {size} {className}"
onclick={handleLocationClick}
disabled={$isLocationLoading}
title={buttonText()}
>
<span class="icon {iconClass()}">
{#if $isLocationLoading}
<div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" />
</div>
{:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24">
<path
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
/>
</svg>
{:else if $locationStatus === 'error'}
<svg viewBox="0 0 24 24">
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24">
<path
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
/>
</svg>
{/if}
</span>
{#if showLabel && variant !== 'icon'}
<span class="label">{buttonText()}</span>
{/if}
</button>
<style>
.location-button {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all 0.2s ease;
position: relative;
}
.location-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Variants */
.primary {
background: #2563eb;
color: white;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.primary:hover:not(:disabled) {
background: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
}
.secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.secondary:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.icon {
background: transparent;
color: #6b7280;
padding: 8px;
border-radius: 50%;
border: 1px solid #d1d5db;
}
.icon:hover:not(:disabled) {
background: #f3f4f6;
color: #374151;
}
/* Sizes */
.small {
padding: 6px 12px;
font-size: 14px;
}
.small.icon {
padding: 6px;
}
.medium {
padding: 8px 16px;
font-size: 16px;
}
.medium.icon {
padding: 8px;
}
.large {
padding: 12px 20px;
font-size: 18px;
}
.large.icon {
padding: 12px;
}
/* Icon styles */
.icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.small .icon svg {
width: 16px;
height: 16px;
}
.large .icon svg {
width: 24px;
height: 24px;
}
.loading-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.icon.success svg {
color: #10b981;
fill: #10b981;
}
.icon.error svg {
color: #ef4444;
fill: #ef4444;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.label {
white-space: nowrap;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.location-button .label {
display: none;
}
.location-button {
padding: 8px;
border-radius: 50%;
}
}
</style>

View 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 -->

View File

@@ -6,9 +6,9 @@
getMapCenter,
getMapZoom,
shouldZoomToLocation,
locationActions
locationActions,
isWatching
} from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
interface Find {
@@ -38,7 +38,6 @@
center?: [number, number];
zoom?: number;
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
@@ -66,7 +65,6 @@
center,
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true,
finds = [],
onFindClick
@@ -139,11 +137,14 @@
>
{#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker">
<div class="marker-pulse"></div>
<div class="marker-outer">
<div class="marker-inner"></div>
<div class="location-marker" class:watching={$isWatching}>
<div class="marker-pulse" class:watching={$isWatching}></div>
<div class="marker-outer" class:watching={$isWatching}>
<div class="marker-inner" class:watching={$isWatching}></div>
</div>
{#if $isWatching}
<div class="watching-ring"></div>
{/if}
</div>
</Marker>
{/if}
@@ -175,12 +176,6 @@
</Marker>
{/each}
</MapLibre>
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
</div>
{/if}
</div>
</div>
@@ -215,17 +210,9 @@
.map-container :global(.maplibregl-map) {
margin: 0 auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-radius: 12px;
overflow: hidden;
}
.location-controls {
position: absolute;
top: 12px;
right: 12px;
}
/* Location marker styles */
:global(.location-marker) {
width: 24px;
@@ -243,6 +230,13 @@
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
:global(.marker-outer.watching) {
background: rgba(245, 158, 11, 0.2);
border-color: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
}
:global(.marker-inner) {
@@ -250,6 +244,12 @@
height: 8px;
background: #2563eb;
border-radius: 50%;
transition: all 0.3s ease;
}
:global(.marker-inner.watching) {
background: #f59e0b;
animation: pulse-glow 2s infinite;
}
:global(.marker-pulse) {
@@ -263,6 +263,22 @@
animation: pulse 2s infinite;
}
:global(.marker-pulse.watching) {
border-color: rgba(245, 158, 11, 0.6);
animation: pulse-watching 1.5s infinite;
}
:global(.watching-ring) {
position: absolute;
top: -8px;
left: -8px;
width: 36px;
height: 36px;
border: 2px solid rgba(245, 158, 11, 0.4);
border-radius: 50%;
animation: expand-ring 3s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
@@ -274,6 +290,48 @@
}
}
@keyframes pulse-watching {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.5);
opacity: 0.4;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse-glow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes expand-ring {
0% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 0.3;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
/* Find marker styles */
:global(.find-marker) {
width: 40px;

View File

@@ -163,6 +163,7 @@
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
background: white;
overflow: hidden;
z-index: 9999;
}
.modal.dropdown {
@@ -171,11 +172,12 @@
right: 0;
max-width: 320px;
width: 320px;
z-index: 1000;
z-index: 10000;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.1);
z-index: 9998;
}
.modal-content {

View 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}

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolveRoute } from '$app/paths';
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,9 +8,10 @@
DropdownMenuSeparator,
DropdownMenuTrigger
} from './dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Skeleton } from './skeleton';
import ProfilePicture from './ProfilePicture.svelte';
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
import NotificationSettingsSheet from './NotificationSettingsSheet.svelte';
interface Props {
username: string;
@@ -21,9 +23,7 @@
let { username, id, profilePictureUrl, loading = false }: Props = $props();
let showProfilePictureSheet = $state(false);
// Get the first letter of username for avatar
const initial = username.charAt(0).toUpperCase();
let showNotificationSettingsSheet = $state(false);
function openProfilePictureSheet() {
showProfilePictureSheet = true;
@@ -32,22 +32,19 @@
function closeProfilePictureSheet() {
showProfilePictureSheet = false;
}
function openNotificationSettingsSheet() {
showNotificationSettingsSheet = true;
}
function closeNotificationSettingsSheet() {
showNotificationSettingsSheet = false;
}
</script>
<DropdownMenu>
<DropdownMenuTrigger class="profile-trigger">
{#if loading}
<Skeleton class="h-10 w-10 rounded-full" />
{:else}
<Avatar class="profile-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="profile-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
{/if}
<ProfilePicture {username} {profilePictureUrl} {loading} class="profile-avatar" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="profile-dropdown-content">
@@ -85,7 +82,11 @@
</DropdownMenuItem>
<DropdownMenuItem class="friends-item">
<a href="/friends" class="friends-link">Friends</a>
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
</DropdownMenuItem>
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettingsSheet}>
Notifications
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -120,6 +121,10 @@
/>
{/if}
{#if showNotificationSettingsSheet}
<NotificationSettingsSheet onClose={closeNotificationSettingsSheet} />
{/if}
<style>
:global(.profile-trigger) {
background: none;
@@ -144,15 +149,6 @@
height: 40px;
}
:global(.profile-avatar-fallback) {
background: black;
color: white;
font-weight: 600;
font-size: 16px;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.profile-dropdown-content) {
min-width: 200px;
max-width: 240px;
@@ -211,6 +207,16 @@
background: #f5f5f5;
}
:global(.notification-settings-item) {
cursor: pointer;
font-weight: 500;
color: #333;
}
:global(.notification-settings-item:hover) {
background: #f5f5f5;
}
.friends-link {
display: block;
width: 100%;

View 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>

View File

@@ -2,7 +2,7 @@
import { invalidateAll } from '$app/navigation';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Button } from './button';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import ProfilePicture from './ProfilePicture.svelte';
interface Props {
userId: string;
@@ -19,8 +19,6 @@
let isDeleting = $state(false);
let showModal = $state(true);
const initial = username.charAt(0).toUpperCase();
// Close modal when showModal changes to false
$effect(() => {
if (!showModal && onClose) {
@@ -118,14 +116,7 @@
<div class="profile-picture-content">
<div class="current-avatar">
<Avatar class="large-avatar">
{#if profilePictureUrl}
<AvatarImage src={profilePictureUrl} alt={username} />
{/if}
<AvatarFallback class="large-avatar-fallback">
{initial}
</AvatarFallback>
</Avatar>
<ProfilePicture {username} {profilePictureUrl} size="xl" class="large-avatar" />
</div>
<div class="upload-section">
@@ -190,15 +181,6 @@
height: 120px;
}
:global(.large-avatar-fallback) {
background: black;
color: white;
font-weight: 600;
font-size: 48px;
border: 4px solid #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.upload-section {
display: flex;
flex-direction: column;

View File

@@ -2,6 +2,7 @@
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import { resolveRoute } from '$app/paths';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
@@ -58,7 +59,7 @@
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
href={disabled ? undefined : resolveRoute(href)}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolveRoute } from '$app/paths';
import { Button } from '$lib/components/button/index.js';
import * as Card from '$lib/components/card/index.js';
import { Label } from '$lib/components/label/index.js';
@@ -62,7 +63,11 @@
</Button>
</div>
</div>
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
<Button
variant="outline"
class="mt-4 w-full"
onclick={() => goto(resolveRoute('/login/google'))}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"

View File

@@ -1,7 +1,7 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',

View File

@@ -13,7 +13,7 @@
bind:ref
data-slot="sheet-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-[9998] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}

View File

@@ -3,11 +3,16 @@ export { default as Input } from './components/Input.svelte';
export { default as Button } from './components/Button.svelte';
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
export { default as Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.svelte';
export { default as LocationButton } from './components/LocationButton.svelte';
export { default as 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';
@@ -24,6 +29,7 @@ export {
locationError,
isLocationLoading,
hasLocationAccess,
isWatching,
getMapCenter,
getMapZoom
} from './stores/location';

View File

@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
import { getLocalR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
.where(eq(table.session.id, session.id));
}
// Generate signed URL for profile picture if it exists
// Generate local proxy URL for profile picture if it exists
let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
// It's a path, generate local proxy URL
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
return {

View File

@@ -1,4 +1,4 @@
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
@@ -75,13 +75,75 @@ export const friendship = pgTable('friendship', {
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Type exports for the new tables
export const findComment = pgTable('find_comment', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Notification system tables
export const notification = pgTable('notification', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
title: text('title').notNull(),
message: text('message').notNull(),
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
isRead: boolean('is_read').default(false),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const notificationSubscription = pgTable('notification_subscription', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
endpoint: text('endpoint').notNull(),
p256dhKey: text('p256dh_key').notNull(),
authKey: text('auth_key').notNull(),
userAgent: text('user_agent'),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const notificationPreferences = pgTable('notification_preferences', {
userId: text('user_id')
.primaryKey()
.references(() => user.id, { onDelete: 'cascade' }),
friendRequests: boolean('friend_requests').default(true),
friendAccepted: boolean('friend_accepted').default(true),
findLiked: boolean('find_liked').default(true),
findCommented: boolean('find_commented').default(true),
pushEnabled: boolean('push_enabled').default(true),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Type exports for the tables
export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect;
export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect;
export type Notification = typeof notification.$inferSelect;
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindCommentInsert = typeof findComment.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert;
export type NotificationInsert = typeof notification.$inferInsert;
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;

View 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
View 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 };

View File

@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
return await getSignedUrl(r2Client, command, { expiresIn });
}
export function getLocalR2Url(path: string): string {
return `/api/media/${path}`;
}

727
src/lib/stores/api-sync.ts Normal file
View 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();

View File

@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
locationStore,
($location) => $location.shouldZoomToLocation
);
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
// Location actions
export const locationActions = {

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

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

View File

@@ -5,11 +5,22 @@
import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton';
import LocationManager from '$lib/components/LocationManager.svelte';
import NotificationManager from '$lib/components/NotificationManager.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
let showHeader = $derived(!isLoginRoute && data?.user);
let isLoading = $derived(!isLoginRoute && !data?.user && data !== null);
let isLoading = $state(false);
// Handle loading state only on client to prevent hydration mismatch
onMount(() => {
if (browser) {
isLoading = !isLoginRoute && !data?.user && data !== null;
}
});
</script>
<svelte:head>
@@ -32,6 +43,12 @@
<Toaster />
<!-- Auto-start location and notfication watching for authenticated users -->
{#if data?.user && !isLoginRoute}
<LocationManager autoStart={true} />
<NotificationManager />
{/if}
{#if showHeader && data.user}
<Header user={data.user} />
{:else if isLoading}
@@ -47,12 +64,12 @@
<style>
.header-skeleton {
border-bottom: 1px solid #e5e7eb;
background: white;
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
position: relative;
z-index: 100;
}
.header-content {

View File

@@ -7,6 +7,9 @@
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 {
@@ -24,6 +27,7 @@
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media: Array<{
id: string;
findId: string;
@@ -53,6 +57,7 @@
};
likeCount?: number;
isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{
type: string;
url: string;
@@ -89,6 +94,34 @@
let showCreateModal = $state(false);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
let isSidebarVisible = $state(true);
// Initialize API sync with server data on mount
onMount(async () => {
if (browser && data.finds && data.finds.length > 0) {
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
description: serverFind.description,
latitude: serverFind.latitude,
longitude: serverFind.longitude,
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
commentCount: 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// All finds - convert server format to component format
let allFinds = $derived(
@@ -102,6 +135,7 @@
},
likeCount: serverFind.likeCount,
isLiked: serverFind.isLikedByUser,
isFromFriend: serverFind.isFromFriend,
media: serverFind.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
@@ -120,7 +154,7 @@
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
return allFinds.filter((find) => find.isFromFriend === true);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
@@ -182,6 +216,10 @@
function closeCreateModal() {
showCreateModal = false;
}
function toggleSidebar() {
isSidebarVisible = !isSidebarVisible;
}
</script>
<svelte:head>
@@ -205,41 +243,43 @@
</svelte:head>
<div class="home-container">
<main class="main-content">
<div class="map-section">
<Map
showLocationButton={true}
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
<!-- Fullscreen map -->
<div class="map-section">
<Map
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
<div class="finds-section">
<div class="finds-header">
<div class="finds-title-section">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
</div>
</main>
<!-- Floating action button for mobile -->
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
<!-- Toggle button -->
<button class="sidebar-toggle" onclick={toggleSidebar} aria-label="Toggle finds list">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isSidebarVisible}
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{:else}
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{/if}
</svg>
</button>
<!-- Left sidebar with finds list -->
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
<div class="finds-header">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
<div class="finds-list-container">
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div>
</div>
</div>
<!-- Modals -->
@@ -252,121 +292,152 @@
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} />
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}
<style>
.home-container {
background-color: #f8f8f8;
min-height: 100vh;
}
.main-content {
padding: 24px 20px;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
position: relative;
}
.map-section {
background: white;
border-radius: 12px;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.map-section :global(.map-container) {
height: 500px;
height: 100vh;
border-radius: 0;
}
.finds-section {
background: white;
.map-section :global(.maplibregl-map) {
border-radius: 0 !important;
box-shadow: none !important;
}
.sidebar-toggle {
position: fixed;
top: 90px;
left: 20px;
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: none;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
.sidebar-toggle svg {
color: #333;
}
.finds-sidebar {
position: fixed;
top: 80px;
left: 80px;
width: 40%;
max-width: 1000px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.finds-sidebar.hidden {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.finds-header {
position: relative;
}
.finds-title-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.finds-list-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
:global(.create-find-button) {
position: absolute;
top: 0;
right: 0;
z-index: 10;
}
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 100;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
@media (max-width: 768px) {
.main-content {
.sidebar-toggle {
top: auto;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
.sidebar-toggle svg {
transform: rotate(90deg);
}
.finds-sidebar {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-width: 100vw;
min-width: 0;
height: 50vh;
border-radius: 20px 20px 0 0;
box-sizing: border-box;
}
.finds-sidebar.hidden {
transform: translateY(100%);
}
.finds-header {
padding: 16px;
gap: 16px;
}
.finds-section {
padding: 16px;
}
.finds-title-section {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
:global(.create-find-button) {
display: none;
}
.fab {
display: flex;
}
.map-section :global(.map-container) {
height: 300px;
height: 100vh;
}
}
@media (max-width: 480px) {
.main-content {
padding: 12px;
.finds-sidebar {
height: 60vh;
}
.finds-section {
.finds-header {
padding: 12px;
}
}

View File

@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
import { getLocalR2Url } from '$lib/server/r2';
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
@@ -107,7 +107,15 @@ export const GET: RequestHandler = async ({ url, locals }) => {
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
) 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))
@@ -168,38 +176,32 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate signed URLs directly
const [signedUrl, signedThumbnailUrl] = await Promise.all([
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
]);
// URLs in database are now paths, generate local proxy URLs
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
return {
...mediaItem,
url: signedUrl,
thumbnailUrl: signedThumbnailUrl
url: localUrl,
thumbnailUrl: localThumbnailUrl
};
})
);
// Generate signed URL for user profile picture if it exists
// Generate local proxy URL for user profile picture if it exists
let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
try {
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for user profile picture:', error);
userProfilePictureUrl = null;
}
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
}
return {
...findItem,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser)
isLikedByUser: Boolean(findItem.isLikedByUser),
isFromFriend: Boolean(findItem.isFromFriend)
};
})
);

View 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 });
}
};

View File

@@ -1,8 +1,10 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findLike, find } from '$lib/server/db/schema';
import { findLike, find, user } from '$lib/server/db/schema';
import { eq, and, count } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { notificationService } from '$lib/server/notifications';
import { pushService } from '$lib/server/push';
function generateLikeId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
@@ -59,6 +61,49 @@ export async function POST({
const likeCount = likeCountResult[0]?.count ?? 0;
// Send notification to find owner if not self-like
const findOwner = existingFind[0];
if (findOwner.userId !== locals.user.id) {
const shouldNotify = await notificationService.shouldNotify(findOwner.userId, 'find_liked');
if (shouldNotify) {
// Get liker's username
const likerUser = await db
.select({ username: user.username })
.from(user)
.where(eq(user.id, locals.user.id))
.limit(1);
const likerUsername = likerUser[0]?.username || 'Someone';
const findTitle = findOwner.title || 'your find';
await notificationService.createNotification({
userId: findOwner.userId,
type: 'find_liked',
title: 'Someone liked your find',
message: `${likerUsername} liked your find: ${findTitle}`,
data: {
findId: findOwner.id,
likerId: locals.user.id,
likerUsername,
findTitle
}
});
// Send push notification
await pushService.sendPushNotification(findOwner.userId, {
title: 'Someone liked your find',
message: `${likerUsername} liked your find: ${findTitle}`,
url: `/?find=${findOwner.id}`,
tag: 'find_liked',
data: {
findId: findOwner.id,
likerId: locals.user.id
}
});
}
}
return json({
success: true,
likeId,

View 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 });
}
};

View File

@@ -4,7 +4,9 @@ import { db } from '$lib/server/db';
import { friendship, user } from '$lib/server/db/schema';
import { eq, and, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
import { getLocalR2Url } from '$lib/server/r2';
import { notificationService } from '$lib/server/notifications';
import { pushService } from '$lib/server/push';
function generateFriendshipId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
@@ -24,30 +26,46 @@ export const GET: RequestHandler = async ({ url, locals }) => {
if (type === 'friends') {
// Get accepted friendships where user is either sender or receiver
friendships = await db
const friendshipsRaw = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
createdAt: friendship.createdAt
})
.from(friendship)
.innerJoin(
user,
or(
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
)
)
.where(
and(
eq(friendship.status, status),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
// 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
@@ -80,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
}
// Generate signed URLs for profile pictures
const friendshipsWithSignedUrls = await Promise.all(
(friendships || []).map(async (friendship) => {
let profilePictureUrl = friendship.friendProfilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
// Generate local proxy URLs for profile pictures
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
let profilePictureUrl = friendship.friendProfilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
})
);
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
});
return json(friendshipsWithSignedUrls);
} catch (err) {
@@ -164,6 +175,34 @@ export const POST: RequestHandler = async ({ request, locals }) => {
})
.returning();
// Send notification to friend
const shouldNotify = await notificationService.shouldNotify(friendId, 'friend_request');
if (shouldNotify) {
await notificationService.createNotification({
userId: friendId,
type: 'friend_request',
title: 'New friend request',
message: `${locals.user.username} sent you a friend request`,
data: {
friendshipId: newFriendship[0].id,
senderId: locals.user.id,
senderUsername: locals.user.username
}
});
// Send push notification
await pushService.sendPushNotification(friendId, {
title: 'New friend request',
message: `${locals.user.username} sent you a friend request`,
url: '/friends',
tag: 'friend_request',
data: {
friendshipId: newFriendship[0].id,
senderId: locals.user.id
}
});
}
return json({ success: true, friendship: newFriendship[0] });
} catch (err) {
console.error('Error sending friend request:', err);

View File

@@ -1,8 +1,10 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { friendship } from '$lib/server/db/schema';
import { friendship, user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { notificationService } from '$lib/server/notifications';
import { pushService } from '$lib/server/push';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) {
@@ -72,6 +74,47 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
.where(eq(friendship.id, friendshipId))
.returning();
// Send notification if friend request was accepted
if (action === 'accept') {
const senderId = friendshipRecord.userId;
const shouldNotify = await notificationService.shouldNotify(senderId, 'friend_accepted');
if (shouldNotify) {
// Get accepter's username
const accepterUser = await db
.select({ username: user.username })
.from(user)
.where(eq(user.id, locals.user.id))
.limit(1);
const accepterUsername = accepterUser[0]?.username || 'Someone';
await notificationService.createNotification({
userId: senderId,
type: 'friend_accepted',
title: 'Friend request accepted',
message: `${accepterUsername} accepted your friend request`,
data: {
friendshipId: friendshipRecord.id,
accepterId: locals.user.id,
accepterUsername
}
});
// Send push notification
await pushService.sendPushNotification(senderId, {
title: 'Friend request accepted',
message: `${accepterUsername} accepted your friend request`,
url: '/friends',
tag: 'friend_accepted',
data: {
friendshipId: friendshipRecord.id,
accepterId: locals.user.id
}
});
}
}
return json({ success: true, friendship: updatedFriendship[0] });
} catch (err) {
console.error('Error updating friendship:', err);

View File

@@ -0,0 +1,63 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { r2Client } from '$lib/server/r2';
import { env } from '$env/dynamic/private';
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const path = params.path;
if (!path) {
throw error(400, 'Path is required');
}
if (!R2_BUCKET_NAME) {
throw error(500, 'R2_BUCKET_NAME environment variable is required');
}
try {
const command = new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
});
const response = await r2Client.send(command);
if (!response.Body) {
throw error(404, 'File not found');
}
const chunks: Uint8Array[] = [];
const body = response.Body as AsyncIterable<Uint8Array>;
for await (const chunk of body) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
setHeaders({
'Content-Type': response.ContentType || 'application/octet-stream',
'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable',
'Content-Length': buffer.length.toString(),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
return new Response(buffer);
} catch (err) {
console.error('Error fetching file from R2:', err);
throw error(500, 'Failed to fetch file');
}
};
export const OPTIONS: RequestHandler = async ({ setHeaders }) => {
setHeaders({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
return new Response(null, { status: 204 });
};

View 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 });
}
};

View 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 });
}
};

View 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');
}
};

View 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 });
}
};

View File

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

View File

@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { user, friendship } from '$lib/server/db/schema';
import { eq, and, or, ilike, ne } from 'drizzle-orm';
import { getSignedR2Url } from '$lib/server/r2';
import { getLocalR2Url } from '$lib/server/r2';
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
@@ -38,25 +38,22 @@ export const GET: RequestHandler = async ({ url, locals }) => {
}> = [];
if (userIds.length > 0) {
existingFriendships = await db
// 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(
and(
eq(friendship.userId, locals.user.id),
or(...userIds.map((id) => eq(friendship.friendId, id)))
),
and(
eq(friendship.friendId, locals.user.id),
or(...userIds.map((id) => eq(friendship.userId, id)))
)
)
);
.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
@@ -66,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
friendshipStatusMap.set(otherUserId, f.status);
});
// Generate signed URLs and add friendship status
const usersWithSignedUrls = await Promise.all(
users.map(async (userItem) => {
let profilePictureUrl = userItem.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
// Generate local proxy URLs and add friendship status
const usersWithSignedUrls = users.map((userItem) => {
let profilePictureUrl = userItem.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
})
);
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
});
return json(usersWithSignedUrls);
} catch (err) {

View File

@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import { Badge } from '$lib/components/badge';
import { toast } from 'svelte-sonner';
import type { PageData } from './$types';
@@ -134,8 +134,10 @@
// Search users when query changes with debounce
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchUsers, 300);
if (searchQuery) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchUsers, 300);
}
});
</script>
@@ -182,10 +184,10 @@
<div class="user-grid">
{#each friends as friend (friend.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<ProfilePicture
username={friend.friendUsername}
profilePictureUrl={friend.friendProfilePictureUrl}
/>
<div class="user-info">
<span class="username">{friend.friendUsername}</span>
<Badge variant="secondary">Friend</Badge>
@@ -216,15 +218,10 @@
<div class="user-grid">
{#each receivedRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<ProfilePicture
username={request.friendUsername}
profilePictureUrl={request.friendProfilePictureUrl}
/>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="outline">Pending</Badge>
@@ -262,15 +259,10 @@
<div class="user-grid">
{#each sentRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<ProfilePicture
username={request.friendUsername}
profilePictureUrl={request.friendProfilePictureUrl}
/>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="secondary">Sent</Badge>
@@ -303,10 +295,10 @@
<div class="user-grid">
{#each searchResults as user (user.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<ProfilePicture
username={user.username}
profilePictureUrl={user.profilePictureUrl}
/>
<div class="user-info">
<span class="username">{user.username}</span>
{#if user.friendshipStatus === 'accepted'}

View File

@@ -130,8 +130,8 @@ self.addEventListener('fetch', (event) => {
}
}
// Handle R2 resources with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
// Handle R2 resources and local media proxy with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) {
const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
@@ -146,7 +146,7 @@ self.addEventListener('fetch', (event) => {
return response;
} catch {
// Return cached version if available, or fall through to other cache checks
return cachedResponse || new Response('R2 resource not available', { status: 404 });
return cachedResponse || new Response('Media resource not available', { status: 404 });
}
}
@@ -222,3 +222,52 @@ self.addEventListener('fetch', (event) => {
event.respondWith(respond());
});
// Handle push notifications
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
const data = event.data.json();
const title = data.title || 'Serengo';
const options = {
body: data.body || '',
icon: data.icon || '/logo.svg',
badge: data.badge || '/logo.svg',
tag: data.tag || 'default',
data: data.data || {},
requireInteraction: false,
vibrate: [200, 100, 200]
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true
})
.then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Otherwise, open a new window
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen);
}
})
);
});