Compare commits

...

30 Commits

Author SHA1 Message Date
Gigi
1bcaa1998d chore: bump version to 0.1.4 2025-10-04 22:14:00 +01:00
Gigi
e98dc1c5da fix: resolve all linting and type errors
- Remove unused applyHighlightsToText import from ContentPanel
- Replace while(true) with proper condition in findHighlightMatches
- Remove unused match parameter from replaceTextWithMark function

All ESLint and TypeScript checks now pass with no errors.
2025-10-04 22:13:31 +01:00
Gigi
aa8d3c285d fix: apply highlights to markdown content as well as HTML
- Update useEffect to check for both html and markdown content
- Add contentRef to markdown div for DOM manipulation
- Add markdown to useEffect dependencies
- Improve logging to show which content type is available

This fixes the issue where highlights weren't appearing because
the reader service was returning markdown instead of HTML.
2025-10-04 22:09:38 +01:00
Gigi
9ac8e8f69c fix: use requestAnimationFrame for highlight DOM manipulation
- Replace setTimeout with requestAnimationFrame for proper DOM timing
- Ensures contentRef is available before applying highlights
- Reorganize useEffect logic for clearer flow
- Add more specific logging for debugging

This fixes the issue where highlights weren't appearing because
the effect ran before React finished rendering the HTML content.
2025-10-04 22:00:19 +01:00
Gigi
842bfa5491 feat: add toggle button to show/hide highlight underlines
- Add eye/eye-slash toggle button in highlights panel header
- Button only appears when there are highlights to show
- Clicking toggles underlines on/off in the main content panel
- When hidden, removes existing <mark> elements from DOM
- Add showUnderlines state management through Bookmarks component
- Style toggle button consistently with collapse button
- Add highlights-actions container for button group

Users can now toggle highlight visibility without losing the highlight list.
2025-10-04 21:54:18 +01:00
Gigi
e2e5d59197 debug: add detailed logging to highlight application useEffect
- Log when useEffect is triggered
- Log contentRef status, relevant highlights count, and html presence
- Log specific reason when skipping highlight application
- This will help identify why highlights aren't being applied to DOM
2025-10-04 21:52:22 +01:00
Gigi
0255ff5d03 feat: filter highlights panel to show only current article
- Add selectedUrl prop to HighlightsPanel
- Filter highlights by URL using same normalization logic as ContentPanel
- Update count badge to show filtered count
- Improve empty state message based on context
- Now shows "No highlights for this article" instead of all highlights

This makes the highlights panel contextual to the current article being viewed.
2025-10-04 21:50:20 +01:00
Gigi
930cd272cb fix: apply highlights after DOM renders to fix timing issue
- Use useEffect to apply highlights after HTML is rendered
- Add 100ms delay to ensure DOM is fully parsed
- Use ref to directly manipulate rendered content
- Remove pre-rendering highlight application from useMemo
- Add detailed logging for debugging

