Compare commits

...

19 Commits

Author SHA1 Message Date
Gigi
7de8c49b01 chore: bump version to 0.2.1 2025-10-05 23:35:23 +01:00
Gigi
c3aece1722 fix: properly await account loading from localStorage on refresh 2025-10-05 23:34:33 +01:00
Gigi
7a4cb77aa3 refactor: remove dedicated login page, handle login through main UI 2025-10-05 23:32:52 +01:00
Gigi
9065501043 fix: add protected routes to prevent logout on page refresh 2025-10-05 23:31:30 +01:00
Gigi
c9ace72d4d fix: use undo icon for reset to defaults button 2025-10-05 23:29:33 +01:00
Gigi
be6ad79f60 docs: add vision section and explain three-level highlight system 2025-10-05 23:28:50 +01:00
Gigi
0473ba71fb fix: update color palette to include default friends/nostrverse colors 2025-10-05 23:28:18 +01:00
Gigi
7e575ea617 feat: add reset to defaults button in settings 2025-10-05 23:27:43 +01:00
Gigi
c3a2dd5603 feat: load and apply settings upon login 2025-10-05 23:23:59 +01:00
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
14 changed files with 436 additions and 200 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

View File

@@ -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 youll get a clean threepane 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 threepane 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
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.1",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -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 && (

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

@@ -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

View File

@@ -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">

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

@@ -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;

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

View File

@@ -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' }
]