marp-share / client /src /comments /CommentsPanel.tsx
Nipun's picture
Deploy marp-share (M1–M5): realtime + persistence + HF auth + comments + export
43370b3 verified
Raw
History Blame Contribute Delete
5.95 kB
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>
)
}