This fixes the issue where highlights weren't appearing because they
were being applied to the HTML string before the DOM was ready.
2025-10-04 21:44:08 +01:00
Gigi
2dea3c2a5c style: change highlights to yellow underline
- Remove background color, use transparent background
- Change border-bottom from blue to gold/yellow (#ffd700)
- Add subtle yellow background on hover
- Adjust light mode colors for better contrast
2025-10-04 20:45:54 +01:00
Gigi
38b80bc85b refactor: DRY up highlightMatching to stay under 210 lines
- Extract helper functions: normalizeWhitespace, createMarkElement, replaceTextWithMark
- Consolidate duplicate exact/normalized matching logic into tryMarkInTextNodes
- Reduce from 242 lines to 209 lines
- Maintain all functionality while improving code reusability
2025-10-04 20:45:06 +01:00
Gigi
c0de624fe6 refactor: use applesauce helpers for highlight parsing
- Replace manual tag parsing with applesauce-core helper functions
- Use getHighlightText, getHighlightContext, getHighlightComment, etc.
- Add support for highlight comments in UI
- Extract author from attributions using proper helper
- Handle both event and address pointers correctly
- Add styling for highlight comments

This follows applesauce best practices and makes the code more robust.
2025-10-04 20:41:26 +01:00
Gigi
1d7ab59272 feat: deduplicate highlight events by ID
- Add dedupeHighlights function to remove duplicate events from multiple relays
- Log both raw and deduplicated event counts for debugging
- Follows same pattern as bookmark deduplication
2025-10-04 20:39:25 +01:00
Gigi
0803417755 feat: improve highlight URL and text matching
- Use proper URL parsing to normalize URLs (remove www, query params, fragments)
- Add detailed logging for URL comparison to debug matching issues
- Implement two-pass text matching: exact match first, then normalized whitespace
- Handle whitespace variations in highlighted text more flexibly
- Add context to debug logs showing surrounding text

This should make highlights appear more reliably even with URL variations
and whitespace differences between the highlight and the actual content.
2025-10-04 20:32:55 +01:00
Gigi
a602f163fb fix: improve HTML highlight matching with DOM manipulation
- Replace simple string replacement with proper DOM tree walking
- Find text nodes and split them to insert mark elements
- Add extensive debugging to track highlight matching
- Handle text that spans across HTML elements correctly

This should fix the issue where highlights weren't showing up in
article content due to HTML tags breaking up the text.
2025-10-04 20:14:25 +01:00
Gigi
4aa496ec3f fix: improve highlights panel collapse behavior
- Flip chevron icon direction (left when collapsed, right when expanded)
- Match bookmarks sidebar styling for collapsed state
- Remove background/border when collapsed for cleaner look
- Ensure toggle button stays at top of panel
- Add proper hover states for collapsed button

The highlights panel now behaves consistently with the bookmarks sidebar,
with the chevron pointing in the correct direction and proper visual feedback.
2025-10-04 19:59:03 +01:00
Gigi
296600bb0d feat: add inline highlight annotations in content panel
- Create highlightMatching utility to find and apply highlights to text/HTML
- Update ContentPanel to accept highlights and match them to current URL
- Add visual highlighting with yellow background and blue underline
- Show highlight count indicator when content has highlights
- Add hover effects and tooltips showing highlight date
- Support both HTML and markdown content highlighting

Highlighted text now appears underlined in the main content panel when
viewing URLs that have associated NIP-84 highlights.
2025-10-04 19:58:10 +01:00
Gigi
7390104414 feat: add NIP-84 highlights panel with three-pane layout
- Add HighlightsPanel component with collapsible functionality
- Add HighlightItem component to display individual highlights
- Create highlightService to fetch kind 9802 events
- Add Highlight type definitions for NIP-84
- Update Bookmarks to support three-pane layout (bookmarks, content, highlights)
- Add comprehensive CSS styling for highlights panel
- Update README with highlights feature documentation

The highlights panel mirrors the bookmark sidebar on the right side, showing
all NIP-84 highlights with context, source links, and timestamps. Both panels
are independently collapsible for flexible viewing.
2025-10-04 19:47:45 +01:00
Gigi
f4fbc34bc1 fix: update remaining Markr references to Boris
- Update Login component welcome message
- Update package-lock.json name references
2025-10-03 15:00:13 +02:00
Gigi
e83976e5e0 feat: rename app from Markr to Boris
- Update package.json name field
- Update README.md title and references
- Update HTML page title
2025-10-03 14:58:28 +02:00
Gigi
0cf7f93482 refactor: split BookmarkItem into separate view components
- Extract CompactView, LargeView, and CardView into separate files
- Keep all files under 210 lines (BookmarkItem: 307→105 lines)
- Improve code organization and maintainability
- Add shared type definitions for view components
- Keep DRY with shared props object
2025-10-03 10:29:17 +02:00
Gigi
796380ea0d fix: move useEffect hook to top level to comply with Rules of Hooks
- Move OG image fetching useEffect to component top level
- Make hook logic conditional instead of hook call itself
- Prevents 'Rendered more hooks than during previous render' error
- Remove duplicate firstUrlClassification declaration
2025-10-03 10:26:40 +02:00
Gigi
3d6403f139 feat: fetch article hero images using free CORS proxy
- Add Open Graph image extraction from article HTML
- Use allorigins.win as free CORS proxy (no auth required)
- Implement HTML parsing to extract og:image meta tags
- Add in-memory caching to avoid repeated fetches
- Async loading with React useEffect for non-YouTube URLs
- 5 second timeout for fetch requests
- Graceful fallback to icon placeholder on errors
2025-10-03 10:24:34 +02:00
Gigi
57c5be9907 feat: add image preview for large view cards
- Extract YouTube video thumbnails from URLs
- Display thumbnail images as background in large preview cards
- Add gradient overlay for better text contrast
- Fallback to icon placeholder for non-YouTube URLs
- Handle multiple YouTube URL formats (watch, youtu.be, shorts)
- Gracefully handle missing images with icon fallback
2025-10-03 10:16:22 +02:00
Gigi
bd3193957c feat: implement large preview view mode
- Add large preview layout with image placeholder area
- Display truncated content (3 lines max) below preview
- Footer with author, timestamp, and action button
- Clickable preview area opens URL in reader
- Clean, minimalistic design with larger spacing
- All views now fully functional: compact, cards, and large
2025-10-03 10:10:17 +02:00
Gigi
64efb103a4 feat: make card view timestamp clickable to open event
- Timestamp in card view now links to event in search portal
- Add hover effect showing link is clickable
- Remove unused getKindIcon import
- All linter and type checks pass
2025-10-03 10:08:56 +02:00
Gigi
4afd9ed6d1 feat: enhance card view design with modern styling
- Add gradient backgrounds to cards and buttons
- Improve visual hierarchy with borders and dividers
- Enhance hover effects with better shadows and transitions
- Increase padding and spacing for better readability
- Add subtle gradients to bookmark type badges
- Improve kind icon styling with hover effects
- Better typography with increased line height and font sizes
2025-10-03 09:54:34 +02:00
Gigi
7e9cdfb0e1 chore: bump version to 0.1.3 2025-10-03 09:52:03 +02:00
Gigi
bdfb7ca9a6 feat: make entire compact list row clickable to open reader
- Add onClick handler to compact-row div
- Show pointer cursor on rows with URLs
- Add stopPropagation to action button to prevent double-trigger
- Include accessibility attributes (role, tabIndex)
2025-10-03 09:51:34 +02:00
Gigi
288b96d614 refactor: make compact list view even more compact
- Move all elements to a single horizontal line
- Reduce text preview from 100 to 60 characters
- Decrease padding and font sizes
- Fix row height to 28px for consistent spacing
- Improve text truncation with ellipsis
2025-10-03 09:49:07 +02:00
Gigi
99c6a4c23b feat: add view mode switching for bookmarks with compact list view
- Add ViewMode type with options: compact, cards, large
- Add view mode toggle buttons in SidebarHeader
- Implement compact list view rendering in BookmarkItem
- Add CSS styles for compact view with condensed layout
- Cards view remains the default and current style
2025-10-03 09:44:39 +02:00
23 changed files with 1871 additions and 254 deletions

View File

@@ -3,4 +3,4 @@ description: when creating or modifying UI elements, especially related to icons
alwaysApply: false
---
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon.
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.

View File

@@ -1,4 +1,4 @@
# Markr
# Boris
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
@@ -13,6 +13,8 @@ A minimal nostr client for bookmark management, built with [applesauce](https://
- **Relative Timestamps**: Human-friendly time display (e.g., "2 hours ago")
- **Event Links**: Quick access to view bookmarks on search.dergigi.com
- **Private Bookmarks**: Support for Amethyst-style hidden/encrypted bookmarks
- **Highlights Panel**: View and manage your NIP-84 highlights in a dedicated collapsible panel
- **Three-Pane Layout**: Bookmarks sidebar, content viewer, and highlights panel working together
- **Minimal UI**: Clean, modern interface focused on bookmark management
## Getting Started
@@ -27,7 +29,7 @@ A minimal nostr client for bookmark management, built with [applesauce](https://
1. Clone the repository:
```bash
git clone <your-repo-url>
cd markr
cd boris
```
2. Install dependencies:
@@ -53,8 +55,10 @@ yarn dev
## Usage
1. **Connect**: Click "Connect with Nostr" to authenticate using your nostr account
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks
3. **Navigate**: Click on bookmark URLs to open them in a new tab
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
4. **Navigate**: Click on bookmark URLs to view content in the center panel
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
## Technical Details
@@ -76,6 +80,8 @@ src/
│ ├── BookmarkItem.tsx # Individual bookmark card
│ ├── SidebarHeader.tsx # Header bar with collapse, profile, logout
│ ├── ContentPanel.tsx # Content viewer panel
│ ├── HighlightsPanel.tsx # Highlights sidebar panel (NIP-84)
│ ├── HighlightItem.tsx # Individual highlight display
│ ├── IconButton.tsx # Reusable icon button component
│ ├── ContentWithResolvedProfiles.tsx # Profile mention resolver
│ ├── ResolvedMention.tsx # Nostr mention component
@@ -85,9 +91,11 @@ src/
│ ├── bookmarkProcessing.ts # Decryption and processing pipeline
│ ├── bookmarkHelpers.ts # Shared types, guards, and utilities
│ ├── bookmarkEvents.ts # Event type handling and deduplication
│ ├── highlightService.ts # Highlight fetching (NIP-84)
│ └── readerService.ts # Content extraction via reader API
├── types/
│ ├── bookmarks.ts # Bookmark type definitions
│ ├── highlights.ts # Highlight type definitions (NIP-84)
│ ├── nostr.d.ts # Nostr type augmentations
│ └── relative-time.d.ts # relative-time package types
├── utils/

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>Markr - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-ez6f4baA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DCTVEVF8.css">
<script type="module" crossorigin src="/assets/index-sYF0VIKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNyWhz1u.css">
</head>
<body>
<div id="root"></div>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markr - Nostr Bookmarks</title>
<title>Boris - Nostr Bookmarks</title>
</head>
<body>
<div id="root"></div>

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "markr",
"name": "boris",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "markr",
"name": "boris",
"version": "0.1.1",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",

View File

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

View File

@@ -1,36 +1,42 @@
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
import { faChevronDown, faChevronUp, faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks'
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
import { getKindIcon } from './kindIcon'
import ContentWithResolvedProfiles from './ContentWithResolvedProfiles'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
import { ViewMode } from './Bookmarks'
import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string) => void
viewMode?: ViewMode
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl }) => {
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
// removed copy-to-clipboard buttons
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
// Extract URLs from bookmark content
const extractedUrls = extractUrlsFromContent(bookmark.content)
const hasUrls = extractedUrls.length > 0
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// Fetch OG image for large view (hook must be at top level)
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
React.useEffect(() => {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [viewMode, firstUrl, instantPreview, ogImage])
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
@@ -72,125 +78,28 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
}
// Get classification for the first URL (for the main button)
const firstUrlClassification = hasUrls ? classifyUrl(extractedUrls[0]) : null
const sharedProps = {
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
}
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
</span>
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
</div>
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
<h4>URLs:</h4>
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 3)).map((url, urlIndex) => {
const classification = classifyUrl(url)
return (
<div key={urlIndex} className="url-row">
<button
className="bookmark-url"
onClick={() => onSelectUrl?.(url)}
title="Open in reader"
>
{url}
</button>
<IconButton
icon={getIconForUrlType(url)}
ariaLabel={classification.buttonText}
title={classification.buttonText}
variant="success"
size={36}
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
/>
</div>
)
})}
{extractedUrls.length > 3 && (
<button
className="expand-toggle"
onClick={() => setUrlsExpanded(v => !v)}
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
>
<FontAwesomeIcon icon={urlsExpanded ? faChevronUp : faChevronDown} />
</button>
)}
</div>
)}
{bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}`} />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
)}
if (viewMode === 'compact') {
return <CompactView {...sharedProps} />
}
{contentLength > 210 && (
<button
className="expand-toggle"
onClick={() => setExpanded(v => !v)}
aria-label={expanded ? 'Collapse' : 'Expand'}
title={expanded ? 'Collapse' : 'Expand'}
>
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
</button>
)}
<div className="bookmark-meta">
{eventNevent ? (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
target="_blank"
rel="noopener noreferrer"
className="kind-icon-link"
title="Open event in search"
>
<span className="kind-icon">
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
</span>
</a>
) : (
<span className="kind-icon">
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
</span>
)}
<span>
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
className="author-link"
title="Open author in search"
>
by: {getAuthorDisplayName()}
</a>
</span>
</div>
if (viewMode === 'large') {
const previewImage = instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
}
{hasUrls && firstUrlClassification && (
<div className="read-now">
<button className="read-now-button" onClick={handleReadNow}>
{firstUrlClassification.buttonText}
</button>
</div>
)}
</div>
)
return <CardView {...sharedProps} />
}

View File

@@ -5,6 +5,7 @@ import { Bookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
import SidebarHeader from './SidebarHeader'
import { ViewMode } from './Bookmarks'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -12,6 +13,8 @@ interface BookmarkListProps {
isCollapsed: boolean
onToggleCollapse: () => void
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -19,7 +22,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onSelectUrl,
isCollapsed,
onToggleCollapse,
onLogout
onLogout,
viewMode,
onViewModeChange
}) => {
if (isCollapsed) {
return (
@@ -38,7 +43,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
return (
<div className="bookmarks-container">
<SidebarHeader onToggleCollapse={onToggleCollapse} onLogout={onLogout} />
<SidebarHeader
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
{bookmarks.length === 0 ? (
<div className="empty-state">
@@ -75,9 +85,15 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
)}
{bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && (
<div className="individual-bookmarks">
<div className="bookmarks-grid">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{bookmark.individualBookmarks.map((individualBookmark, index) =>
<BookmarkItem key={index} bookmark={individualBookmark} index={index} onSelectUrl={onSelectUrl} />
<BookmarkItem
key={index}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
interface CardViewProps {
bookmark: IndividualBookmark
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const CardView: React.FC<CardViewProps> = ({
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
}) => {
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
</span>
{eventNevent ? (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
title="Open event in search"
>
{formatDate(bookmark.created_at)}
</a>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
)}
</div>
{extractedUrls.length > 0 && (
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
const classification = classifyUrl(url)
return (
<div key={urlIndex} className="url-row">
<button
className="bookmark-url"
onClick={() => onSelectUrl?.(url)}
title="Open in reader"
>
{url}
</button>
<IconButton
icon={getIconForUrlType(url)}
ariaLabel={classification.buttonText}
title={classification.buttonText}
variant="success"
size={32}
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
/>
</div>
)
})}
{extractedUrls.length > 1 && (
<button
className="expand-toggle-urls"
onClick={() => setUrlsExpanded(v => !v)}
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
>
{urlsExpanded ? `Hide ${extractedUrls.length - 1} more` : `Show ${extractedUrls.length - 1} more`}
</button>
)}
</div>
)}
{bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}`} />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
)}
{contentLength > 210 && (
<button
className="expand-toggle"
onClick={() => setExpanded(v => !v)}
aria-label={expanded ? 'Collapse' : 'Expand'}
title={expanded ? 'Collapse' : 'Expand'}
>
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
</button>
)}
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"
title="Open author in search"
>
{getAuthorDisplayName()}
</a>
</div>
{hasUrls && firstUrlClassification && (
<button className="read-now-button-minimal" onClick={handleReadNow}>
{firstUrlClassification.buttonText}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
interface CompactViewProps {
bookmark: IndividualBookmark
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
}
export const CompactView: React.FC<CompactViewProps> = ({
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification
}) => {
const handleCompactClick = () => {
if (hasUrls && onSelectUrl) {
onSelectUrl(extractedUrls[0])
}
}
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
className={`compact-row ${hasUrls ? 'clickable' : ''}`}
onClick={handleCompactClick}
role={hasUrls ? 'button' : undefined}
tabIndex={hasUrls ? 0 : undefined}
>
<span className="bookmark-type-compact">
{bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
</span>
{bookmark.content && (
<div className="compact-text">
<ContentWithResolvedProfiles content={bookmark.content.slice(0, 60) + (bookmark.content.length > 60 ? '…' : '')} />
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
{hasUrls && (
<button
className="compact-read-btn"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(extractedUrls[0]) }}
title={firstUrlClassification?.buttonText}
>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
interface LargeViewProps {
bookmark: IndividualBookmark
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
previewImage: string | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const LargeView: React.FC<LargeViewProps> = ({
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
previewImage,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
}) => {
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{hasUrls && (
<div
className="large-preview-image"
onClick={() => onSelectUrl?.(extractedUrls[0])}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
>
{!previewImage && (
<div className="preview-placeholder">
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
</div>
)}
</div>
)}
<div className="large-content">
{bookmark.content && (
<div className="large-text">
<ContentWithResolvedProfiles content={bookmark.content} />
</div>
)}
<div className="large-footer">
<span className="large-author">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"
>
{getAuthorDisplayName()}
</a>
</span>
{eventNevent && (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
>
{formatDate(bookmark.created_at)}
</a>
)}
{hasUrls && firstUrlClassification && (
<button className="large-read-button" onClick={handleReadNow}>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
{firstUrlClassification.buttonText}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
export type IconGetter = (url: string) => IconDefinition

View File

@@ -2,11 +2,16 @@ import React, { useState, useEffect } from 'react'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights } from '../services/highlightService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
relayPool: RelayPool | null
onLogout: () => void
@@ -15,10 +20,15 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const [isCollapsed, setIsCollapsed] = useState(false)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [showUnderlines, setShowUnderlines] = useState(true)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -27,8 +37,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
console.log('relayPool:', !!relayPool)
console.log('activeAccount:', !!activeAccount)
if (relayPool && activeAccount) {
console.log('Starting to fetch bookmarks...')
console.log('Starting to fetch bookmarks and highlights...')
handleFetchBookmarks()
handleFetchHighlights()
} else {
console.log('Not fetching bookmarks - missing dependencies')
}
@@ -52,6 +63,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, setLoading, timeoutId)
}
const handleFetchHighlights = async () => {
if (!relayPool || !activeAccount) {
return
}
setHighlightsLoading(true)
try {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
}
const handleSelectUrl = async (url: string) => {
setSelectedUrl(url)
setReaderLoading(true)
@@ -77,7 +104,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
return (
<div className={`two-pane ${isCollapsed ? 'sidebar-collapsed' : ''}`}>
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
@@ -85,6 +112,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
<div className="pane main">
@@ -94,6 +123,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
html={readerContent?.html}
markdown={readerContent?.markdown}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
/>
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={highlights}
loading={highlightsLoading}
isCollapsed={isHighlightsCollapsed}
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
selectedUrl={selectedUrl}
onToggleUnderlines={setShowUnderlines}
/>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import React from 'react'
import React, { useMemo, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { applyHighlightsToHTML } from '../utils/highlightMatching'
interface ContentPanelProps {
loading: boolean
@@ -10,9 +12,144 @@ interface ContentPanelProps {
html?: string
markdown?: string
selectedUrl?: string
highlights?: Highlight[]
showUnderlines?: boolean
}
const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markdown, selectedUrl }) => {
const ContentPanel: React.FC<ContentPanelProps> = ({
loading,
title,
html,
markdown,
selectedUrl,
highlights = [],
showUnderlines = true
}) => {
const contentRef = useRef<HTMLDivElement>(null)
// Filter highlights relevant to the current URL
const relevantHighlights = useMemo(() => {
if (!selectedUrl || highlights.length === 0) {
console.log('🔍 No highlights to filter:', { selectedUrl, highlightsCount: highlights.length })
return []
}
// Normalize URLs for comparison (remove trailing slashes, protocols, www, query params, fragments)
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
// Get just the hostname + pathname, remove trailing slash
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
// Fallback for invalid URLs
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
}
}
const normalizedSelected = normalizeUrl(selectedUrl)
console.log('🔍 Normalized selected URL:', normalizedSelected)
const filtered = highlights.filter(h => {
if (!h.urlReference) {
console.log('⚠️ Highlight has no URL reference:', h.id.slice(0, 8))
return false
}
const normalizedRef = normalizeUrl(h.urlReference)
const matches = normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
console.log('🔍 URL comparison:', {
highlightId: h.id.slice(0, 8),
originalRef: h.urlReference,
normalizedRef,
normalizedSelected,
matches
})
return matches
})
console.log('🔍 Filtered highlights:', {
selectedUrl,
totalHighlights: highlights.length,
relevantHighlights: filtered.length,
highlights: filtered.map(h => ({
id: h.id.slice(0, 8),
urlRef: h.urlReference,
content: h.content.slice(0, 50)
}))
})
return filtered
}, [selectedUrl, highlights])
// Apply highlights after DOM is rendered
useEffect(() => {
// Skip if no content or underlines are hidden
if ((!html && !markdown) || !showUnderlines) {
console.log('⚠️ Skipping highlight application:', {
reason: (!html && !markdown) ? 'no content' : 'underlines hidden',
hasHtml: !!html,
hasMarkdown: !!markdown
})
// If underlines are hidden, remove any existing highlights
if (!showUnderlines && contentRef.current) {
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
marks.forEach(mark => {
const text = mark.textContent || ''
const textNode = document.createTextNode(text)
mark.parentNode?.replaceChild(textNode, mark)
})
}
return
}
// Skip if no relevant highlights
if (relevantHighlights.length === 0) {
console.log('⚠️ No relevant highlights to apply')
return
}
console.log('🔍 Scheduling highlight application:', {
relevantHighlightsCount: relevantHighlights.length,
highlights: relevantHighlights.map(h => h.content.slice(0, 50)),
hasHtml: !!html,
hasMarkdown: !!markdown
})
// Use requestAnimationFrame to ensure DOM is fully rendered
const rafId = requestAnimationFrame(() => {
if (!contentRef.current) {
console.log('⚠️ contentRef not available after RAF')
return
}
console.log('🔍 Applying highlights to rendered DOM')
const originalHTML = contentRef.current.innerHTML
const highlightedHTML = applyHighlightsToHTML(originalHTML, relevantHighlights)
if (originalHTML !== highlightedHTML) {
console.log('✅ Applied highlights to DOM')
contentRef.current.innerHTML = highlightedHTML
} else {
console.log('⚠️ No changes made to DOM')
}
})
return () => cancelAnimationFrame(rafId)
}, [relevantHighlights, html, markdown, showUnderlines])
const highlightedMarkdown = useMemo(() => {
if (!markdown || relevantHighlights.length === 0) return markdown
// For markdown, we'll apply highlights after rendering
return markdown
}, [markdown, relevantHighlights])
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -32,17 +169,29 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markd
)
}
const hasHighlights = relevantHighlights.length > 0
return (
<div className="reader">
{title && <h2 className="reader-title">{title}</h2>}
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
</div>
)}
</div>
)}
{markdown ? (
<div className="reader-markdown">
<div ref={contentRef} className="reader-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{highlightedMarkdown}
</ReactMarkdown>
</div>
) : html ? (
<div className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faLink, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
interface HighlightItemProps {
highlight: Highlight
onSelectUrl?: (url: string) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl }) => {
const handleLinkClick = (url: string, e: React.MouseEvent) => {
if (onSelectUrl) {
e.preventDefault()
onSelectUrl(url)
}
}
const getSourceLink = () => {
if (highlight.eventReference) {
return `https://search.dergigi.com/e/${highlight.eventReference}`
}
return highlight.urlReference
}
const sourceLink = getSourceLink()
return (
<div className="highlight-item">
<div className="highlight-quote-icon">
<FontAwesomeIcon icon={faQuoteLeft} />
</div>
<div className="highlight-content">
<blockquote className="highlight-text">
{highlight.content}
</blockquote>
{highlight.comment && (
<div className="highlight-comment">
{highlight.comment}
</div>
)}
{highlight.context && (
<details className="highlight-context">
<summary>Show context</summary>
<p className="context-text">{highlight.context}</p>
</details>
)}
<div className="highlight-meta">
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
</span>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
className="highlight-source"
title={highlight.eventReference ? 'View on Nostr' : 'View source'}
>
<FontAwesomeIcon icon={highlight.eventReference ? faLink : faExternalLinkAlt} />
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
</a>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faChevronLeft, faHighlighter, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
interface HighlightsPanelProps {
highlights: Highlight[]
loading: boolean
isCollapsed: boolean
onToggleCollapse: () => void
onSelectUrl?: (url: string) => void
selectedUrl?: string
onToggleUnderlines?: (show: boolean) => void
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
highlights,
loading,
isCollapsed,
onToggleCollapse,
onSelectUrl,
selectedUrl,
onToggleUnderlines
}) => {
const [showUnderlines, setShowUnderlines] = useState(true)
const handleToggleUnderlines = () => {
const newValue = !showUnderlines
setShowUnderlines(newValue)
onToggleUnderlines?.(newValue)
}
// Filter highlights to show only those relevant to the current URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
}
}
const normalizedSelected = normalizeUrl(selectedUrl)
return highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}, [highlights, selectedUrl])
if (isCollapsed) {
return (
<div className="highlights-container collapsed">
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Expand highlights panel"
aria-label="Expand highlights panel"
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
</div>
)
}
return (
<div className="highlights-container">
<div className="highlights-header">
<div className="highlights-title">
<FontAwesomeIcon icon={faHighlighter} />
<h3>Highlights</h3>
{!loading && <span className="count">({filteredHighlights.length})</span>}
</div>
<div className="highlights-actions">
{filteredHighlights.length > 0 && (
<button
onClick={handleToggleUnderlines}
className="toggle-underlines-btn"
title={showUnderlines ? 'Hide underlines' : 'Show underlines'}
aria-label={showUnderlines ? 'Hide underlines' : 'Show underlines'}
>
<FontAwesomeIcon icon={showUnderlines ? faEye : faEyeSlash} />
</button>
)}
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
</div>
</div>
{loading ? (
<div className="highlights-loading">
<p>Loading highlights...</p>
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">
<FontAwesomeIcon icon={faHighlighter} size="2x" />
<p>No highlights for this article.</p>
<p className="empty-hint">
{selectedUrl
? 'Create highlights for this article using a Nostr client that supports NIP-84.'
: 'Select an article to view its highlights.'}
</p>
</div>
) : (
<div className="highlights-list">
{filteredHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
onSelectUrl={onSelectUrl}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -30,7 +30,7 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
return (
<div className="login-container">
<div className="login-card">
<h2>Welcome to Markr</h2>
<h2>Welcome to Boris</h2>
<p>Connect your nostr account to view your bookmarks</p>
<button
onClick={handleLogin}

View File

@@ -1,17 +1,20 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUser } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUser, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange }) => {
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
@@ -30,30 +33,55 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
const profileImage = getProfileImage()
return (
<div className="sidebar-header-bar">
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="profile-avatar" title={getUserDisplayName()}>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUser} />
)}
<>
<div className="sidebar-header-bar">
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="profile-avatar" title={getUserDisplayName()}>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUser} />
)}
</div>
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
</div>
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
</div>
<div className="view-mode-controls">
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</>
)
}

