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
This commit is contained in:
11
drizzle/0006_strange_firebird.sql
Normal file
11
drizzle/0006_strange_firebird.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "find_comment" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"find_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "find_comment" ADD CONSTRAINT "find_comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
521
drizzle/meta/0006_snapshot.json
Normal file
521
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
{
|
||||||
|
"id": "f487a86b-4f25-4d3c-a667-e383c352f3cc",
|
||||||
|
"prevId": "e0be0091-df6b-48be-9d64-8b4108d91651",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.find": {
|
||||||
|
"name": "find",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_name": {
|
||||||
|
"name": "location_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"name": "is_public",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_user_id_user_id_fk": {
|
||||||
|
"name": "find_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_comment": {
|
||||||
|
"name": "find_comment",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_comment_find_id_find_id_fk": {
|
||||||
|
"name": "find_comment_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_comment_user_id_user_id_fk": {
|
||||||
|
"name": "find_comment_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_comment",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_like": {
|
||||||
|
"name": "find_like",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_like_find_id_find_id_fk": {
|
||||||
|
"name": "find_like_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"find_like_user_id_user_id_fk": {
|
||||||
|
"name": "find_like_user_id_user_id_fk",
|
||||||
|
"tableFrom": "find_like",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.find_media": {
|
||||||
|
"name": "find_media",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"find_id": {
|
||||||
|
"name": "find_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_url": {
|
||||||
|
"name": "fallback_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"fallback_thumbnail_url": {
|
||||||
|
"name": "fallback_thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"order_index": {
|
||||||
|
"name": "order_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"find_media_find_id_find_id_fk": {
|
||||||
|
"name": "find_media_find_id_find_id_fk",
|
||||||
|
"tableFrom": "find_media",
|
||||||
|
"tableTo": "find",
|
||||||
|
"columnsFrom": [
|
||||||
|
"find_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"friend_id": {
|
||||||
|
"name": "friend_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_user_id_user_id_fk": {
|
||||||
|
"name": "friendship_user_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_friend_id_user_id_fk": {
|
||||||
|
"name": "friendship_friend_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"friend_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"profile_picture_url": {
|
||||||
|
"name": "profile_picture_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1760631798851,
|
"when": 1760631798851,
|
||||||
"tag": "0005_rapid_warpath",
|
"tag": "0005_rapid_warpath",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762428302491,
|
||||||
|
"tag": "0006_strange_firebird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
141
src/lib/components/Comment.svelte
Normal file
141
src/lib/components/Comment.svelte
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/button';
|
||||||
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
|
import { Trash2 } from '@lucide/svelte';
|
||||||
|
import type { CommentState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
|
interface CommentProps {
|
||||||
|
comment: CommentState;
|
||||||
|
showDeleteButton?: boolean;
|
||||||
|
onDelete?: (commentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { comment, showDeleteButton = false, onDelete }: CommentProps = $props();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete(comment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string): string {
|
||||||
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - dateObj.getTime();
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (minutes < 1) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}d`;
|
||||||
|
} else {
|
||||||
|
return dateObj.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<ProfilePicture
|
||||||
|
username={comment.user.username}
|
||||||
|
profilePictureUrl={comment.user.profilePictureUrl}
|
||||||
|
class="avatar-small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-content">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-username">@{comment.user.username}</span>
|
||||||
|
<span class="comment-time">{formatDate(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-text">
|
||||||
|
{comment.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteButton}
|
||||||
|
<div class="comment-actions">
|
||||||
|
<Button variant="ghost" size="sm" class="delete-button" onclick={handleDelete}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.avatar-small) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delete-button) {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
padding: 0.25rem;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delete-button:hover) {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
background: hsl(var(--destructive) / 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
src/lib/components/CommentForm.svelte
Normal file
117
src/lib/components/CommentForm.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<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 0;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
200
src/lib/components/CommentsList.svelte
Normal file
200
src/lib/components/CommentsList.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { findId, currentUserId, collapsed = true }: 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: any): boolean {
|
||||||
|
return Boolean(
|
||||||
|
currentUserId && (comment.user.id === currentUserId || comment.user.id === 'current-user')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet loadingSkeleton()}
|
||||||
|
<div class="loading-skeleton">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<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">
|
||||||
|
{#each $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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<CommentForm onSubmit={handleAddComment} />
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
||||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
import { MoreHorizontal, MessageCircle, Share } from '@lucide/svelte';
|
import CommentsList from '$lib/components/CommentsList.svelte';
|
||||||
|
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
||||||
|
|
||||||
interface FindCardProps {
|
interface FindCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
}>;
|
}>;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
|
commentCount?: number;
|
||||||
|
currentUserId?: string;
|
||||||
onExplore?: (id: string) => void;
|
onExplore?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +39,20 @@
|
|||||||
media,
|
media,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
isLiked = false,
|
isLiked = false,
|
||||||
|
commentCount = 0,
|
||||||
|
currentUserId,
|
||||||
onExplore
|
onExplore
|
||||||
}: FindCardProps = $props();
|
}: FindCardProps = $props();
|
||||||
|
|
||||||
|
let showComments = $state(false);
|
||||||
|
|
||||||
function handleExplore() {
|
function handleExplore() {
|
||||||
onExplore?.(id);
|
onExplore?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleComments() {
|
||||||
|
showComments = !showComments;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="find-card">
|
<div class="find-card">
|
||||||
@@ -73,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" class="more-button">
|
<Button variant="ghost" size="sm" class="more-button">
|
||||||
<MoreHorizontal size={16} />
|
<Ellipsis size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,9 +131,9 @@
|
|||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<LikeButton findId={id} {isLiked} {likeCount} size="sm" />
|
<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} />
|
<MessageCircle size={16} />
|
||||||
<span>comment</span>
|
<span>{commentCount || 'comment'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" class="action-button">
|
<Button variant="ghost" size="sm" class="action-button">
|
||||||
<Share size={16} />
|
<Share size={16} />
|
||||||
@@ -133,6 +144,13 @@
|
|||||||
explore
|
explore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
{#if showComments}
|
||||||
|
<div class="comments-section">
|
||||||
|
<CommentsList findId={id} {currentUserId} collapsed={false} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -287,6 +305,13 @@
|
|||||||
font-size: 0.875rem;
|
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 */
|
/* Mobile responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.post-actions {
|
.post-actions {
|
||||||
|
|||||||
@@ -75,13 +75,28 @@ export const friendship = pgTable('friendship', {
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const findComment = pgTable('find_comment', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
findId: text('find_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => find.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
// Type exports for the new tables
|
// Type exports for the new tables
|
||||||
export type Find = typeof find.$inferSelect;
|
export type Find = typeof find.$inferSelect;
|
||||||
export type FindMedia = typeof findMedia.$inferSelect;
|
export type FindMedia = typeof findMedia.$inferSelect;
|
||||||
export type FindLike = typeof findLike.$inferSelect;
|
export type FindLike = typeof findLike.$inferSelect;
|
||||||
|
export type FindComment = typeof findComment.$inferSelect;
|
||||||
export type Friendship = typeof friendship.$inferSelect;
|
export type Friendship = typeof friendship.$inferSelect;
|
||||||
|
|
||||||
export type FindInsert = typeof find.$inferInsert;
|
export type FindInsert = typeof find.$inferInsert;
|
||||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||||
|
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||||
|
|||||||
@@ -59,9 +59,30 @@ export interface FindState {
|
|||||||
}>;
|
}>;
|
||||||
isLikedByUser: boolean;
|
isLikedByUser: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
|
commentCount: number;
|
||||||
isFromFriend: boolean;
|
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
|
// Generate unique operation IDs
|
||||||
function generateOperationId(): string {
|
function generateOperationId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
@@ -93,6 +114,7 @@ class APISync {
|
|||||||
this.initializeEntityStore('find');
|
this.initializeEntityStore('find');
|
||||||
this.initializeEntityStore('user');
|
this.initializeEntityStore('user');
|
||||||
this.initializeEntityStore('friendship');
|
this.initializeEntityStore('friendship');
|
||||||
|
this.initializeEntityStore('comment');
|
||||||
|
|
||||||
// Start processing queue
|
// Start processing queue
|
||||||
this.startQueueProcessor();
|
this.startQueueProcessor();
|
||||||
@@ -157,6 +179,33 @@ class APISync {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Check if entity state exists
|
||||||
*/
|
*/
|
||||||
@@ -359,6 +408,23 @@ class APISync {
|
|||||||
'Content-Type': 'application/json'
|
'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 {
|
} else {
|
||||||
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
|
||||||
}
|
}
|
||||||
@@ -373,6 +439,10 @@ class APISync {
|
|||||||
// Update entity state with successful result
|
// Update entity state with successful result
|
||||||
if (entityType === 'find' && action === 'like') {
|
if (entityType === 'find' && action === 'like') {
|
||||||
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +537,163 @@ class APISync {
|
|||||||
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
|
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)
|
* Initialize find data from server (only if no existing state)
|
||||||
*/
|
*/
|
||||||
@@ -476,6 +703,13 @@ class APISync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 unused subscriptions (call this when components unmount)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
// Server response type
|
// Server response type
|
||||||
interface ServerFind {
|
interface ServerFind {
|
||||||
@@ -97,36 +98,6 @@
|
|||||||
// Initialize API sync with server data on mount
|
// Initialize API sync with server data on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (browser && data.finds && data.finds.length > 0) {
|
if (browser && data.finds && data.finds.length > 0) {
|
||||||
// Dynamically import the API sync to avoid SSR issues
|
|
||||||
const { apiSync } = await import('$lib/stores/api-sync');
|
|
||||||
|
|
||||||
// Define the FindState interface locally
|
|
||||||
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;
|
|
||||||
isFromFriend: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
|
||||||
id: serverFind.id,
|
id: serverFind.id,
|
||||||
title: serverFind.title,
|
title: serverFind.title,
|
||||||
@@ -143,6 +114,7 @@
|
|||||||
media: serverFind.media,
|
media: serverFind.media,
|
||||||
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
isLikedByUser: Boolean(serverFind.isLikedByUser),
|
||||||
likeCount: serverFind.likeCount || 0,
|
likeCount: serverFind.likeCount || 0,
|
||||||
|
commentCount: 0,
|
||||||
isFromFriend: Boolean(serverFind.isFromFriend)
|
isFromFriend: Boolean(serverFind.isFromFriend)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
119
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
119
src/routes/api/finds/[findId]/comments/+server.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { findComment, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const [newComment] = 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
46
src/routes/api/finds/comments/[commentId]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { findComment, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user