From a71ee52d54dd0342c00bb50ef7fb31fe2b4aabcb Mon Sep 17 00:00:00 2001 From: dame-is Date: Thu, 22 Jan 2026 15:38:44 -0500 Subject: [PATCH] Add margin lexicon support in metadata generation and record previews. Introduce custom SVG for margin and enhance waypoint handling for margin-related records, including URL generation and recommendations for various margin types. --- src/app/[handle]/[collection]/[rkey]/page.tsx | 141 +++++- .../margin/MarginAnnotationPreview.tsx | 448 ++++++++++++++++++ .../margin/MarginBookmarkPreview.tsx | 375 +++++++++++++++ .../margin/MarginCollectionItemPreview.tsx | 364 ++++++++++++++ .../margin/MarginCollectionPreview.tsx | 338 +++++++++++++ .../margin/MarginHighlightPreview.tsx | 414 ++++++++++++++++ src/components/margin/MarginLikePreview.tsx | 330 +++++++++++++ src/components/margin/MarginReplyPreview.tsx | 339 +++++++++++++ src/components/margin/index.ts | 12 + src/utils/marginLexicons.ts | 109 +++++ src/utils/waypoints.tsx | 88 ++++ 11 files changed, 2952 insertions(+), 6 deletions(-) create mode 100644 src/components/margin/MarginAnnotationPreview.tsx create mode 100644 src/components/margin/MarginBookmarkPreview.tsx create mode 100644 src/components/margin/MarginCollectionItemPreview.tsx create mode 100644 src/components/margin/MarginCollectionPreview.tsx create mode 100644 src/components/margin/MarginHighlightPreview.tsx create mode 100644 src/components/margin/MarginLikePreview.tsx create mode 100644 src/components/margin/MarginReplyPreview.tsx create mode 100644 src/components/margin/index.ts create mode 100644 src/utils/marginLexicons.ts diff --git a/src/app/[handle]/[collection]/[rkey]/page.tsx b/src/app/[handle]/[collection]/[rkey]/page.tsx index a06ba89..a789f06 100644 --- a/src/app/[handle]/[collection]/[rkey]/page.tsx +++ b/src/app/[handle]/[collection]/[rkey]/page.tsx @@ -10,6 +10,16 @@ import Header from '@/components/Header'; import { parseURI, resolveHandle, getDisplayName } from '@/utils/uriParser'; import { fetchRecordData } from '@/utils/recordFetcher'; import { resolveDidToHandle } from '@/utils/didResolver'; +import { getMarginLexiconType, getMarginLexiconDisplayName, getMarginLexiconDescription } from '@/utils/marginLexicons'; +import { + MarginAnnotationPreview, + MarginBookmarkPreview, + MarginHighlightPreview, + MarginCollectionPreview, + MarginCollectionItemPreview, + MarginReplyPreview, + MarginLikePreview, +} from '@/components/margin'; type Props = { params: Promise<{ handle: string; collection: string; rkey: string }>; @@ -61,6 +71,7 @@ export async function generateMetadata({ params }: Props): Promise { ogImageUrl = ogUrl.toString(); } else if (recordData.type === 'record') { const record = recordData.data; + const marginLexiconType = getMarginLexiconType(collection); if (collection === 'app.bsky.graph.list' || collection.endsWith('.list')) { title = record.value?.name @@ -74,6 +85,59 @@ export async function generateMetadata({ params }: Props): Promise { ogUrl.searchParams.set('handle', resolvedDid); ogUrl.searchParams.set('rkey', rkey); ogImageUrl = ogUrl.toString(); + } else if (marginLexiconType) { + // Custom metadata for margin lexicons + const lexiconDisplayName = getMarginLexiconDisplayName(collection); + const lexiconDescription = getMarginLexiconDescription(collection); + + switch (marginLexiconType) { + case 'at.margin.annotation': + title = record.value?.target?.title + ? `Annotation on "${record.value.target.title}" by @${displayHandle}` + : `Annotation by @${displayHandle} — View on Aturi`; + description = record.value?.body?.value + ? record.value.body.value.slice(0, 160) + : lexiconDescription; + break; + case 'at.margin.bookmark': + title = record.value?.title + ? `Bookmark: ${record.value.title} by @${displayHandle}` + : `Bookmark by @${displayHandle} — View on Aturi`; + description = record.value?.description + ? record.value.description.slice(0, 160) + : record.value?.source || lexiconDescription; + break; + case 'at.margin.highlight': + title = record.value?.target?.title + ? `Highlight on "${record.value.target.title}" by @${displayHandle}` + : `Highlight by @${displayHandle} — View on Aturi`; + description = record.value?.target?.selector?.exact + ? record.value.target.selector.exact.slice(0, 160) + : lexiconDescription; + break; + case 'at.margin.collection': + title = record.value?.name + ? `${record.value.name} — Margin Collection by @${displayHandle}` + : `Margin Collection by @${displayHandle}`; + description = record.value?.description + ? record.value.description.slice(0, 160) + : lexiconDescription; + break; + case 'at.margin.reply': + title = `Reply by @${displayHandle} — View on Aturi`; + description = record.value?.text + ? record.value.text.slice(0, 160) + : lexiconDescription; + break; + case 'at.margin.like': + case 'at.margin.collectionItem': + title = `${lexiconDisplayName} by @${displayHandle} — View on Aturi`; + description = lexiconDescription; + break; + default: + title = `${lexiconDisplayName} by @${displayHandle} — View on Aturi`; + description = lexiconDescription; + } } else { // Generic record type const collectionName = collection.split('.').pop() || collection; @@ -158,6 +222,9 @@ async function RecordContent({ handle, collection, rkey }: { handle: string; col ); } + // Determine if this is a margin lexicon with custom preview + const marginLexiconType = getMarginLexiconType(collection); + return (
@@ -171,12 +238,74 @@ async function RecordContent({ handle, collection, rkey }: { handle: string; col /> )} {recordData.type === 'record' && ( - + <> + {/* Render custom margin preview if available */} + {marginLexiconType === 'at.margin.annotation' && ( + + )} + {marginLexiconType === 'at.margin.bookmark' && ( + + )} + {marginLexiconType === 'at.margin.highlight' && ( + + )} + {marginLexiconType === 'at.margin.collection' && ( + + )} + {marginLexiconType === 'at.margin.collectionItem' && ( + + )} + {marginLexiconType === 'at.margin.reply' && ( + + )} + {marginLexiconType === 'at.margin.like' && ( + + )} + {/* Fall back to generic record preview if not a margin lexicon */} + {!marginLexiconType && ( + + )} + )}
)} diff --git a/src/components/margin/MarginAnnotationPreview.tsx b/src/components/margin/MarginAnnotationPreview.tsx new file mode 100644 index 0000000..0873f9f --- /dev/null +++ b/src/components/margin/MarginAnnotationPreview.tsx @@ -0,0 +1,448 @@ +/** + * MarginAnnotationPreview Component + * Custom preview for at.margin.annotation records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { sanitizeHandle } from '@/utils/sanitize'; +import { X, ExternalLink, Tag, Calendar } from 'lucide-react'; + +type MarginAnnotationPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type AnnotationRecord = { + $type: string; + target: { + source: string; + title?: string; + sourceHash?: string; + selector?: any; + }; + body?: { + value?: string; + format?: string; + language?: string; + }; + tags?: string[]; + motivation?: string; + createdAt: string; +}; + +export default function MarginAnnotationPreview({ + record, + collection, + handle, + rkey, +}: MarginAnnotationPreviewProps) { + const { value, cid } = record; + const annotation = value as AnnotationRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(annotation.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + // Extract selected text if available + const selectedText = annotation.target.selector?.exact; + + return ( + <> +
+ {/* Header */} +
+
+
+ Annotation +
+ {annotation.motivation && ( +
+ {annotation.motivation} +
+ )} +
+ + {/* Page Title */} + {annotation.target.title && ( +

+ {annotation.target.title} +

+ )} + + {/* Source URL */} + { + e.currentTarget.style.opacity = '0.7'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '1'; + }} + > + + {annotation.target.source} + +
+ + {/* Selected Text (if available) */} + {selectedText && ( +
+
+ Selected Text +
+
+ {selectedText} +
+
+ )} + + {/* Annotation Body */} + {annotation.body?.value && ( +
+
+ Note +
+
+ {annotation.body.value} +
+
+ )} + + {/* Tags */} + {annotation.tags && annotation.tags.length > 0 && ( +
+
+ + {annotation.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata Footer */} +
+
+ + + {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Annotation Data +
+
+ at.margin.annotation +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginBookmarkPreview.tsx b/src/components/margin/MarginBookmarkPreview.tsx new file mode 100644 index 0000000..714c9f0 --- /dev/null +++ b/src/components/margin/MarginBookmarkPreview.tsx @@ -0,0 +1,375 @@ +/** + * MarginBookmarkPreview Component + * Custom preview for at.margin.bookmark records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, Bookmark, ExternalLink, Tag, Calendar } from 'lucide-react'; + +type MarginBookmarkPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type BookmarkRecord = { + $type: string; + source: string; + title?: string; + description?: string; + tags?: string[]; + sourceHash?: string; + createdAt: string; +}; + +export default function MarginBookmarkPreview({ + record, + collection, + handle, + rkey, +}: MarginBookmarkPreviewProps) { + const { value, cid } = record; + const bookmark = value as BookmarkRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(bookmark.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + return ( + <> +
+ {/* Header */} +
+
+ +
+ Bookmark +
+
+ + {/* Page Title */} + {bookmark.title && ( +

+ {bookmark.title} +

+ )} + + {/* Description */} + {bookmark.description && ( +

+ {bookmark.description} +

+ )} + + {/* Source URL */} + { + e.currentTarget.style.opacity = '0.7'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '1'; + }} + > + + {bookmark.source} + +
+ + {/* Tags */} + {bookmark.tags && bookmark.tags.length > 0 && ( +
+
+ + {bookmark.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata Footer */} +
+
+ + + Bookmarked {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Bookmark Data +
+
+ at.margin.bookmark +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginCollectionItemPreview.tsx b/src/components/margin/MarginCollectionItemPreview.tsx new file mode 100644 index 0000000..b95e1ef --- /dev/null +++ b/src/components/margin/MarginCollectionItemPreview.tsx @@ -0,0 +1,364 @@ +/** + * MarginCollectionItemPreview Component + * Custom preview for at.margin.collectionItem records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, Link2, Calendar } from 'lucide-react'; + +type MarginCollectionItemPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type CollectionItemRecord = { + $type: string; + collection: string; + annotation: string; + position?: number; + createdAt: string; +}; + +export default function MarginCollectionItemPreview({ + record, + collection, + handle, + rkey, +}: MarginCollectionItemPreviewProps) { + const { value, cid } = record; + const item = value as CollectionItemRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(item.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + return ( + <> +
+ {/* Header */} +
+ + +
+ Collection Item +
+ +
+ Links an annotation to a collection +
+ + {typeof item.position !== 'undefined' && ( +
+ Position: {item.position} +
+ )} +
+ + {/* Collection Reference */} +
+
+ Collection: +
+
+ {item.collection} +
+
+ + {/* Annotation Reference */} +
+
+ Annotation: +
+
+ {item.annotation} +
+
+ + {/* Metadata Footer */} +
+
+ + + {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Collection Item Data +
+
+ at.margin.collectionItem +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginCollectionPreview.tsx b/src/components/margin/MarginCollectionPreview.tsx new file mode 100644 index 0000000..b39c662 --- /dev/null +++ b/src/components/margin/MarginCollectionPreview.tsx @@ -0,0 +1,338 @@ +/** + * MarginCollectionPreview Component + * Custom preview for at.margin.collection records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, FolderOpen, Calendar } from 'lucide-react'; + +type MarginCollectionPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type CollectionRecord = { + $type: string; + name: string; + description?: string; + icon?: string; + createdAt: string; +}; + +export default function MarginCollectionPreview({ + record, + collection, + handle, + rkey, +}: MarginCollectionPreviewProps) { + const { value, cid } = record; + const marginCollection = value as CollectionRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(marginCollection.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + return ( + <> +
+ {/* Header */} +
+ {marginCollection.icon && ( +
+ {marginCollection.icon} +
+ )} + {!marginCollection.icon && ( + + )} + +
+ Collection +
+ +

+ {marginCollection.name} +

+ + {marginCollection.description && ( +

+ {marginCollection.description} +

+ )} +
+ + {/* Metadata Footer */} +
+
+ + + Created {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Collection Data +
+
+ at.margin.collection +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginHighlightPreview.tsx b/src/components/margin/MarginHighlightPreview.tsx new file mode 100644 index 0000000..c787acc --- /dev/null +++ b/src/components/margin/MarginHighlightPreview.tsx @@ -0,0 +1,414 @@ +/** + * MarginHighlightPreview Component + * Custom preview for at.margin.highlight records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, Highlighter, ExternalLink, Tag, Calendar } from 'lucide-react'; + +type MarginHighlightPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type HighlightRecord = { + $type: string; + target: { + source: string; + title?: string; + sourceHash?: string; + selector?: any; + }; + color?: string; + tags?: string[]; + createdAt: string; +}; + +export default function MarginHighlightPreview({ + record, + collection, + handle, + rkey, +}: MarginHighlightPreviewProps) { + const { value, cid } = record; + const highlight = value as HighlightRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(highlight.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + const selectedText = highlight.target.selector?.exact; + const highlightColor = highlight.color || '#fde047'; + + return ( + <> +
+ {/* Header */} +
+
+ +
+ Highlight +
+ {highlight.color && ( +
+ )} +
+ + {/* Page Title */} + {highlight.target.title && ( +

+ {highlight.target.title} +

+ )} + + {/* Source URL */} + { + e.currentTarget.style.opacity = '0.7'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '1'; + }} + > + + {highlight.target.source} + +
+ + {/* Highlighted Text */} + {selectedText && ( +
+
+ Highlighted Text +
+
+ {selectedText} +
+
+ )} + + {/* Tags */} + {highlight.tags && highlight.tags.length > 0 && ( +
+
+ + {highlight.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+ )} + + {/* Metadata Footer */} +
+
+ + + {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Highlight Data +
+
+ at.margin.highlight +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginLikePreview.tsx b/src/components/margin/MarginLikePreview.tsx new file mode 100644 index 0000000..e39c5b3 --- /dev/null +++ b/src/components/margin/MarginLikePreview.tsx @@ -0,0 +1,330 @@ +/** + * MarginLikePreview Component + * Custom preview for at.margin.like records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, Heart, Calendar } from 'lucide-react'; + +type MarginLikePreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type LikeRecord = { + $type: string; + subject: { + uri: string; + cid: string; + }; + createdAt: string; +}; + +export default function MarginLikePreview({ + record, + collection, + handle, + rkey, +}: MarginLikePreviewProps) { + const { value, cid } = record; + const like = value as LikeRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(like.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + return ( + <> +
+ {/* Header */} +
+ + +
+ Like +
+ +
+ Liked an annotation or reply +
+
+ + {/* Subject Reference */} +
+
+ Subject: +
+
+ {like.subject.uri} +
+
+ + {/* Metadata Footer */} +
+
+ + + {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Like Data +
+
+ at.margin.like +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/MarginReplyPreview.tsx b/src/components/margin/MarginReplyPreview.tsx new file mode 100644 index 0000000..163578a --- /dev/null +++ b/src/components/margin/MarginReplyPreview.tsx @@ -0,0 +1,339 @@ +/** + * MarginReplyPreview Component + * Custom preview for at.margin.reply records + */ + +'use client'; + +import { useState } from 'react'; +import { GenericRecord } from '@/utils/recordFetcher'; +import { X, MessageCircle, Calendar } from 'lucide-react'; + +type MarginReplyPreviewProps = { + record: GenericRecord; + collection: string; + handle: string; + rkey: string; +}; + +type ReplyRecord = { + $type: string; + text: string; + format?: string; + parent: { + uri: string; + cid: string; + }; + root: { + uri: string; + cid: string; + }; + createdAt: string; +}; + +export default function MarginReplyPreview({ + record, + collection, + handle, + rkey, +}: MarginReplyPreviewProps) { + const { value, cid } = record; + const reply = value as ReplyRecord; + const [showJsonModal, setShowJsonModal] = useState(false); + + const createdAt = new Date(reply.createdAt); + const formattedDate = createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + + return ( + <> +
+ {/* Header */} +
+
+ +
+ Reply +
+
+
+ + {/* Reply Text */} +
+
+ {reply.text} +
+
+ + {/* Parent/Root References */} +
+
+ In reply to: +
+
+ {reply.parent.uri} +
+
+ + {/* Metadata Footer */} +
+
+ + + {formattedDate} + +
+ + +
+ + {/* AT URI Footer */} +
+ at:// + {handle}/{collection}/{rkey} +
+
+ + {/* Full JSON Modal */} + {showJsonModal && ( +
setShowJsonModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ Raw Reply Data +
+
+ at.margin.reply +
+
+ +
+ +
+
+                {JSON.stringify(value, null, 2)}
+              
+
+
+
+ )} + + + + ); +} diff --git a/src/components/margin/index.ts b/src/components/margin/index.ts new file mode 100644 index 0000000..8067343 --- /dev/null +++ b/src/components/margin/index.ts @@ -0,0 +1,12 @@ +/** + * Margin Preview Components + * Custom preview components for at.margin.* lexicons + */ + +export { default as MarginAnnotationPreview } from './MarginAnnotationPreview'; +export { default as MarginBookmarkPreview } from './MarginBookmarkPreview'; +export { default as MarginHighlightPreview } from './MarginHighlightPreview'; +export { default as MarginCollectionPreview } from './MarginCollectionPreview'; +export { default as MarginCollectionItemPreview } from './MarginCollectionItemPreview'; +export { default as MarginReplyPreview } from './MarginReplyPreview'; +export { default as MarginLikePreview } from './MarginLikePreview'; diff --git a/src/utils/marginLexicons.ts b/src/utils/marginLexicons.ts new file mode 100644 index 0000000..4a5a869 --- /dev/null +++ b/src/utils/marginLexicons.ts @@ -0,0 +1,109 @@ +/** + * Margin Lexicon Utilities + * Helper functions for detecting and working with at.margin.* records + */ + +export type MarginLexiconType = + | 'at.margin.annotation' + | 'at.margin.bookmark' + | 'at.margin.highlight' + | 'at.margin.collection' + | 'at.margin.collectionItem' + | 'at.margin.reply' + | 'at.margin.like' + | null; + +/** + * Check if a collection is a margin lexicon + */ +export function isMarginLexicon(collection: string): boolean { + return collection.startsWith('at.margin.'); +} + +/** + * Get the specific margin lexicon type from a collection string + */ +export function getMarginLexiconType(collection: string): MarginLexiconType { + if (!isMarginLexicon(collection)) { + return null; + } + + // Exact matches for supported margin lexicons + switch (collection) { + case 'at.margin.annotation': + return 'at.margin.annotation'; + case 'at.margin.bookmark': + return 'at.margin.bookmark'; + case 'at.margin.highlight': + return 'at.margin.highlight'; + case 'at.margin.collection': + return 'at.margin.collection'; + case 'at.margin.collectionItem': + return 'at.margin.collectionItem'; + case 'at.margin.reply': + return 'at.margin.reply'; + case 'at.margin.like': + return 'at.margin.like'; + default: + return null; + } +} + +/** + * Check if the lexicon type has a custom preview component + */ +export function hasCustomMarginPreview(collection: string): boolean { + return getMarginLexiconType(collection) !== null; +} + +/** + * Get a human-readable display name for a margin lexicon + */ +export function getMarginLexiconDisplayName(collection: string): string { + const type = getMarginLexiconType(collection); + + switch (type) { + case 'at.margin.annotation': + return 'Annotation'; + case 'at.margin.bookmark': + return 'Bookmark'; + case 'at.margin.highlight': + return 'Highlight'; + case 'at.margin.collection': + return 'Collection'; + case 'at.margin.collectionItem': + return 'Collection Item'; + case 'at.margin.reply': + return 'Reply'; + case 'at.margin.like': + return 'Like'; + default: + return collection.replace('at.margin.', ''); + } +} + +/** + * Get a short description for a margin lexicon type + */ +export function getMarginLexiconDescription(collection: string): string { + const type = getMarginLexiconType(collection); + + switch (type) { + case 'at.margin.annotation': + return 'Annotate and comment on web content'; + case 'at.margin.bookmark': + return 'Bookmarked webpage'; + case 'at.margin.highlight': + return 'Highlighted text from a webpage'; + case 'at.margin.collection': + return 'Collection of annotations and bookmarks'; + case 'at.margin.collectionItem': + return 'Item in a collection'; + case 'at.margin.reply': + return 'Reply to an annotation'; + case 'at.margin.like': + return 'Like on an annotation or reply'; + default: + return 'Margin record'; + } +} diff --git a/src/utils/waypoints.tsx b/src/utils/waypoints.tsx index 757d83e..7e71d6b 100644 --- a/src/utils/waypoints.tsx +++ b/src/utils/waypoints.tsx @@ -108,6 +108,13 @@ export const PinskySVG = () => ( ); +export const MarginSVG = () => ( + + + + +); + export const WAYPOINT_DESTINATIONS: Record = { anisota: { id: 'anisota', @@ -363,6 +370,44 @@ export const WAYPOINT_DESTINATIONS: Record = { supportedTypes: ['post', 'profile', 'list'], category: 'atmosphereApps', }, + + margin: { + id: 'margin', + name: 'Margin', + description: 'View on margin.at', + icon: , + getUrl: (handle, collection, rkey, did) => { + // Margin supports at.margin.* records + if (collection && rkey && collection.startsWith('at.margin.')) { + // Extract the record type (annotation, highlight, bookmark, etc.) + const recordType = collection.replace('at.margin.', ''); + + // For annotations and highlights, they use domain/type/rkey format + // For profiles, they use profile/did format + // Use DID if available, otherwise handle + const identifier = did || handle; + + if (recordType === 'annotation' || recordType === 'highlight' || recordType === 'bookmark') { + // Try to extract domain from handle if it looks like a domain + // e.g., isaaccorbrey.com becomes isaaccorbrey.com + // Otherwise use the identifier as-is + const handleLooksLikeDomain = handle.includes('.') && !handle.startsWith('did:'); + const domain = handleLooksLikeDomain ? handle : identifier; + + return `https://margin.at/${domain}/${recordType}/${rkey}`; + } + + // For other margin types, fall back to profile + return `https://margin.at/profile/${identifier}`; + } + + // Profile view - use DID if available + const identifier = did || handle; + return `https://margin.at/profile/${identifier}`; + }, + supportedTypes: ['profile', 'record'], + category: 'atmosphereApps', + }, }; export const WAYPOINT_ORDER = [ @@ -373,6 +418,7 @@ export const WAYPOINT_ORDER = [ 'reddwarf', 'leaflet', 'pinksky', + 'margin', 'pdsls', 'anisotaExplorer', 'smokesignal', @@ -500,6 +546,48 @@ const RECOMMENDED_WAYPOINTS: Record = { label: 'Recommended for Repos', }, + // Margin annotations + 'at.margin.annotation': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Annotations', + }, + + // Margin bookmarks + 'at.margin.bookmark': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Bookmarks', + }, + + // Margin highlights + 'at.margin.highlight': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Highlights', + }, + + // Margin collections + 'at.margin.collection': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Collections', + }, + + // Margin collection items + 'at.margin.collectionItem': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Collection Items', + }, + + // Margin replies + 'at.margin.reply': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Replies', + }, + + // Margin likes + 'at.margin.like': { + waypointIds: ['margin', 'pdsls', 'atptools'], + label: 'Recommended for Likes', + }, + // Generic records - use pdsls for raw data, then atp.tools, then Anisota Explorer for exploring 'record': { waypointIds: ['pdsls', 'atptools', 'anisotaExplorer'], -- 2.51.2