View File

@@ -107,7 +107,19 @@ body {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.view-mode-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 1rem;
justify-content: center;
}
.profile-avatar {
@@ -217,13 +229,7 @@ body {
}
.bookmark-urls {
margin: 1rem 0;
}
.bookmark-urls h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: #666;
margin: 0.75rem 0;
}
.bookmark-url {
@@ -398,7 +404,7 @@ body {
margin: 0 auto;
}
/* Two-pane layout */
/* Two-pane layout (legacy support) */
.two-pane {
display: grid;
grid-template-columns: 360px 1fr;
@@ -411,6 +417,27 @@ body {
grid-template-columns: 60px 1fr;
}
/* Three-pane layout */
.three-pane {
display: grid;
grid-template-columns: 360px 1fr 360px;
gap: 1rem;
height: calc(100vh - 4rem);
transition: grid-template-columns 0.3s ease;
}
.three-pane.sidebar-collapsed {
grid-template-columns: 60px 1fr 360px;
}
.three-pane.highlights-collapsed {
grid-template-columns: 360px 1fr 60px;
}
.three-pane.sidebar-collapsed.highlights-collapsed {
grid-template-columns: 60px 1fr 60px;
}
.pane.sidebar {
overflow-y: auto;
height: 100%;
@@ -421,6 +448,11 @@ body {
height: 100%;
}
.pane.highlights {
overflow-y: auto;
height: 100%;
}
.reader {
background: #1a1a1a;
border: 1px solid #333;
@@ -444,8 +476,34 @@ body {
font-size: 1.2rem;
}
.reader-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.reader-title {
margin: 0 0 1rem 0;
margin: 0;
flex: 1;
}
.highlight-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(100, 108, 255, 0.1);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 6px;
font-size: 0.875rem;
color: #646cff;
}
.highlight-indicator svg {
font-size: 0.875rem;
}
.reader-html {
@@ -588,12 +646,20 @@ body {
gap: 1rem;
}
.bookmarks-grid.bookmarks-compact {
gap: 0.5rem;
}
.bookmarks-grid.bookmarks-large {
gap: 1.5rem;
}
.individual-bookmark {
background: #2a2a2a;
padding: 1.25rem;
border-radius: 8px;
transition: all 0.2s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #333;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@@ -601,8 +667,90 @@ body {
}
.individual-bookmark:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-color: #444;
background: #2d2d2d;
}
/* Compact view styles */
.individual-bookmark.compact {
padding: 0.4rem 0.75rem;
background: transparent;
border-bottom: 1px solid #333;
border-radius: 0;
box-shadow: none;
}
.individual-bookmark.compact:hover {
background: #2a2a2a;
transform: none;
box-shadow: none;
}
.compact-row {
display: flex;
align-items: center;
gap: 0.75rem;
height: 28px;
}
.compact-row.clickable {
cursor: pointer;
}
.compact-row.clickable:active {
opacity: 0.8;
}
.bookmark-type-compact {
display: flex;
align-items: center;
gap: 0.25rem;
color: #646cff;
font-size: 0.85rem;
flex-shrink: 0;
}
.compact-text {
flex: 1;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bookmark-date-compact {
font-size: 0.7rem;
color: #666;
flex-shrink: 0;
white-space: nowrap;
}
.compact-read-btn {
background: #28a745;
color: white;
border: none;
padding: 0;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 22px;
flex-shrink: 0;
transition: background-color 0.2s ease;
}
.compact-read-btn:hover {
background: #218838;
}
.compact-read-btn:active {
transform: translateY(1px);
}
.bookmark-header {
@@ -615,16 +763,11 @@ body {
}
.bookmark-type {
background: #646cff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
color: #646cff;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.35rem;
}
.bookmark-id {
@@ -641,10 +784,23 @@ body {
color: #666;
}
.bookmark-date-link {
font-size: 0.8rem;
color: #666;
text-decoration: none;
transition: color 0.2s ease;
}
.bookmark-date-link:hover {
color: #8ab4f8;
text-decoration: underline;
}
.individual-bookmark .bookmark-content {
margin: 0.75rem 0;
color: #ccc;
line-height: 1.5;
line-height: 1.6;
font-size: 0.9rem;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@@ -667,76 +823,151 @@ body {
color: #bbb;
}
.individual-bookmark .bookmark-meta {
.bookmark-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
gap: 0.75rem;
}
.bookmark-meta-minimal {
font-size: 0.8rem;
color: #888;
}
.author-link-minimal {
color: #888;
text-decoration: none;
transition: color 0.2s ease;
}
.author-link-minimal:hover {
color: #aaa;
}
.read-now-button-minimal {
background: #28a745;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.2s ease;
white-space: nowrap;
}
.read-now-button-minimal:hover {
background: #218838;
}
.expand-toggle-urls {
margin-top: 0.5rem;
background: transparent;
border: none;
color: #646cff;
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem 0;
text-decoration: underline;
}
.expand-toggle-urls:hover {
color: #8088ff;
}
/* Large preview view styles */
.individual-bookmark.large {
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.large-preview-image {
width: 100%;
height: 180px;
background: #1a1a1a;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #333;
position: relative;
}
.large-preview-image:hover {
opacity: 0.9;
}
.large-preview-image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%);
pointer-events: none;
}
.preview-placeholder {
font-size: 3rem;
color: #444;
}
.large-content {
padding: 1.25rem;
}
.large-text {
color: #ccc;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 1rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.large-footer {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.8rem;
color: #888;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.individual-bookmark .bookmark-meta span {
background: #1a1a1a;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
.large-author {
flex: 1;
}
.author-link {
color: #8ab4f8;
text-decoration: none;
}
.author-link:hover { text-decoration: underline; }
.kind-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid #444;
border-radius: 6px;
background: #2a2a2a;
color: #ddd;
}
.kind-icon svg {
font-size: 0.9rem;
color: #646cff;
}
.kind-icon-link {
text-decoration: none;
}
.read-now {
margin-top: 0.75rem;
display: flex;
justify-content: flex-end;
}
.read-now-button {
.large-read-button {
background: #28a745;
color: white;
border: none;
padding: 0.6rem 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
letter-spacing: 0.5px;
transition: background-color 0.2s ease, transform 0.1s ease;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.read-now-button:hover {
.large-read-button:hover {
background: #218838;
}
.read-now-button:active {
transform: translateY(1px);
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -809,4 +1040,307 @@ body {
.private-bookmark {
background: linear-gradient(135deg, #f5f5f5 0%, #e9ecef 100%);
}
.highlights-container {
background: #f5f5f5;
border-color: #ddd;
}
.highlights-header {
background: #fff;
border-color: #ddd;
}
.highlight-item {
background: #fff;
border-color: #ddd;
}
.highlight-quote-icon {
color: #646cff;
}
.highlight-text {
color: #213547;
}
}
/* Highlights Panel Styles */
.highlights-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.highlights-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 1rem;
background: transparent;
border: none;
}
.highlights-container.collapsed .toggle-highlights-btn {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.highlights-container.collapsed .toggle-highlights-btn:hover {
background: #333;
color: #fff;
}
.highlights-container.collapsed .toggle-highlights-btn:active {
transform: translateY(1px);
}
.highlights-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #333;
background: #1e1e1e;
border-radius: 12px 12px 0 0;
}
.highlights-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.highlights-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.highlights-title .count {
color: #888;
font-size: 0.875rem;
}
.highlights-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toggle-underlines-btn,
.toggle-highlights-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.toggle-underlines-btn:hover,
.toggle-highlights-btn:hover {
background: #2a2a2a;
color: #fff;
}
.toggle-underlines-btn:active,
.toggle-highlights-btn:active {
transform: translateY(1px);
}
.highlights-loading,
.highlights-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #888;
text-align: center;
gap: 0.5rem;
}
.highlights-empty svg {
color: #555;
margin-bottom: 0.5rem;
}
.empty-hint {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.highlights-list {
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.highlight-item {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
display: flex;
gap: 0.75rem;
transition: border-color 0.2s ease;
}
.highlight-item:hover {
border-color: #646cff;
}
.highlight-quote-icon {
color: #646cff;
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 0.25rem;
}
.highlight-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.highlight-text {
margin: 0;
padding: 0;
font-style: italic;
color: #ddd;
line-height: 1.6;
border-left: none;
font-size: 0.95rem;
}
.highlight-comment {
margin-top: 0.5rem;
padding: 0.75rem;
background: rgba(100, 108, 255, 0.1);
border-left: 3px solid #646cff;
border-radius: 4px;
font-size: 0.875rem;
color: #ddd;
line-height: 1.5;
}
.highlight-context {
margin-top: 0.5rem;
}
.highlight-context summary {
cursor: pointer;
font-size: 0.875rem;
color: #888;
user-select: none;
transition: color 0.2s ease;
}
.highlight-context summary:hover {
color: #aaa;
}
.context-text {
margin: 0.5rem 0 0 0;
padding: 0.75rem;
background: #252525;
border-radius: 6px;
font-size: 0.875rem;
color: #aaa;
line-height: 1.5;
}
.highlight-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: #888;
flex-wrap: wrap;
}
.highlight-time {
color: #888;
}
.highlight-source {
display: flex;
align-items: center;
gap: 0.375rem;
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
}
.highlight-source:hover {
color: #535bf2;
text-decoration: underline;
}
.highlight-source svg {
font-size: 0.875rem;
}
/* Inline content highlights */
.content-highlight {
background: transparent;
border-bottom: 2px solid #ffd700;
padding: 0.125rem 0;
cursor: help;
transition: all 0.2s ease;
position: relative;
}
.content-highlight:hover {
background: rgba(255, 215, 0, 0.15);
border-bottom-color: #ffed4e;
}
.reader-html .content-highlight,
.reader-markdown .content-highlight {
color: inherit;
text-decoration: none;
}
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight {
background: transparent;
border-bottom-color: #d4af37;
}
.content-highlight:hover {
background: rgba(212, 175, 55, 0.15);
border-bottom-color: #ffd700;
}
.highlight-indicator {
background: rgba(100, 108, 255, 0.15);
border-color: rgba(100, 108, 255, 0.4);
}
}

View File

@@ -0,0 +1,91 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import {
getHighlightText,
getHighlightContext,
getHighlightComment,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightSourceUrl,
getHighlightAttributions
} from 'applesauce-core/helpers'
import { Highlight } from '../types/highlights'
/**
* Deduplicate highlight events by ID
* Since highlights can come from multiple relays, we need to ensure
* we only show each unique highlight once
*/
function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
const byId = new Map<string, NostrEvent>()
for (const event of events) {
if (event?.id && !byId.has(event.id)) {
byId.set(event.id, event)
}
}
return Array.from(byId.values())
}
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) from relays:', relayUrls)
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Deduplicate events by ID
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
// Use applesauce helpers to extract highlight data
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
// Get author from attributions
const author = attributions.find(a => a.role === 'author')?.pubkey
// Get event reference (prefer event pointer, fallback to address pointer)
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights:', error)
return []
}
}

15
src/types/highlights.ts Normal file
View File

@@ -0,0 +1,15 @@
// NIP-84 Highlight types
export interface Highlight {
id: string
pubkey: string
created_at: number
content: string // The highlighted text
tags: string[][]
// Extracted tag values
eventReference?: string // 'e' or 'a' tag
urlReference?: string // 'r' tag
author?: string // 'p' tag with 'author' role
context?: string // surrounding text context
comment?: string // optional comment about the highlight
}

View File

@@ -0,0 +1,208 @@
import React from 'react'
import { Highlight } from '../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
/**
* Find all occurrences of highlight text in the content
*/
export function findHighlightMatches(
content: string,
highlights: Highlight[]
): HighlightMatch[] {
const matches: HighlightMatch[] = []
for (const highlight of highlights) {
if (!highlight.content || highlight.content.trim().length === 0) {
continue
}
const searchText = highlight.content.trim()
let startIndex = 0
// Find all occurrences of this highlight in the content
let index = content.indexOf(searchText, startIndex)
while (index !== -1) {
matches.push({
highlight,
startIndex: index,
endIndex: index + searchText.length
})
startIndex = index + searchText.length
index = content.indexOf(searchText, startIndex)
}
}
// Sort by start index
return matches.sort((a, b) => a.startIndex - b.startIndex)
}
/**
* Apply highlights to text content by wrapping matched text in span elements
*/
export function applyHighlightsToText(
text: string,
highlights: Highlight[]
): React.ReactNode {
const matches = findHighlightMatches(text, highlights)
if (matches.length === 0) {
return text
}
const result: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < matches.length; i++) {
const match = matches[i]
// Skip overlapping highlights (keep the first one)
if (match.startIndex < lastIndex) {
continue
}
// Add text before the highlight
if (match.startIndex > lastIndex) {
result.push(text.substring(lastIndex, match.startIndex))
}
// Add the highlighted text
const highlightedText = text.substring(match.startIndex, match.endIndex)
result.push(
<mark
key={`highlight-${match.highlight.id}-${match.startIndex}`}
className="content-highlight"
data-highlight-id={match.highlight.id}
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
>
{highlightedText}
</mark>
)
lastIndex = match.endIndex
}
// Add remaining text
if (lastIndex < text.length) {
result.push(text.substring(lastIndex))
}
return <>{result}</>
}
// Helper to normalize whitespace for flexible matching
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string): HTMLElement {
const mark = document.createElement('mark')
mark.className = 'content-highlight'
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
return mark
}
// Helper to replace text node with mark element
function replaceTextWithMark(textNode: Text, before: string, after: string, mark: HTMLElement) {
const parent = textNode.parentNode
if (!parent) return
if (before) parent.insertBefore(document.createTextNode(before), textNode)
parent.insertBefore(mark, textNode)
if (after) {
textNode.textContent = after
} else {
parent.removeChild(textNode)
}
}
// Helper to find and mark text in nodes
function tryMarkInTextNodes(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
for (const textNode of textNodes) {
const text = textNode.textContent || ''
const searchIn = useNormalized ? normalizeWhitespace(text) : text
const searchFor = useNormalized ? normalizedSearch : searchText
const index = searchIn.indexOf(searchFor)
if (index === -1) continue
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
let actualIndex = index
if (useNormalized) {
// Map normalized index back to original text
let normalizedIdx = 0
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
actualIndex = i + 1
}
}
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
const mark = createMarkElement(highlight, match)
replaceTextWithMark(textNode, before, after, mark)
return true
}
return false
}
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(html: string, highlights: Highlight[]): string {
if (!html || highlights.length === 0) return html
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
console.log('🔍 applyHighlightsToHTML:', {
htmlLength: html.length,
highlightsCount: highlights.length,
highlightTexts: highlights.map(h => h.content.slice(0, 50))
})
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) continue
console.log('🔍 Processing highlight:', searchText.slice(0, 50))
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true)
if (!found) console.log('⚠️ No match found for highlight')
}
const result = tempDiv.innerHTML
console.log('🔍 HTML highlighting complete:', {
originalLength: html.length,
modifiedLength: result.length,
changed: html !== result
})
return result
}

