Skip to content

Commit 5f067b6

Browse files
committed
Improve: Broke down note.jsx into multiple components
- note folder - hooks folder
1 parent f4a1626 commit 5f067b6

8 files changed

Lines changed: 1243 additions & 1067 deletions

client/src/components/note.jsx

Lines changed: 63 additions & 1067 deletions
Large diffs are not rendered by default.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { BiGroup } from 'react-icons/bi';
2+
3+
/**
4+
* CollaborationHeader - Shows live collaboration status and connected users
5+
* Displays user avatars, connection status, and provides access to invite dialog
6+
*/
7+
export default function CollaborationHeader({
8+
connectedPeers,
9+
webrtcPeers,
10+
connectionStatus,
11+
onShowInvite
12+
}) {
13+
return (
14+
<div className="flex items-center space-x-2">
15+
{/* Live Collaboration Indicator - Notion Style */}
16+
{connectedPeers.length > 0 ? (
17+
<div
18+
className="flex items-center space-x-1.5 px-2.5 py-1 bg-blue-50/80 backdrop-blur-sm border border-blue-200/60 rounded-lg transition-all duration-300 hover:bg-blue-100/80"
19+
title={`${connectedPeers.length + 1} people in this note\n${connectedPeers.map(p => `• ${p.username}${webrtcPeers.find(wp => wp.sid === p.sid) ? ' ✓' : ' (connecting...)'}`).join('\n')}`}
20+
>
21+
<div className="flex -space-x-0.5">
22+
{connectedPeers.slice(0, 3).map((peer) => (
23+
<div
24+
key={peer.sid}
25+
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm flex items-center justify-center text-[10px] font-medium text-white transition-all duration-300 ${
26+
webrtcPeers.find(wp => wp.sid === peer.sid)
27+
? 'bg-gradient-to-br from-blue-500 to-blue-600 shadow-blue-500/20'
28+
: 'bg-gradient-to-br from-amber-400 to-amber-500 shadow-amber-400/20 animate-pulse'
29+
}`}
30+
title={`${peer.username} ${webrtcPeers.find(wp => wp.sid === peer.sid) ? '(connected)' : '(connecting...)'}`}
31+
>
32+
{peer.username.charAt(0).toUpperCase()}
33+
</div>
34+
))}
35+
{connectedPeers.length > 3 && (
36+
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-slate-400 to-slate-500 border-2 border-white shadow-sm flex items-center justify-center">
37+
<span className="text-[10px] font-medium text-white">+{connectedPeers.length - 3}</span>
38+
</div>
39+
)}
40+
</div>
41+
<span className="text-xs font-medium text-blue-700">
42+
{connectedPeers.length === 1 ? '1 other' : `${connectedPeers.length} others`}
43+
</span>
44+
</div>
45+
) : connectionStatus === 'connecting' ? (
46+
<div className="flex items-center space-x-1.5 px-2.5 py-1 bg-amber-50/80 backdrop-blur-sm border border-amber-200/60 rounded-lg">
47+
<div className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div>
48+
<span className="text-xs font-medium text-amber-700">Connecting...</span>
49+
</div>
50+
) : connectionStatus === 'error' ? (
51+
<div className="flex items-center space-x-1.5 px-2.5 py-1 bg-orange-50/80 backdrop-blur-sm border border-orange-200/60 rounded-lg">
52+
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
53+
<span className="text-xs font-medium text-orange-700">Offline</span>
54+
</div>
55+
) : (
56+
<span className="text-xs font-light text-slate-400">Working alone</span>
57+
)}
58+
59+
{/* Collaboration Button */}
60+
<button
61+
onClick={onShowInvite}
62+
className="px-4 py-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-xl transition-all duration-300"
63+
>
64+
<BiGroup className="w-5 h-5" />
65+
</button>
66+
</div>
67+
);
68+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useState, useEffect } from 'react';
2+
import { BsPersonPlus } from 'react-icons/bs';
3+
import { MdOutlineClose } from 'react-icons/md';
4+
5+
import { showToast } from '../common/toast';
6+
import { isValidEmail } from '../../utils/validation';
7+
import { firestore } from '../../firebase/config';
8+
import { updateDoc, getDoc, doc, arrayUnion } from "@firebase/firestore";
9+
10+
/**
11+
* CollaboratorInviteDialog - Modal for managing document collaborators
12+
* Allows inviting new collaborators and removing existing ones
13+
*/
14+
export default function CollaboratorInviteDialog({
15+
showInvitePopup,
16+
onClose,
17+
noteID,
18+
user
19+
}) {
20+
const [inputToken, setInputToken] = useState("");
21+
const [existingCollaborators, setExistingCollaborators] = useState([]);
22+
const [documentOwner, setDocumentOwner] = useState(null);
23+
24+
const fetchCollaborators = async () => {
25+
if (!noteID) return;
26+
27+
const noteRef = doc(firestore, 'notes', noteID);
28+
try {
29+
const documentSnapshot = await getDoc(noteRef);
30+
if (documentSnapshot.exists()) {
31+
const data = documentSnapshot.data();
32+
const collaborators = data.collaborators || [];
33+
const owner = data.owner || data.createdBy || data.lastModifiedBy; // Check actual owner field first
34+
35+
setDocumentOwner(owner || null); // Explicitly handle undefined case
36+
setExistingCollaborators(collaborators);
37+
}
38+
} catch (error) {
39+
console.error('Error fetching collaborators:', error);
40+
}
41+
};
42+
43+
const handleInvite = async (email) => {
44+
if (!email || !isValidEmail(email)) {
45+
return showToast.error("Please enter a valid email address");
46+
}
47+
48+
// Check if user is already a collaborator
49+
if (existingCollaborators.includes(email)) {
50+
return showToast.error("User is already a collaborator");
51+
}
52+
53+
if (email === user?.email) {
54+
return showToast.error("You can't invite yourself");
55+
}
56+
57+
const noteRef = doc(firestore, 'notes', noteID);
58+
59+
try {
60+
const documentSnapshot = await getDoc(noteRef);
61+
if (documentSnapshot.exists()) {
62+
await updateDoc(noteRef, {
63+
collaborators: arrayUnion(email)
64+
});
65+
66+
setInputToken("");
67+
setExistingCollaborators(prev => [...prev, email]);
68+
showToast.success("User invited successfully");
69+
} else {
70+
console.log('Document does not exist');
71+
}
72+
} catch (error) {
73+
console.error('Error fetching document:', error);
74+
}
75+
};
76+
77+
const removeCollaborator = async (email) => {
78+
// Prevent removing the document owner
79+
if (email === documentOwner) {
80+
return showToast.error("Cannot remove the document owner");
81+
}
82+
83+
// Only allow owner or the user themselves to remove collaborators
84+
if (documentOwner !== user?.email && email !== user?.email) {
85+
return showToast.error("Only the owner can remove other collaborators");
86+
}
87+
88+
const noteRef = doc(firestore, 'notes', noteID);
89+
90+
try {
91+
const documentSnapshot = await getDoc(noteRef);
92+
if (documentSnapshot.exists()) {
93+
const data = documentSnapshot.data();
94+
const updatedCollaborators = (data.collaborators || []).filter(collab => collab !== email);
95+
96+
await updateDoc(noteRef, {
97+
collaborators: updatedCollaborators
98+
});
99+
100+
setExistingCollaborators(updatedCollaborators);
101+
showToast.success(email === user?.email ? "You left the document" : "Collaborator removed");
102+
}
103+
} catch (error) {
104+
console.error('Error removing collaborator:', error);
105+
showToast.error("Failed to remove collaborator");
106+
}
107+
};
108+
109+
// Fetch collaborators when dialog opens
110+
useEffect(() => {
111+
if (showInvitePopup) {
112+
fetchCollaborators();
113+
}
114+
}, [showInvitePopup, noteID]);
115+
116+
if (!showInvitePopup) return null;
117+
118+
return (
119+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
120+
<div className="bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 w-full max-w-md">
121+
{/* Header */}
122+
<div className="px-6 py-4 border-b border-slate-200/50">
123+
<div className="flex items-center justify-between">
124+
<h3 className="text-lg font-light text-slate-900">Invite Collaborator</h3>
125+
<button
126+
onClick={onClose}
127+
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-xl transition-all duration-300"
128+
>
129+
<MdOutlineClose className="w-4 h-4" />
130+
</button>
131+
</div>
132+
</div>
133+
134+
{/* Content */}
135+
<div className="px-6 py-6">
136+
<div className="space-y-4">
137+
{/* Current Collaborators */}
138+
<div>
139+
<label className="block text-sm font-light text-slate-600 mb-3">
140+
Current Collaborators
141+
</label>
142+
<div className="space-y-2 max-h-40 overflow-y-auto">
143+
{/* Owner */}
144+
{documentOwner ? (
145+
<div className="flex items-center justify-between p-3 bg-slate-50/80 rounded-lg">
146+
<div className="flex items-center space-x-3">
147+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-xs font-medium text-white">
148+
{documentOwner.charAt(0).toUpperCase()}
149+
</div>
150+
<div>
151+
<span className="text-sm font-medium text-slate-900">{documentOwner}</span>
152+
<span className="text-xs text-slate-500 ml-2">(Owner)</span>
153+
{documentOwner === user?.email && (
154+
<span className="text-xs text-blue-600 ml-1">(You)</span>
155+
)}
156+
</div>
157+
</div>
158+
</div>
159+
) : (
160+
<div className="flex items-center justify-between p-3 bg-amber-50/80 rounded-lg border border-amber-200/50">
161+
<div className="flex items-center space-x-3">
162+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-500 flex items-center justify-center text-xs font-medium text-white">
163+
?
164+
</div>
165+
<div>
166+
<span className="text-sm font-medium text-slate-900">Unknown Owner</span>
167+
<span className="text-xs text-slate-500 ml-2">(Legacy document)</span>
168+
</div>
169+
</div>
170+
</div>
171+
)}
172+
173+
{/* Existing collaborators (excluding owner and current user to prevent duplicates) */}
174+
{existingCollaborators
175+
.filter(email => email !== documentOwner) // Don't show owner twice
176+
.map((email, index) => (
177+
<div key={email} className="flex items-center justify-between p-3 bg-slate-50/80 rounded-lg">
178+
<div className="flex items-center space-x-3">
179+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center text-xs font-medium text-white">
180+
{email.charAt(0).toUpperCase()}
181+
</div>
182+
<div>
183+
<span className="text-sm text-slate-900">{email}</span>
184+
{email === user?.email && (
185+
<span className="text-xs text-blue-600 ml-2">(You)</span>
186+
)}
187+
</div>
188+
</div>
189+
{(documentOwner === user?.email || email === user?.email) && (
190+
<button
191+
onClick={() => removeCollaborator(email)}
192+
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1 rounded-lg transition-all duration-200"
193+
title={email === user?.email ? "Leave document" : "Remove collaborator"}
194+
>
195+
<MdOutlineClose className="w-4 h-4" />
196+
</button>
197+
)}
198+
</div>
199+
))
200+
}
201+
202+
{existingCollaborators.filter(email => email !== documentOwner).length === 0 && !documentOwner && (
203+
<div className="text-center py-4 text-slate-400">
204+
<span className="text-sm">No collaborators yet</span>
205+
</div>
206+
)}
207+
</div>
208+
</div>
209+
210+
{/* Add new collaborator */}
211+
<div className="border-t border-slate-200/50 pt-4">
212+
<label className="block text-sm font-light text-slate-600 mb-2">
213+
Invite New Collaborator
214+
</label>
215+
<input
216+
type="text"
217+
placeholder="example@outlook.com"
218+
value={inputToken}
219+
onChange={(e) => setInputToken(e.target.value)}
220+
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white transition-all duration-300 font-light"
221+
onKeyPress={(e) => {
222+
if (e.key === 'Enter') {
223+
handleInvite(inputToken);
224+
}
225+
}}
226+
autoFocus
227+
/>
228+
</div>
229+
230+
<div className="flex items-center space-x-3 pt-2">
231+
<button
232+
onClick={onClose}
233+
className="flex-1 px-4 py-3 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-xl transition-all duration-300 font-light"
234+
>
235+
Done
236+
</button>
237+
<button
238+
onClick={() => handleInvite(inputToken)}
239+
disabled={!inputToken.trim()}
240+
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed font-light flex items-center justify-center space-x-2"
241+
>
242+
<BsPersonPlus className="w-4 h-4" />
243+
<span>Invite</span>
244+
</button>
245+
</div>
246+
</div>
247+
</div>
248+
</div>
249+
</div>
250+
);
251+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import ReactQuill from 'react-quill';
2+
import 'react-quill/dist/quill.snow.css';
3+
import '../quill-custom.css';
4+
5+
/**
6+
* DocumentEditor - ReactQuill wrapper with custom toolbar configuration
7+
* Handles the rich text editor with collaborative editing capabilities
8+
*/
9+
export default function DocumentEditor({ quillRef, onTextChange }) {
10+
const toolbarOptions = [
11+
['bold', 'italic', 'underline', 'strike'],
12+
['blockquote', 'code-block'],
13+
[{ 'header': 1 }, { 'header': 2 }],
14+
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
15+
[{ 'script': 'sub' }, { 'script': 'super' }],
16+
[{ 'indent': '-1' }, { 'indent': '+1' }],
17+
[{ 'size': ['small', false, 'large', 'huge'] }],
18+
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
19+
[{ 'color': [] }, { 'background': [] }],
20+
[{ 'font': [] }],
21+
[{ 'align': [] }],
22+
['clean'],
23+
];
24+
25+
const modules = {
26+
toolbar: toolbarOptions,
27+
};
28+
29+
return (
30+
<div className="max-w-5xl mx-auto px-6 py-6">
31+
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-white/20 quill-container">
32+
<ReactQuill
33+
ref={quillRef}
34+
modules={modules}
35+
theme="snow"
36+
onChange={onTextChange}
37+
placeholder="Start writing your note..."
38+
/>
39+
</div>
40+
</div>
41+
);
42+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* SaveStatusIndicator - Floating save status indicator (Google Docs style)
3+
* Shows current save status with appropriate styling and animations
4+
*/
5+
export default function SaveStatusIndicator({ saveStatus }) {
6+
// Don't render if document is saved
7+
if (saveStatus === 'saved') {
8+
return null;
9+
}
10+
11+
return (
12+
<div className="fixed bottom-6 right-6 z-30">
13+
<div className={`px-3 py-2 rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 border ${
14+
saveStatus === 'saving'
15+
? 'bg-amber-100/90 text-amber-700 border-amber-200'
16+
: 'bg-red-100/90 text-red-700 border-red-200'
17+
}`}>
18+
<span className="text-xs font-medium">
19+
{saveStatus === 'saving' ? 'Saving...' : 'Unsaved'}
20+
</span>
21+
</div>
22+
</div>
23+
);
24+
}

0 commit comments

Comments
 (0)