mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bcaa1998d | ||
|
|
e98dc1c5da | ||
|
|
aa8d3c285d | ||
|
|
9ac8e8f69c | ||
|
|
842bfa5491 | ||
|
|
e2e5d59197 | ||
|
|
0255ff5d03 | ||
|
|
930cd272cb | ||
|
|
2dea3c2a5c | ||
|
|
38b80bc85b | ||
|
|
c0de624fe6 | ||
|
|
1d7ab59272 | ||
|
|
0803417755 | ||
|
|
a602f163fb | ||
|
|
4aa496ec3f | ||
|
|
296600bb0d | ||
|
|
7390104414 | ||
|
|
f4fbc34bc1 | ||
|
|
e83976e5e0 | ||
|
|
0cf7f93482 | ||
|
|
796380ea0d | ||
|
|
3d6403f139 | ||
|
|
57c5be9907 | ||
|
|
bd3193957c | ||
|
|
64efb103a4 | ||
|
|
4afd9ed6d1 | ||
|
|
7e9cdfb0e1 | ||
|
|
bdfb7ca9a6 | ||
|
|
288b96d614 | ||
|
|
99c6a4c23b |
@@ -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.
|
||||
16
README.md
16
README.md
@@ -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
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>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>
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
153
src/components/BookmarkViews/CardView.tsx
Normal file
153
src/components/BookmarkViews/CardView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
71
src/components/BookmarkViews/CompactView.tsx
Normal file
71
src/components/BookmarkViews/CompactView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
94
src/components/BookmarkViews/LargeView.tsx
Normal file
94
src/components/BookmarkViews/LargeView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/components/BookmarkViews/shared.ts
Normal file
4
src/components/BookmarkViews/shared.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
export type IconGetter = (url: string) => IconDefinition
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
src/components/HighlightItem.tsx
Normal file
76
src/components/HighlightItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
131
src/components/HighlightsPanel.tsx
Normal file
131
src/components/HighlightsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
678
src/index.css
678
src/index.css
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
91
src/services/highlightService.ts
Normal file
91
src/services/highlightService.ts
Normal 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
15
src/types/highlights.ts
Normal 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
|
||||
}
|
||||
|
||||
208
src/utils/highlightMatching.tsx
Normal file
208
src/utils/highlightMatching.tsx
Normal 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
88
src/utils/imagePreview.ts
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user