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:
2025-11-06 12:38:32 +01:00
parent af49ed6237
commit b8c88d7a58
12 changed files with 1442 additions and 34 deletions

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

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

View File

@@ -4,7 +4,8 @@
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.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 {
id: string;
@@ -23,6 +24,8 @@
}>;
likeCount?: number;
isLiked?: boolean;
commentCount?: number;
currentUserId?: string;
onExplore?: (id: string) => void;
}
@@ -36,12 +39,20 @@
media,
likeCount = 0,
isLiked = false,
commentCount = 0,
currentUserId,
onExplore
}: FindCardProps = $props();
let showComments = $state(false);
function handleExplore() {
onExplore?.(id);
}
function toggleComments() {
showComments = !showComments;
}
</script>
<div class="find-card">
@@ -73,7 +84,7 @@
</div>
</div>
<Button variant="ghost" size="sm" class="more-button">
<MoreHorizontal size={16} />
<Ellipsis size={16} />
</Button>
</div>
@@ -120,9 +131,9 @@
<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} />
@@ -133,6 +144,13 @@
explore
</Button>
</div>
<!-- Comments Section -->
{#if showComments}
<div class="comments-section">
<CommentsList findId={id} {currentUserId} collapsed={false} />
</div>
{/if}
</div>
<style>
@@ -287,6 +305,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

@@ -75,13 +75,28 @@ export const friendship = pgTable('friendship', {
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
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 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;

View File

@@ -59,9 +59,30 @@ export interface FindState {
}>;
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)}`;
@@ -93,6 +114,7 @@ class APISync {
this.initializeEntityStore('find');
this.initializeEntityStore('user');
this.initializeEntityStore('friendship');
this.initializeEntityStore('comment');
// Start processing queue
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
*/
@@ -359,6 +408,23 @@ class APISync {
'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}`);
}
@@ -373,6 +439,10 @@ class APISync {
// 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 });
}
}
@@ -467,6 +537,163 @@ class APISync {
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)
*/
@@ -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)
*/