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