88
src/utils/imagePreview.ts Normal file
View File

@@ -0,0 +1,88 @@
// Utility to extract preview images from URLs
export const extractYouTubeVideoId = (url: string): string | null => {
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/shorts\/([^&\n?#]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
export const getYouTubeThumbnail = (url: string): string | null => {
const videoId = extractYouTubeVideoId(url)
if (!videoId) return null
// Use maxresdefault for best quality, falls back to hqdefault if not available
return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
}
const extractOgImage = (html: string): string | null => {
// Extract og:image meta tag from HTML
const ogImageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i)
if (ogImageMatch && ogImageMatch[1]) {
return ogImageMatch[1]
}
// Try reversed order (content before property)
const ogImageMatch2 = html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["'][^>]*>/i)
if (ogImageMatch2 && ogImageMatch2[1]) {
return ogImageMatch2[1]
}
return null
}
// Cache for fetched OG images to avoid repeated requests
const ogImageCache = new Map<string, string | null>()
export const fetchOgImage = async (url: string): Promise<string | null> => {
// Check cache first
if (ogImageCache.has(url)) {
return ogImageCache.get(url) || null
}
try {
// Use allorigins.win as a free CORS proxy (no auth required)
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`
const response = await fetch(proxyUrl, {
signal: AbortSignal.timeout(5000) // 5 second timeout
})
if (!response.ok) {
ogImageCache.set(url, null)
return null
}
const data = await response.json()
const html = data.contents
const ogImage = extractOgImage(html)
ogImageCache.set(url, ogImage)
return ogImage
} catch (error) {
console.warn('Failed to fetch OG image for:', url, error)
ogImageCache.set(url, null)
return null
}
}
export const getPreviewImage = (url: string, type: string): string | null => {
// YouTube videos - instant thumbnail
if (type === 'youtube') {
return getYouTubeThumbnail(url)
}
// For other URLs, return null and let component fetch async
return null
}