| 1 | "use client"; |
| 2 | |
| 3 | import { useState, useRef } from "react"; |
| 4 | import { useCollab } from "./collab-provider"; |
| 5 | import type { CollabNote } from "../hooks/use-collab-socket"; |
| 6 | |
| 7 | interface NotesSidebarProps { |
| 8 | diagramId: string; |
| 9 | diagramTitle: string; |
| 10 | } |
| 11 | |
| 12 | function timeAgo(ts: string): string { |
| 13 | const secs = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); |
| 14 | if (secs < 60) return "just now"; |
| 15 | if (secs < 3600) return Math.floor(secs / 60) + "m ago"; |
| 16 | if (secs < 86400) return Math.floor(secs / 3600) + "h ago"; |
| 17 | return Math.floor(secs / 86400) + "d ago"; |
| 18 | } |
| 19 | |
| 20 | function NoteCard({ note, onEdit, onDelete }: { note: CollabNote; onEdit: (text: string) => void; onDelete: () => void }) { |
| 21 | const [editing, setEditing] = useState(false); |
| 22 | const [editText, setEditText] = useState(note.text); |
| 23 | |
| 24 | const handleSave = () => { |
| 25 | if (editText.trim() && editText !== note.text) { |
| 26 | onEdit(editText.trim()); |
| 27 | } |
| 28 | setEditing(false); |
| 29 | }; |
| 30 | |
| 31 | return ( |
| 32 | <div |
| 33 | style={{ |
| 34 | padding: "8px 10px", |
| 35 | borderBottom: "1px solid var(--border-subtle, var(--divide))", |
| 36 | fontSize: "13px", |
| 37 | position: "relative", |
| 38 | transition: "background-color 0.12s", |
| 39 | }} |
| 40 | onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-hover)")} |
| 41 | onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")} |
| 42 | > |
| 43 | <div className="flex items-center gap-1.5" style={{ marginBottom: "3px", fontSize: "10px", color: "var(--text-muted)", fontFamily: "'JetBrains Mono', Menlo, monospace" }}> |
| 44 | <span style={{ fontWeight: 500, color: "var(--accent)" }}>{note.author}</span> |
| 45 | <span>·</span> |
| 46 | <span>{timeAgo(note.timestamp)}</span> |
| 47 | </div> |
| 48 | {editing ? ( |
| 49 | <div> |
| 50 | <textarea |
| 51 | value={editText} |
| 52 | onChange={(e) => setEditText(e.target.value)} |
| 53 | onKeyDown={(e) => { |
| 54 | if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSave(); } |
| 55 | if (e.key === "Escape") setEditing(false); |
| 56 | }} |
| 57 | className="w-full text-sm px-2 py-1" |
| 58 | style={{ |
| 59 | backgroundColor: "var(--bg-input)", |
| 60 | border: "1px solid var(--border)", |
| 61 | color: "var(--text-primary)", |
| 62 | resize: "vertical", |
| 63 | minHeight: "2.5rem", |
| 64 | outline: "none", |
| 65 | font: "inherit", |
| 66 | }} |
| 67 | rows={2} |
| 68 | autoFocus |
| 69 | /> |
| 70 | <div className="flex gap-1 mt-1"> |
| 71 | <button |
| 72 | onClick={handleSave} |
| 73 | className="text-xs px-2 py-0.5" |
| 74 | style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)", border: "none", cursor: "pointer", font: "inherit" }} |
| 75 | > |
| 76 | Save |
| 77 | </button> |
| 78 | <button |
| 79 | onClick={() => setEditing(false)} |
| 80 | className="text-xs px-2 py-0.5" |
| 81 | style={{ border: "1px solid var(--border)", background: "none", color: "var(--text-muted)", cursor: "pointer", font: "inherit" }} |
| 82 | > |
| 83 | Cancel |
| 84 | </button> |
| 85 | </div> |
| 86 | </div> |
| 87 | ) : ( |
| 88 | <div> |
| 89 | <p style={{ color: "var(--text-primary)", whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: "12px", lineHeight: 1.5 }}> |
| 90 | {note.text} |
| 91 | </p> |
| 92 | <div className="note-actions flex gap-2 mt-1"> |
| 93 | <button |
| 94 | onClick={() => { setEditText(note.text); setEditing(true); }} |
| 95 | className="text-xs" |
| 96 | style={{ color: "var(--text-faint)", background: "none", border: "none", cursor: "pointer", font: "inherit" }} |
| 97 | > |
| 98 | Edit |
| 99 | </button> |
| 100 | <button |
| 101 | onClick={onDelete} |
| 102 | className="text-xs" |
| 103 | style={{ color: "var(--text-faint)", background: "none", border: "none", cursor: "pointer", font: "inherit" }} |
| 104 | > |
| 105 | Delete |
| 106 | </button> |
| 107 | </div> |
| 108 | </div> |
| 109 | )} |
| 110 | </div> |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | export function NotesSidebar({ diagramId, diagramTitle }: NotesSidebarProps) { |
| 115 | const { notes, emit } = useCollab(); |
| 116 | const [text, setText] = useState(""); |
| 117 | const diagramNotes = notes[diagramId] ?? []; |
| 118 | |
| 119 | const handleAdd = () => { |
| 120 | if (!text.trim()) return; |
| 121 | emit("add-note", { text: text.trim(), diagramId, diagramTitle }); |
| 122 | setText(""); |
| 123 | }; |
| 124 | |
| 125 | const handleEdit = (noteId: string, newText: string) => { |
| 126 | emit("edit-note", { noteId, diagramId, text: newText }); |
| 127 | }; |
| 128 | |
| 129 | const handleDelete = (noteId: string) => { |
| 130 | emit("delete-note", { noteId, diagramId }); |
| 131 | }; |
| 132 | |
| 133 | return ( |
| 134 | <div |
| 135 | style={{ |
| 136 | width: "300px", |
| 137 | minWidth: "180px", |
| 138 | borderLeft: "1px solid var(--border)", |
| 139 | background: "var(--bg-card)", |
| 140 | display: "flex", |
| 141 | flexDirection: "column", |
| 142 | flexShrink: 0, |
| 143 | overflow: "hidden", |
| 144 | }} |
| 145 | > |
| 146 | <div |
| 147 | style={{ |
| 148 | padding: "8px 12px", |
| 149 | fontSize: "11px", |
| 150 | fontWeight: 500, |
| 151 | color: "var(--text-muted)", |
| 152 | borderBottom: "1px solid var(--border-subtle, var(--divide))", |
| 153 | fontFamily: "'JetBrains Mono', Menlo, monospace", |
| 154 | textTransform: "uppercase", |
| 155 | letterSpacing: "0.5px", |
| 156 | }} |
| 157 | > |
| 158 | Notes |
| 159 | </div> |
| 160 | <div style={{ flex: 1, overflowY: "auto", padding: "6px" }}> |
| 161 | {diagramNotes.length === 0 && ( |
| 162 | <p style={{ color: "var(--text-faint)", fontSize: "12px", fontStyle: "italic", textAlign: "center", padding: "32px 16px" }}> |
| 163 | No notes yet for this diagram. |
| 164 | Click on the diagram or type below to add one. |
| 165 | </p> |
| 166 | )} |
| 167 | {diagramNotes.map((note) => ( |
| 168 | <NoteCard |
| 169 | key={note.id} |
| 170 | note={note} |
| 171 | onEdit={(t) => handleEdit(note.id, t)} |
| 172 | onDelete={() => handleDelete(note.id)} |
| 173 | /> |
| 174 | ))} |
| 175 | </div> |
| 176 | <div style={{ borderTop: "1px solid var(--border-subtle, var(--divide))", padding: "8px", display: "flex", gap: "6px", alignItems: "flex-end" }}> |
| 177 | <textarea |
| 178 | value={text} |
| 179 | onChange={(e) => setText(e.target.value)} |
| 180 | onKeyDown={(e) => { |
| 181 | if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleAdd(); } |
| 182 | }} |
| 183 | placeholder="Add a note..." |
| 184 | style={{ |
| 185 | flex: 1, |
| 186 | backgroundColor: "var(--bg-input, var(--bg-inset))", |
| 187 | border: "1px solid var(--border)", |
| 188 | color: "var(--text-primary)", |
| 189 | resize: "none", |
| 190 | outline: "none", |
| 191 | font: "inherit", |
| 192 | fontSize: "13px", |
| 193 | padding: "6px 8px", |
| 194 | lineHeight: 1.4, |
| 195 | }} |
| 196 | rows={1} |
| 197 | /> |
| 198 | <button |
| 199 | onClick={handleAdd} |
| 200 | disabled={!text.trim()} |
| 201 | title="Send note (Enter)" |
| 202 | style={{ |
| 203 | width: "32px", |
| 204 | height: "32px", |
| 205 | display: "flex", |
| 206 | alignItems: "center", |
| 207 | justifyContent: "center", |
| 208 | background: text.trim() ? "var(--accent)" : "var(--bg-hover)", |
| 209 | color: text.trim() ? "var(--accent-text)" : "var(--text-faint)", |
| 210 | border: "none", |
| 211 | borderRadius: "4px", |
| 212 | cursor: text.trim() ? "pointer" : "default", |
| 213 | flexShrink: 0, |
| 214 | }} |
| 215 | > |
| 216 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> |
| 217 | </button> |
| 218 | </div> |
| 219 | </div> |
| 220 | ); |
| 221 | } |
| 222 | |