mirror of
https://github.com/dergigi/boris.git
synced 2026-02-07 16:14:39 +01:00
feat: add simple highlight creation feature
- Create HighlightButton component that appears on text selection - Add highlightCreationService using EventFactory and HighlightBlueprint - Integrate highlight button into ContentPanel with text selection detection - Update Bookmarks to pass required props and refresh highlights after creation - Publish highlights to NIP-84 relays automatically - Only show button when user is logged in
This commit is contained in:
3
.cursor/rules/app-settings-and-nostr.mdc
Normal file
3
.cursor/rules/app-settings-and-nostr.mdc
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index-8PiwZoBK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
|
||||
<script type="module" crossorigin src="/assets/index-CDSPBMvO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -42,6 +42,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const [currentArticle, setCurrentArticle] = useState<any>(undefined) // Store the current article event
|
||||
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: true,
|
||||
friends: true,
|
||||
@@ -71,7 +72,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
@@ -177,12 +179,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setCurrentArticle(undefined) // Clear previous article
|
||||
setShowSettings(false)
|
||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const content = await loadContent(url, relayPool, bookmark)
|
||||
setReaderContent(content)
|
||||
|
||||
// If this is a Nostr article (kind:30023), we need to get the event for highlight creation
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
setCurrentArticle(bookmark)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch content:', err)
|
||||
} finally {
|
||||
@@ -190,6 +198,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlightCreated = async () => {
|
||||
// Refresh highlights after creating a new one
|
||||
if (!relayPool || !currentArticleCoordinate) return
|
||||
|
||||
try {
|
||||
const newHighlights = await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
)
|
||||
setHighlights(newHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh highlights:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
@@ -237,6 +261,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||
}}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
relayPool={relayPool || undefined}
|
||||
activeAccount={activeAccount || undefined}
|
||||
currentArticle={currentArticle}
|
||||
currentArticleCoordinate={currentArticleCoordinate}
|
||||
onHighlightCreated={handleHighlightCreated}
|
||||
onShowToast={(message, type) => {
|
||||
// Use existing toast mechanism
|
||||
if (type === 'success') {
|
||||
console.log('✅', message)
|
||||
} else {
|
||||
console.error('❌', message)
|
||||
}
|
||||
}}
|
||||
highlightVisibility={highlightVisibility}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
followedPubkeys={followedPubkeys}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
@@ -10,6 +10,11 @@ import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -27,6 +32,13 @@ interface ContentPanelProps {
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
// For highlight creation
|
||||
relayPool?: RelayPool
|
||||
activeAccount?: IAccount
|
||||
currentArticle?: NostrEvent | null
|
||||
currentArticleCoordinate?: string
|
||||
onHighlightCreated?: () => void
|
||||
onShowToast?: (message: string, type: 'success' | 'error') => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -44,11 +56,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set()
|
||||
followedPubkeys = new Set(),
|
||||
// For highlight creation
|
||||
relayPool,
|
||||
activeAccount,
|
||||
currentArticle,
|
||||
currentArticleCoordinate,
|
||||
onHighlightCreated,
|
||||
onShowToast
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
@@ -158,6 +178,59 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Handle text selection for highlight creation
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Only allow highlight creation if user is logged in
|
||||
if (!activeAccount || !relayPool) {
|
||||
highlightButtonRef.current?.hide()
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
highlightButtonRef.current?.hide()
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
highlightButtonRef.current?.updateSelection(text, range.cloneRange())
|
||||
} else {
|
||||
highlightButtonRef.current?.hide()
|
||||
}
|
||||
}, 10)
|
||||
}, [activeAccount, relayPool])
|
||||
|
||||
// Handle highlight creation
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
if (!activeAccount || !relayPool || !currentArticle) {
|
||||
onShowToast?.('Please log in to create highlights', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createHighlight(
|
||||
text,
|
||||
currentArticle,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
|
||||
onShowToast?.('Highlight created successfully!', 'success')
|
||||
highlightButtonRef.current?.hide()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
|
||||
// Trigger refresh of highlights
|
||||
onHighlightCreated?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
onShowToast?.('Failed to create highlight', 'error')
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, currentArticleCoordinate, onShowToast, onHighlightCreated])
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -202,12 +275,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
@@ -218,7 +293,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -226,6 +302,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<p>No readable content found for this URL.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAccount && relayPool && (
|
||||
<HighlightButton
|
||||
ref={highlightButtonRef}
|
||||
onHighlight={handleCreateHighlight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
88
src/components/HighlightButton.tsx
Normal file
88
src/components/HighlightButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useImperativeHandle, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface HighlightButtonProps {
|
||||
onHighlight: (text: string) => void
|
||||
}
|
||||
|
||||
export interface HighlightButtonRef {
|
||||
updateSelection: (text: string, range: Range) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightButtonProps>(
|
||||
({ onHighlight }, ref) => {
|
||||
const currentSelectionRef = useRef<string>('')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (currentSelectionRef.current) {
|
||||
onHighlight(currentSelectionRef.current)
|
||||
}
|
||||
},
|
||||
[onHighlight]
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Prevent the button from taking focus away from the text selection
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
// Expose methods to update selection and hide button
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateSelection: (text: string, range: Range) => {
|
||||
currentSelectionRef.current = text
|
||||
if (buttonRef.current) {
|
||||
const rect = range.getBoundingClientRect()
|
||||
buttonRef.current.style.display = 'flex'
|
||||
buttonRef.current.style.top = `${rect.bottom + window.scrollY + 8}px`
|
||||
buttonRef.current.style.left = `${rect.left + rect.width / 2 - 20}px`
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
currentSelectionRef.current = ''
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="highlight-create-button"
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--color-primary, #0066cc)',
|
||||
color: 'white',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
cursor: 'pointer',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
tabIndex={-1}
|
||||
aria-label="Create highlight"
|
||||
title="Create highlight"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HighlightButton.displayName = 'HighlightButton'
|
||||
|
||||
@@ -16,6 +16,7 @@ interface UseArticleLoaderProps {
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
setCurrentArticle?: (article: any) => void
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
@@ -28,7 +29,8 @@ export function useArticleLoader({
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
@@ -54,6 +56,7 @@ export function useArticleLoader({
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
67
src/services/highlightCreationService.ts
Normal file
67
src/services/highlightCreationService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
article: NostrEvent | null,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
if (!selectedText || !article) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
}
|
||||
|
||||
// Create EventFactory with the account as signer
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
// Parse article coordinate to get address pointer
|
||||
const addressPointer = parseArticleCoordinate(article)
|
||||
|
||||
// Create highlight event using the blueprint
|
||||
const highlightEvent = await factory.create(
|
||||
HighlightBlueprint,
|
||||
selectedText,
|
||||
addressPointer,
|
||||
comment ? { comment } : undefined
|
||||
)
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to relays
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.snort.social',
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
await relayPool.publish(relayUrls, signedEvent)
|
||||
|
||||
console.log('✅ Highlight published:', signedEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse article coordinate to create address pointer
|
||||
*/
|
||||
function parseArticleCoordinate(article: NostrEvent): AddressPointer {
|
||||
// Try to get identifier from article tags
|
||||
const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || ''
|
||||
|
||||
return {
|
||||
kind: article.kind,
|
||||
pubkey: article.pubkey,
|
||||
identifier,
|
||||
relays: [] // Optional relays hint
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user