Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useState } from 'react' | |
| import type { ThreadSnap } from './model' | |
| import type { CollabUser } from '../collab/user' | |
| function timeAgo(ts: number): string { | |
| const s = Math.floor((Date.now() - ts) / 1000) | |
| if (s < 60) return 'just now' | |
| const m = Math.floor(s / 60) | |
| if (m < 60) return `${m}m` | |
| const h = Math.floor(m / 60) | |
| if (h < 24) return `${h}h` | |
| return `${Math.floor(h / 24)}d` | |
| } | |
| export interface NewComment { | |
| quote: string | |
| } | |
| export function CommentsPanel({ | |
| threads, | |
| me, | |
| activeId, | |
| composing, | |
| onSubmitNew, | |
| onCancelNew, | |
| onReply, | |
| onResolve, | |
| onDelete, | |
| onFocus, | |
| onClose, | |
| }: { | |
| threads: ThreadSnap[] | |
| me: CollabUser | |
| activeId: string | null | |
| composing: NewComment | null | |
| onSubmitNew: (text: string) => void | |
| onCancelNew: () => void | |
| onReply: (threadId: string, text: string) => void | |
| onResolve: (threadId: string, resolved: boolean) => void | |
| onDelete: (threadId: string) => void | |
| onFocus: (thread: ThreadSnap) => void | |
| onClose: () => void | |
| }) { | |
| const open = threads.filter((t) => !t.resolved) | |
| const resolved = threads.filter((t) => t.resolved) | |
| open.sort((a, b) => (a.from ?? Infinity) - (b.from ?? Infinity)) | |
| return ( | |
| <aside className="comments"> | |
| <div className="comments-head"> | |
| <strong>Comments</strong> | |
| <span className="muted">{open.length} open</span> | |
| <span className="spacer" /> | |
| <button className="icon-btn" onClick={onClose} title="Close"> | |
| ✕ | |
| </button> | |
| </div> | |
| <div className="comments-body"> | |
| {composing && ( | |
| <NewCommentCard composing={composing} onSubmit={onSubmitNew} onCancel={onCancelNew} /> | |
| )} | |
| {open.length === 0 && !composing && ( | |
| <p className="muted comments-empty"> | |
| Select text in the editor and click <b>+ Comment</b> to start a thread. | |
| </p> | |
| )} | |
| {open.map((t) => ( | |
| <ThreadCard | |
| key={t.id} | |
| thread={t} | |
| me={me} | |
| active={t.id === activeId} | |
| onReply={onReply} | |
| onResolve={onResolve} | |
| onDelete={onDelete} | |
| onFocus={onFocus} | |
| /> | |
| ))} | |
| {resolved.length > 0 && ( | |
| <details className="resolved-group"> | |
| <summary>Resolved ({resolved.length})</summary> | |
| {resolved.map((t) => ( | |
| <ThreadCard | |
| key={t.id} | |
| thread={t} | |
| me={me} | |
| active={t.id === activeId} | |
| onReply={onReply} | |
| onResolve={onResolve} | |
| onDelete={onDelete} | |
| onFocus={onFocus} | |
| /> | |
| ))} | |
| </details> | |
| )} | |
| </div> | |
| </aside> | |
| ) | |
| } | |
| function NewCommentCard({ | |
| composing, | |
| onSubmit, | |
| onCancel, | |
| }: { | |
| composing: NewComment | |
| onSubmit: (text: string) => void | |
| onCancel: () => void | |
| }) { | |
| const [text, setText] = useState('') | |
| const ref = useRef<HTMLTextAreaElement>(null) | |
| useEffect(() => ref.current?.focus(), []) | |
| return ( | |
| <div className="thread-card composing"> | |
| {composing.quote && <div className="quote">“{composing.quote}”</div>} | |
| <textarea | |
| ref={ref} | |
| className="reply-input" | |
| placeholder="Add a comment…" | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) onSubmit(text.trim()) | |
| }} | |
| /> | |
| <div className="thread-actions"> | |
| <button className="btn-primary" disabled={!text.trim()} onClick={() => onSubmit(text.trim())}> | |
| Comment | |
| </button> | |
| <button className="btn-ghost" onClick={onCancel}> | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function ThreadCard({ | |
| thread, | |
| me, | |
| active, | |
| onReply, | |
| onResolve, | |
| onDelete, | |
| onFocus, | |
| }: { | |
| thread: ThreadSnap | |
| me: CollabUser | |
| active: boolean | |
| onReply: (threadId: string, text: string) => void | |
| onResolve: (threadId: string, resolved: boolean) => void | |
| onDelete: (threadId: string) => void | |
| onFocus: (thread: ThreadSnap) => void | |
| }) { | |
| const [text, setText] = useState('') | |
| const ref = useRef<HTMLDivElement>(null) | |
| useEffect(() => { | |
| if (active) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) | |
| }, [active]) | |
| return ( | |
| <div | |
| ref={ref} | |
| className={`thread-card${active ? ' active' : ''}${thread.resolved ? ' resolved' : ''}`} | |
| > | |
| <div className="quote" onClick={() => onFocus(thread)} title="Jump to text"> | |
| {thread.from == null ? <span className="muted">(text removed)</span> : `“${thread.quote}”`} | |
| </div> | |
| {thread.replies.map((r) => ( | |
| <div key={r.id} className="reply"> | |
| <div className="reply-meta"> | |
| <span className="dot" style={{ background: r.color }} /> | |
| <span className="author">{r.author}</span> | |
| <span className="muted">{timeAgo(r.ts)}</span> | |
| </div> | |
| <div className="reply-text">{r.text}</div> | |
| </div> | |
| ))} | |
| <div className="reply-compose"> | |
| <input | |
| className="reply-input one-line" | |
| placeholder="Reply…" | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && text.trim()) { | |
| onReply(thread.id, text.trim()) | |
| setText('') | |
| } | |
| }} | |
| /> | |
| </div> | |
| <div className="thread-actions"> | |
| <button className="btn-ghost" onClick={() => onResolve(thread.id, !thread.resolved)}> | |
| {thread.resolved ? 'Reopen' : 'Resolve'} | |
| </button> | |
| <button className="btn-ghost danger" onClick={() => onDelete(thread.id)}> | |
| Delete | |
| </button> | |
| <span className="spacer" /> | |
| <span className="muted you-tag">{me.name}</span> | |
| </div> | |
| </div> | |
| ) | |
| } | |