mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de8c49b01 | ||
|
|
c3aece1722 | ||
|
|
7a4cb77aa3 | ||
|
|
9065501043 | ||
|
|
c9ace72d4d | ||
|
|
be6ad79f60 | ||
|
|
0473ba71fb | ||
|
|
7e575ea617 | ||
|
|
c3a2dd5603 | ||
|
|
ad54f2aaa5 | ||
|
|
a6ea97b731 | ||
|
|
2f2e19fdf9 | ||
|
|
ce99600aa9 | ||
|
|
77bcc481b5 | ||
|
|
8bb97b3e4e | ||
|
|
2bbfa82eec | ||
|
|
cc68e67726 | ||
|
|
f3a8cf1c23 | ||
|
|
290d9303b5 |
9
.cursor/rules/app-settings-and-nostr.mdc
Normal file
9
.cursor/rules/app-settings-and-nostr.mdc
Normal 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
|
||||
22
README.md
22
README.md
@@ -2,7 +2,27 @@
|
||||
|
||||
Your reading list for the Nostr world.
|
||||
|
||||
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you’ll get a clean three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you'll get a clean three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
|
||||
## The Vision
|
||||
|
||||
When I wrote "Purple Text, Orange Highlights" 2.5 years ago, I had a certain interface in mind that would allow the reader to curate, discover, highlight, and provide value to writers and other readers alike. Boris is my attempt to build this interface.
|
||||
|
||||
Boris has three "levels" of highlights for each article:
|
||||
- user = yellow
|
||||
- friends = orange
|
||||
- nostrverse = purple
|
||||
|
||||
In case it's not self-explanatory:
|
||||
- **your highlights** = highlights that the logged-in npub made
|
||||
- **friends** = highlights that your friends made, i.e. highlights of the npubs that the logged-in user follows
|
||||
- **nostrverse** = all the highlights we can find on all the relays we're connected to
|
||||
|
||||
The user can toggle hide/show any of these "levels".
|
||||
|
||||
In addition to rendering articles from nostr and the legacy web, Boris can act as a "read it later" app, thanks to the power of nostr bookmarks.
|
||||
|
||||
If you bookmark something on nostr, Boris will show it in the bookmarks bar. If said something contains a URL, Boris will extract and render it in a distraction-free and reader-friendly way.
|
||||
|
||||
## What Boris does
|
||||
|
||||
|
||||
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--wClm1wz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.1",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
189
src/App.tsx
189
src/App.tsx
@@ -2,13 +2,12 @@ import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
@@ -16,6 +15,38 @@ import { useToast } from './hooks/useToast'
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
|
||||
// AppRoutes component that has access to hooks
|
||||
function AppRoutes({
|
||||
relayPool,
|
||||
showToast
|
||||
}: {
|
||||
relayPool: RelayPool
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
@@ -23,15 +54,15 @@ function App() {
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
const loadAccounts = async () => {
|
||||
const initializeApp = async () => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
await accounts.fromJSON(json)
|
||||
@@ -46,65 +77,72 @@ function App() {
|
||||
} catch (err) {
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
}
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
loadAccounts()
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
let cleanup: (() => void) | undefined
|
||||
initializeApp().then((fn) => {
|
||||
cleanup = fn
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup subscriptions on unmount
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -121,26 +159,7 @@ function App() {
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => {
|
||||
if (accountManager) {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
showToast('Logged out successfully')
|
||||
console.log('Logged out')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
<Route path="/login" element={<Login onLogin={() => showToast('Logged in successfully')} />} />
|
||||
</Routes>
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
79
src/components/HighlightButton.tsx
Normal file
79
src/components/HighlightButton.tsx
Normal 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'
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
|
||||
// Create account from nostr extension
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
onLogin()
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h2>Welcome to Boris</h2>
|
||||
<p>Connect your nostr account to view your bookmarks</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isConnecting}
|
||||
className="login-button"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect with Nostr'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import ColorPicker from './ColorPicker'
|
||||
@@ -7,6 +7,21 @@ import FontSelector from './FontSelector'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
defaultViewMode: 'compact',
|
||||
showHighlights: true,
|
||||
sidebarCollapsed: true,
|
||||
highlightsCollapsed: true,
|
||||
readingFont: 'source-serif-4',
|
||||
fontSize: 18,
|
||||
highlightStyle: 'marker',
|
||||
highlightColor: '#ffff00',
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
highlightColorFriends: '#f97316',
|
||||
highlightColorMine: '#ffff00',
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
onSave: (settings: UserSettings) => Promise<void>
|
||||
@@ -45,17 +60,32 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
if (confirm('Reset all settings to defaults?')) {
|
||||
setLocalSettings(DEFAULT_SETTINGS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-view">
|
||||
<div className="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="settings-header-actions">
|
||||
<IconButton
|
||||
icon={faUndo}
|
||||
onClick={handleResetToDefaults}
|
||||
title="Reset to defaults"
|
||||
ariaLabel="Reset to defaults"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,53 +56,6 @@ body {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Login Styles */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #1a1a1a;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: #646cff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
@@ -1147,7 +1100,6 @@ body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.bookmark-item {
|
||||
background: #f9f9f9;
|
||||
border-color: #ddd;
|
||||
@@ -1846,6 +1798,12 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ export function hexToRgb(hex: string): string {
|
||||
|
||||
export const HIGHLIGHT_COLORS = [
|
||||
{ name: 'Yellow', value: '#ffff00' },
|
||||
{ name: 'Orange', value: '#ff9500' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Pink', value: '#ff69b4' },
|
||||
{ name: 'Green', value: '#00ff7f' },
|
||||
{ name: 'Blue', value: '#4da6ff' },
|
||||
{ name: 'Purple', value: '#b19cd9' }
|
||||
{ name: 'Purple', value: '#9333ea' }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user