(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
20 kB · 674 lines
JSX
at ui-refactor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675import { useState, useEffect } from "react";import { useAuth } from "../context/AuthContext";import ReplyList from "./ReplyList";import { Link } from "react-router-dom";import RichText from "./RichText";import { normalizeAnnotation, normalizeHighlight, likeAnnotation, unlikeAnnotation, getReplies, createReply, deleteReply, updateAnnotation, updateHighlight, getEditHistory, deleteAnnotation,} from "../api/client";import { MessageSquare, Heart, Trash2, Folder, Edit2, Save, X, Clock,} from "lucide-react";import { HighlightIcon, TrashIcon } from "./Icons";import ShareMenu from "./ShareMenu";import UserMeta from "./UserMeta";
function buildTextFragmentUrl(baseUrl, selector) { if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { return baseUrl; } let fragment = ":~:text="; if (selector.prefix) { fragment += encodeURIComponent(selector.prefix) + "-,"; } fragment += encodeURIComponent(selector.exact); if (selector.suffix) { fragment += ",-" + encodeURIComponent(selector.suffix); } return baseUrl + "#" + fragment;}
const truncateUrl = (url, maxLength = 50) => { if (!url) return ""; try { const parsed = new URL(url); const fullPath = parsed.host + parsed.pathname; if (fullPath.length > maxLength) return fullPath.substring(0, maxLength) + "..."; return fullPath; } catch { return url.length > maxLength ? url.substring(0, maxLength) + "..." : url; }};
function SembleBadge() { return ( <div className="semble-badge" title="Added using Semble"> <span>via Semble</span> <img src="/semble-logo.svg" alt="Semble" /> </div> );}
export default function AnnotationCard({ annotation, onDelete, onAddToCollection,}) { const { user, login } = useAuth(); const data = normalizeAnnotation(annotation);
const [likeCount, setLikeCount] = useState(data.likeCount || 0); const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); const [deleting, setDeleting] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(data.text || ""); const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); const [saving, setSaving] = useState(false); const [showHistory, setShowHistory] = useState(false); const [editHistory, setEditHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); const [replies, setReplies] = useState([]); const [replyCount, setReplyCount] = useState(data.replyCount || 0); const [showReplies, setShowReplies] = useState(false); const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(""); const [posting, setPosting] = useState(false); const [hasEditHistory, setHasEditHistory] = useState(false);
const isOwner = user?.did && data.author?.did === user.did; const isSemble = data.uri?.includes("network.cosmik"); const highlightedText = data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
useEffect(() => { if (data.uri && !data.color && !data.description) { getEditHistory(data.uri) .then((history) => { if (history?.length > 0) setHasEditHistory(true); }) .catch(() => {}); } }, [data.uri, data.color, data.description]);
const fetchHistory = async () => { if (showHistory) { setShowHistory(false); return; } try { setLoadingHistory(true); setShowHistory(true); const history = await getEditHistory(data.uri); setEditHistory(history); } catch (err) { console.error("Failed to fetch history:", err); } finally { setLoadingHistory(false); } };
const handlePostReply = async (parentReply) => { if (!replyText.trim()) return; try { setPosting(true); const parentUri = parentReply ? parentReply.id || parentReply.uri : data.uri; const parentCid = parentReply ? parentReply.cid : annotation.cid || data.cid;
await createReply({ parentUri, parentCid: parentCid || "", rootUri: data.uri, rootCid: annotation.cid || data.cid || "", text: replyText, });
setReplyText(""); setReplyingTo(null);
const res = await getReplies(data.uri); if (res.items) { setReplies(res.items); setReplyCount(res.items.length); } } catch (err) { alert("Failed to post reply: " + err.message); } finally { setPosting(false); } };
const handleSaveEdit = async () => { try { setSaving(true); const tagList = editTags .split(",") .map((t) => t.trim()) .filter(Boolean); await updateAnnotation(data.uri, editText, tagList); setIsEditing(false); if (annotation.body) annotation.body.value = editText; else if (annotation.text) annotation.text = editText; if (annotation.tags) annotation.tags = tagList; data.tags = tagList; } catch (err) { alert("Failed to update: " + err.message); } finally { setSaving(false); } };
const handleLike = async () => { if (!user) { login(); return; } try { if (isLiked) { setIsLiked(false); setLikeCount((prev) => Math.max(0, prev - 1)); await unlikeAnnotation(data.uri); } else { setIsLiked(true); setLikeCount((prev) => prev + 1); const cid = annotation.cid || data.cid || ""; if (data.uri && cid) await likeAnnotation(data.uri, cid); } } catch { setIsLiked(!isLiked); setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); } };
const handleDelete = async () => { if (!confirm("Delete this annotation? This cannot be undone.")) return; try { setDeleting(true); const parts = data.uri.split("/"); const rkey = parts[parts.length - 1]; await deleteAnnotation(rkey); if (onDelete) onDelete(data.uri); else window.location.reload(); } catch (err) { alert("Failed to delete: " + err.message); } finally { setDeleting(false); } };
const loadReplies = async () => { if (!showReplies && replies.length === 0) { try { const res = await getReplies(data.uri); if (res.items) setReplies(res.items); } catch (err) { console.error("Failed to load replies:", err); } } setShowReplies(!showReplies); };
const handleCollect = () => { if (!user) { login(); return; } if (onAddToCollection) onAddToCollection(); };
return ( <article className="card annotation-card"> <header className="annotation-header"> <div className="annotation-header-left"> <UserMeta author={data.author} createdAt={data.createdAt} /> </div> <div className="annotation-header-right"> {isSemble && <SembleBadge />} {hasEditHistory && !data.color && !data.description && ( <button className="annotation-action action-icon-only" onClick={fetchHistory} title="View Edit History" > <Clock size={16} /> </button> )} {isOwner && !isSemble && ( <> {!data.color && !data.description && ( <button className="annotation-action action-icon-only" onClick={() => setIsEditing(!isEditing)} title="Edit" > <Edit2 size={16} /> </button> )} <button className="annotation-action action-icon-only" onClick={handleDelete} disabled={deleting} title="Delete" > <Trash2 size={16} /> </button> </> )} </div> </header>
{showHistory && ( <div className="history-panel"> <div className="history-header"> <h4 className="history-title">Edit History</h4> <button className="annotation-action action-icon-only" onClick={() => setShowHistory(false)} > <X size={14} /> </button> </div> {loadingHistory ? ( <div className="history-status">Loading history...</div> ) : editHistory.length === 0 ? ( <div className="history-status">No edit history found.</div> ) : ( <ul className="history-list"> {editHistory.map((edit) => ( <li key={edit.id} className="history-item"> <div className="history-date"> {new Date(edit.editedAt).toLocaleString()} </div> <div className="history-content">{edit.previousContent}</div> </li> ))} </ul> )} </div> )}
<div className="annotation-content"> <a href={data.url} target="_blank" rel="noopener noreferrer" className="annotation-source" > {truncateUrl(data.url)} {data.title && ( <span className="annotation-source-title"> · {data.title}</span> )} </a>
{highlightedText && ( <a href={fragmentUrl} target="_blank" rel="noopener noreferrer" className="annotation-highlight" style={{ borderLeftColor: data.color || "var(--accent)" }} > <mark>“{highlightedText}”</mark> </a> )}
{isEditing ? ( <div className="edit-form"> <textarea value={editText} onChange={(e) => setEditText(e.target.value)} className="reply-input" rows={3} placeholder="Your annotation..." /> <input type="text" className="reply-input" placeholder="Tags (comma separated)..." value={editTags} onChange={(e) => setEditTags(e.target.value)} style={{ marginTop: "8px" }} /> <div className="action-buttons-end" style={{ marginTop: "8px" }}> <button onClick={() => setIsEditing(false)} className="btn btn-ghost" > Cancel </button> <button onClick={handleSaveEdit} disabled={saving} className="btn btn-primary" > {saving ? ( "Saving..." ) : ( <> <Save size={14} /> Save </> )} </button> </div> </div> ) : ( <RichText text={data.text} facets={data.facets} /> )}
{data.tags?.length > 0 && ( <div className="annotation-tags"> {data.tags.map((tag, i) => ( <Link key={i} to={`/?tag=${encodeURIComponent(tag)}`} className="annotation-tag" > #{tag} </Link> ))} </div> )} </div>
<footer className="annotation-actions"> <div className="annotation-actions-left"> <button className={`annotation-action ${isLiked ? "liked" : ""}`} onClick={handleLike} > <Heart size={16} fill={isLiked ? "currentColor" : "none"} /> {likeCount > 0 && <span>{likeCount}</span>} </button>
<button className={`annotation-action ${showReplies ? "active" : ""}`} onClick={loadReplies} > <MessageSquare size={16} /> <span>{replyCount > 0 ? replyCount : "Reply"}</span> </button>
<ShareMenu uri={data.uri} text={data.title || data.url} handle={data.author?.handle} type="Annotation" url={data.url} />
<button className="annotation-action" onClick={handleCollect}> <Folder size={16} /> <span>Collect</span> </button> </div> </footer>
{showReplies && ( <div className="inline-replies"> <ReplyList replies={replies} rootUri={data.uri} user={user} onReply={(reply) => setReplyingTo(reply)} onDelete={async (reply) => { if (!confirm("Delete this reply?")) return; try { await deleteReply(reply.id || reply.uri); const res = await getReplies(data.uri); if (res.items) { setReplies(res.items); setReplyCount(res.items.length); } } catch (err) { alert("Failed to delete: " + err.message); } }} isInline={true} />
<div className="reply-form"> {replyingTo && ( <div className="replying-to-banner"> <span> Replying to @ {(replyingTo.creator || replyingTo.author)?.handle || "unknown"} </span> <button onClick={() => setReplyingTo(null)} className="cancel-reply" > × </button> </div> )} <textarea className="reply-input" placeholder={ replyingTo ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` : "Write a reply..." } value={replyText} onChange={(e) => setReplyText(e.target.value)} rows={2} /> <div className="reply-form-actions"> <button className="btn btn-primary" disabled={posting || !replyText.trim()} onClick={() => { if (!user) { login(); return; } handlePostReply(replyingTo); }} > {posting ? "Posting..." : "Reply"} </button> </div> </div> </div> )} </article> );}
export function HighlightCard({ highlight, onDelete, onAddToCollection, onUpdate,}) { const { user, login } = useAuth(); const data = normalizeHighlight(highlight); const highlightedText = data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); const isOwner = user?.did && data.author?.did === user.did; const isSemble = data.uri?.includes("network.cosmik");
const [isEditing, setIsEditing] = useState(false); const [editColor, setEditColor] = useState(data.color || "#f59e0b"); const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
const handleSaveEdit = async () => { try { const tagList = editTags .split(",") .map((t) => t.trim()) .filter(Boolean); await updateHighlight(data.uri, editColor, tagList); setIsEditing(false); if (typeof onUpdate === "function") { onUpdate({ ...highlight, color: editColor, tags: tagList }); } } catch (err) { alert("Failed to update: " + err.message); } };
const handleCollect = () => { if (!user) { login(); return; } if (onAddToCollection) onAddToCollection(); };
return ( <article className="card annotation-card"> <header className="annotation-header"> <div className="annotation-header-left"> <UserMeta author={data.author} createdAt={data.createdAt} /> </div> <div className="annotation-header-right"> {isSemble && ( <div className="semble-badge" title="Added using Semble"> <span>via Semble</span> <img src="/semble-logo.svg" alt="Semble" /> </div> )} {isOwner && ( <> <button className="annotation-action action-icon-only" onClick={() => setIsEditing(!isEditing)} title="Edit Color" > <Edit2 size={16} /> </button> <button className="annotation-action action-icon-only" onClick={(e) => { e.preventDefault(); onDelete && onDelete(highlight.id || highlight.uri); }} title="Delete" > <TrashIcon size={16} /> </button> </> )} </div> </header>
<div className="annotation-content"> <a href={data.url} target="_blank" rel="noopener noreferrer" className="annotation-source" > {truncateUrl(data.url)} </a>
{highlightedText && ( <a href={fragmentUrl} target="_blank" rel="noopener noreferrer" className="annotation-highlight" style={{ borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", }} > <mark>“{highlightedText}”</mark> </a> )}
{isEditing && ( <div className="color-edit-form"> <div className="color-picker-wrapper"> <div className="color-preview" style={{ backgroundColor: editColor }} /> <input type="color" value={editColor} onChange={(e) => setEditColor(e.target.value)} className="color-input" /> </div> <input type="text" className="reply-input" placeholder="Tags (comma separated)" value={editTags} onChange={(e) => setEditTags(e.target.value)} style={{ flex: 1, margin: 0 }} /> <button onClick={handleSaveEdit} className="btn btn-primary" style={{ padding: "0 12px", height: "32px" }} > <Save size={16} /> </button> </div> )}
{data.tags?.length > 0 && ( <div className="annotation-tags"> {data.tags.map((tag, i) => ( <Link key={i} to={`/?tag=${encodeURIComponent(tag)}`} className="annotation-tag" > #{tag} </Link> ))} </div> )} </div>
<footer className="annotation-actions"> <div className="annotation-actions-left"> <span className="annotation-action" style={{ color: data.color || "#f59e0b", cursor: "default" }} > <HighlightIcon size={14} /> Highlight </span>
<ShareMenu uri={data.uri} text={data.title || data.description} handle={data.author?.handle} type="Highlight" />
<button className="annotation-action" onClick={handleCollect}> <Folder size={16} /> <span>Collect</span> </button> </div> </footer> </article> );}