Compare commits

..

10 Commits

Author SHA1 Message Date
Gigi
ad54f2aaa5 chore: bump version to 0.2.0 2025-10-05 23:16:50 +01:00
Gigi
a6ea97b731 fix: replace any types with proper NostrEvent types
- Replace any type with NostrEvent | undefined in Bookmarks component
- Replace any type with NostrEvent in useArticleLoader hook
- Remove incorrect bookmark-to-article assignment
- All linter warnings resolved
- Type checks passing
2025-10-05 23:16:05 +01:00
Gigi
2f2e19fdf9 fix: move FAB to Bookmarks component for proper floating
Move HighlightButton outside .pane.main container by rendering it in Bookmarks component. This bypasses the CSS contain property that was preventing position:fixed from working properly. FAB now floats correctly in bottom-right corner.
2025-10-05 23:14:24 +01:00
Gigi
ce99600aa9 fix: move FAB outside reader container for proper viewport-fixed positioning
The CSS contain property on .reader was creating a new containing block that broke position:fixed. Moving FAB outside allows it to float properly.
2025-10-05 23:11:52 +01:00
Gigi
77bcc481b5 fix: revert FAB opacity to 0.4 when no text selected 2025-10-05 23:10:42 +01:00
Gigi
8bb97b3e4e fix: set FAB to 50% transparent when no text selected
Change opacity from 0 to 0.5 for better visibility
2025-10-05 23:10:27 +01:00
Gigi
2bbfa82eec refactor: make FAB fully transparent when no text selected
- Change opacity from 0.4 to 0 when no selection (fully transparent)
- Remove shadow when transparent for cleaner look
- Use pointerEvents: none to prevent interaction when invisible
- Remove disabled attribute, handle interaction via pointer events
- Button smoothly fades in/scales up when text is selected
2025-10-05 23:10:08 +01:00
Gigi
cc68e67726 refactor: change highlight button to FAB style
- Replace floating popup button with persistent FAB in bottom-right corner
- Button always visible but disabled when no text is selected
- Uses user's highlight color from settings
- Visual feedback: scales up and becomes opaque when text is selected
- Follows Google apps design pattern for floating action buttons
2025-10-05 23:09:36 +01:00
Gigi
f3a8cf1c23 fix: highlight button positioning with scroll
Change button from absolute to fixed positioning so it appears at the correct location relative to selected text regardless of scroll position
2025-10-05 23:06:33 +01:00
Gigi
290d9303b5 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
2025-10-05 23:03:23 +01:00
8 changed files with 265 additions and 9 deletions

View File

@@ -0,0 +1,9 @@
---
description: when dealing with user and app settings
alwaysApply: false
---
We use nostr to load/save/sync our settings.
- https://nostrbook.dev/kinds/30078
- https://github.com/nostr-protocol/nips/blob/master/78.md

4
dist/index.html vendored
View File

@@ -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--wClm1wz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.11",
"version": "0.2.0",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -18,6 +18,10 @@ import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
import { createHighlight } from '../services/highlightCreationService'
import { useRef, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -42,6 +46,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<NostrEvent | undefined>(undefined) // Store the current article event
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
nostrverse: true,
friends: true,
@@ -52,6 +57,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
relayPool,
@@ -71,7 +77,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
setCurrentArticleEventId,
setCurrentArticle
})
// Load initial data on login
@@ -177,12 +184,16 @@ 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)
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
// For web bookmarks, there's no article event to set
} catch (err) {
console.warn('Failed to fetch content:', err)
} finally {
@@ -190,6 +201,54 @@ 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)
}
}
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
}, [])
const handleClearSelection = useCallback(() => {
highlightButtonRef.current?.clearSelection()
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool || !currentArticle) {
console.error('Missing requirements for highlight creation')
return
}
try {
await createHighlight(
text,
currentArticle,
activeAccount,
relayPool
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
// Trigger refresh of highlights
handleHighlightCreated()
} catch (error) {
console.error('Failed to create highlight:', error)
}
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
return (
<>
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
@@ -238,6 +297,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}}
selectedHighlightId={selectedHighlightId}
highlightVisibility={highlightVisibility}
onTextSelection={handleTextSelection}
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
/>
@@ -262,6 +323,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
/>
</div>
</div>
{activeAccount && relayPool && (
<HighlightButton
ref={highlightButtonRef}
onHighlight={handleCreateHighlight}
highlightColor={settings.highlightColor || '#ffff00'}
/>
)}
{toastMessage && (
<Toast
message={toastMessage}

View File

@@ -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'
@@ -27,6 +27,9 @@ interface ContentPanelProps {
highlightVisibility?: HighlightVisibility
currentUserPubkey?: string
followedPubkeys?: Set<string>
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -44,7 +47,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
currentUserPubkey,
followedPubkeys = new Set()
followedPubkeys = new Set(),
// For highlight creation
onTextSelection,
onClearSelection
}) => {
const contentRef = useRef<HTMLDivElement>(null)
const markdownPreviewRef = useRef<HTMLDivElement>(null)
@@ -158,6 +164,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Handle text selection for highlight creation
const handleMouseUp = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
}, [onTextSelection, onClearSelection])
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -202,12 +228,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 +246,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp}
/>
)
) : (

View File

@@ -0,0 +1,79 @@
import React, { useCallback, useImperativeHandle, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
interface HighlightButtonProps {
onHighlight: (text: string) => void
highlightColor?: string
}
export interface HighlightButtonRef {
updateSelection: (text: string) => void
clearSelection: () => void
}
export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightButtonProps>(
({ onHighlight, highlightColor = '#ffff00' }, ref) => {
const currentSelectionRef = useRef<string>('')
const [hasSelection, setHasSelection] = useState(false)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (currentSelectionRef.current) {
onHighlight(currentSelectionRef.current)
}
},
[onHighlight]
)
// Expose methods to update selection
useImperativeHandle(ref, () => ({
updateSelection: (text: string) => {
currentSelectionRef.current = text
setHasSelection(!!text)
},
clearSelection: () => {
currentSelectionRef.current = ''
setHasSelection(false)
}
}))
return (
<button
className="highlight-fab"
style={{
position: 'fixed',
bottom: '32px',
right: '32px',
zIndex: 1000,
width: '56px',
height: '56px',
borderRadius: '50%',
backgroundColor: highlightColor,
color: '#000',
border: 'none',
boxShadow: hasSelection ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
cursor: hasSelection ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s ease',
opacity: hasSelection ? 1 : 0.4,
transform: hasSelection ? 'scale(1)' : 'scale(0.8)',
pointerEvents: hasSelection ? 'auto' : 'none',
userSelect: 'none'
}}
onClick={handleClick}
aria-label="Create highlight from selection"
title={hasSelection ? 'Create highlight' : ''}
>
<FontAwesomeIcon icon={faHighlighter} size="lg" />
</button>
)
}
)
HighlightButton.displayName = 'HighlightButton'

View File

@@ -4,6 +4,7 @@ import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
interface UseArticleLoaderProps {
naddr: string | undefined
@@ -16,6 +17,7 @@ interface UseArticleLoaderProps {
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void
}
export function useArticleLoader({
@@ -28,7 +30,8 @@ export function useArticleLoader({
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
setCurrentArticleEventId,
setCurrentArticle
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
@@ -54,6 +57,7 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)

View 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
}
}