Skip to content

Commit 8153324

Browse files
committed
Add comments
1 parent ee61d28 commit 8153324

15 files changed

Lines changed: 723 additions & 119 deletions

firestore.indexes.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
{
2-
"indexes": [],
2+
"indexes": [
3+
{
4+
"collectionGroup": "comments",
5+
"queryScope": "COLLECTION",
6+
"fields": [
7+
{ "fieldPath": "isPinned", "order": "DESCENDING" },
8+
{ "fieldPath": "createdAt", "order": "DESCENDING" }
9+
]
10+
}
11+
],
312
"fieldOverrides": []
413
}

firestore.rules

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
rules_version = '2';
22
service cloud.firestore {
33
match /databases/{database}/documents {
4-
// Public read access but only authenticated users can write
5-
match /notes/{noteId} {
6-
allow read: if true;
7-
allow write: if request.auth != null;
4+
// Base rule - authenticated users can read
5+
match /{document=**} {
6+
allow read: if request.auth != null;
87
}
98

10-
match /comments/{commentId} {
11-
allow read: if true;
12-
allow write: if request.auth != null;
9+
// Notes collection
10+
match /notes/{noteId} {
11+
// Allow authenticated users to read notes
12+
allow read: if request.auth != null;
13+
14+
// Allow admins and editors to create/update/delete notes
15+
allow create, update, delete: if request.auth != null &&
16+
(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin' ||
17+
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'editor');
18+
19+
// Comments subcollection - temporarily more permissive for debugging
20+
match /comments/{commentId} {
21+
// Allow all authenticated users to read/write comments for debugging
22+
allow read, write: if request.auth != null;
23+
}
1324
}
1425

26+
// Users collection
1527
match /users/{userId} {
16-
allow read: if true;
17-
allow write: if request.auth != null && request.auth.uid == userId;
28+
// Users can read/write their own data
29+
allow read, write: if request.auth != null && request.auth.uid == userId;
30+
31+
// Admins can read all user data
32+
allow read: if request.auth != null &&
33+
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
1834
}
1935
}
2036
}

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dependencies": {
1515
"class-variance-authority": "^0.7.1",
1616
"clsx": "^2.1.1",
17+
"date-fns": "^4.1.0",
1718
"firebase": "^10.7.0",
1819
"lucide-react": "^0.476.0",
1920
"react": "^19.0.0",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useState } from 'react';
2+
3+
interface CommentFormProps {
4+
onSubmit: (content: string) => Promise<void>;
5+
placeholder?: string;
6+
}
7+
8+
export default function CommentForm({
9+
onSubmit,
10+
placeholder = "Add a comment..."
11+
}: CommentFormProps) {
12+
const [content, setContent] = useState('');
13+
const [isSubmitting, setIsSubmitting] = useState(false);
14+
15+
const handleSubmit = async (e: React.FormEvent) => {
16+
e.preventDefault();
17+
18+
if (!content.trim()) return;
19+
20+
setIsSubmitting(true);
21+
try {
22+
await onSubmit(content);
23+
setContent('');
24+
} catch (error) {
25+
console.error('Error submitting comment:', error);
26+
} finally {
27+
setIsSubmitting(false);
28+
}
29+
};
30+
31+
return (
32+
<form onSubmit={handleSubmit}>
33+
<textarea
34+
value={content}
35+
onChange={(e) => setContent(e.target.value)}
36+
placeholder={placeholder}
37+
rows={2}
38+
className="w-full p-2 rounded-md border-2 border-border bg-card text-foreground text-sm"
39+
disabled={isSubmitting}
40+
/>
41+
<div className="flex justify-end mt-2">
42+
<button
43+
type="submit"
44+
disabled={!content.trim() || isSubmitting}
45+
className="inline-flex justify-center py-1.5 px-3 border-2 border-primary shadow-sm text-sm font-medium rounded-md text-primary-foreground bg-primary hover:bg-primary/80 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
46+
>
47+
Post
48+
</button>
49+
</div>
50+
</form>
51+
);
52+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useState } from 'react';
2+
import { Comment } from '../../types/Comment';
3+
import { useAuth } from '../../contexts/AuthContext';
4+
import { updateComment, deleteComment } from '../../services/commentService';
5+
import { formatDistanceToNow } from 'date-fns';
6+
import { PinIcon, EditIcon, TrashIcon } from 'lucide-react';
7+
8+
interface CommentItemProps {
9+
comment: Comment;
10+
noteId: string;
11+
onPin: (commentId: string, isPinned: boolean) => Promise<void>;
12+
isPinnable: boolean;
13+
}
14+
15+
export default function CommentItem({
16+
comment,
17+
noteId,
18+
onPin,
19+
isPinnable
20+
}: CommentItemProps) {
21+
const { currentUser } = useAuth();
22+
const [isEditing, setIsEditing] = useState(false);
23+
const [editedContent, setEditedContent] = useState(comment.content);
24+
const [isUpdating, setIsUpdating] = useState(false);
25+
26+
const canEdit = currentUser && currentUser.uid === comment.createdBy.uid;
27+
28+
// Format the timestamp safely
29+
const formatTimeAgo = (date: Date | null) => {
30+
if (!date || isNaN(date.getTime())) {
31+
return 'Just now';
32+
}
33+
34+
try {
35+
return formatDistanceToNow(date, { addSuffix: true });
36+
} catch (error) {
37+
console.error('Date formatting error:', error);
38+
return 'Recently';
39+
}
40+
};
41+
42+
const handleUpdate = async () => {
43+
if (!editedContent.trim() || isUpdating) return;
44+
45+
setIsUpdating(true);
46+
try {
47+
await updateComment(noteId, comment.id, editedContent);
48+
setIsEditing(false);
49+
} catch (error) {
50+
console.error('Error updating comment:', error);
51+
} finally {
52+
setIsUpdating(false);
53+
}
54+
};
55+
56+
const handleDelete = async () => {
57+
if (window.confirm('Are you sure you want to delete this comment?')) {
58+
try {
59+
await deleteComment(noteId, comment.id);
60+
} catch (error) {
61+
console.error('Error deleting comment:', error);
62+
}
63+
}
64+
};
65+
66+
const handlePin = async () => {
67+
try {
68+
await onPin(comment.id, !comment.isPinned);
69+
} catch (error) {
70+
console.error('Error pinning comment:', error);
71+
}
72+
};
73+
74+
return (
75+
<div className={`mb-3 p-3 rounded-md ${comment.isPinned ? 'bg-primary/5 border border-primary/20' : 'bg-card hover:bg-muted'}`}>
76+
<div className="flex justify-between items-start">
77+
<div className="flex items-center">
78+
<div className="font-medium text-sm">
79+
{comment.createdBy.displayName || 'Anonymous'}
80+
</div>
81+
<span className="mx-1 text-xs text-muted-foreground"></span>
82+
<div className="text-xs text-muted-foreground">
83+
{formatTimeAgo(comment.createdAt)}
84+
</div>
85+
</div>
86+
87+
<div className="flex space-x-1">
88+
{isPinnable && (
89+
<button
90+
onClick={handlePin}
91+
className={`p-1 rounded-full ${comment.isPinned ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
92+
title={comment.isPinned ? 'Unpin comment' : 'Pin comment'}
93+
>
94+
<PinIcon className="h-3.5 w-3.5" />
95+
</button>
96+
)}
97+
98+
{canEdit && (
99+
<>
100+
<button
101+
onClick={() => setIsEditing(true)}
102+
className="p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted"
103+
title="Edit comment"
104+
disabled={isEditing}
105+
>
106+
<EditIcon className="h-3.5 w-3.5" />
107+
</button>
108+
109+
<button
110+
onClick={handleDelete}
111+
className="p-1 rounded-full text-muted-foreground hover:text-destructive hover:bg-muted"
112+
title="Delete comment"
113+
>
114+
<TrashIcon className="h-3.5 w-3.5" />
115+
</button>
116+
</>
117+
)}
118+
</div>
119+
</div>
120+
121+
<div className="mt-1">
122+
{isEditing ? (
123+
<div>
124+
<textarea
125+
value={editedContent}
126+
onChange={(e) => setEditedContent(e.target.value)}
127+
className="w-full p-2 text-sm border rounded-md bg-card"
128+
rows={2}
129+
/>
130+
<div className="flex justify-end space-x-2 mt-2">
131+
<button
132+
onClick={() => setIsEditing(false)}
133+
className="px-2 py-1 text-xs"
134+
>
135+
Cancel
136+
</button>
137+
<button
138+
onClick={handleUpdate}
139+
className="px-2 py-1 bg-primary text-primary-foreground rounded text-xs"
140+
disabled={!editedContent.trim() || isUpdating}
141+
>
142+
{isUpdating ? 'Saving...' : 'Save'}
143+
</button>
144+
</div>
145+
</div>
146+
) : (
147+
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
148+
)}
149+
</div>
150+
</div>
151+
);
152+
}

0 commit comments

Comments
 (0)