Compare commits

...

55 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
Gigi
5727a38a7e chore: bump version to 0.1.2 2025-10-03 09:37:56 +02:00
Gigi
9046150d1f fix(ui): make sidebar and reader scroll independently
- Remove position: sticky from sidebar
- Set fixed height on two-pane container (calc(100vh - 4rem))
- Add overflow-y: auto to both sidebar and main panes
- Each pane now scrolls independently without affecting the other
- Fix issue where bookmark bar was 'stuck' with long articles
2025-10-03 09:31:04 +02:00
Gigi
53b54c77e7 feat(reader): open bookmark URLs in reader instead of new window
- Change URL links to buttons that open in reader
- Style URL buttons to look like links (cursor, hover, no button appearance)
- Rename 'content-panel' to 'reader' throughout codebase
- Update all CSS classes: content-panel → reader, content-title → reader-title, etc.
- Change empty state text from 'preview' to 'read' to match reader terminology
- Keep things simple and focused on in-app reading experience
2025-10-03 09:30:28 +02:00
Gigi
d6756dc5a1 refactor: remove duplicate formatDate function from helpers.ts
- Keep single formatDate implementation in bookmarkUtils.tsx
- Both BookmarkList and BookmarkItem already import from bookmarkUtils
- Maintain DRY principle by eliminating duplication
2025-10-03 09:27:56 +02:00
Gigi
5ea81bda8e fix(deps): replace relative-time with date-fns for timestamp formatting
- Replace relative-time package (which uses Temporal API) with date-fns
- Update formatDate to use formatDistanceToNow from date-fns
- Remove relative-time type declarations
- Apply fix to both helpers.ts and bookmarkUtils.tsx
- Fix runtime error: relative-time expects Temporal objects, not Date objects
- date-fns provides better compatibility with current JavaScript standards
2025-10-03 09:25:05 +02:00
Gigi
6ad273b5f9 fix(deps): correct relative-time package usage
- Change from calling relativeTime as function to instantiating RelativeTime class
- Use relativeTime.from(date) method instead of relativeTime(date)
- Update TypeScript type definitions to reflect class-based API
- Fix runtime error: 'Cannot call a class as a function'
- Apply fix to both bookmarkUtils.tsx and helpers.ts
2025-10-03 09:22:19 +02:00
Gigi
32bbda0364 docs(readme): update features, project structure, and add TODO section
- Update features list to reflect current functionality
- Add comprehensive project structure documentation
- Add TODO section with prioritized next steps
- Include high priority, medium priority, and nice-to-have items
- Add contributing guidelines
- Keep technical details about private bookmarks implementation
2025-10-03 02:08:38 +02:00
Gigi
857337c748 fix(ui): swap chevron directions for collapse/expand button
- When sidebar is expanded: show chevron RIGHT (to collapse/hide)
- When sidebar is collapsed: show chevron LEFT (to expand/show)
- Update SidebarHeader to use faChevronRight
- Update BookmarkList collapsed state to use faChevronLeft
2025-10-03 02:06:40 +02:00
Gigi
c21c29d5ee fix(ui): ensure all icon buttons remain perfectly square
- Add padding: 0 and box-sizing: border-box to .icon-button
- Add box-sizing: border-box to .profile-avatar
- Update .sidebar-header-bar .toggle-sidebar-btn to use fixed width/height instead of min values
- Add explicit styling for .bookmarks-container.collapsed .toggle-sidebar-btn
- Ensure borders don't add to total dimensions (33px x 33px including borders)
2025-10-03 02:05:19 +02:00
Gigi
0d956ed692 feat(ui): display timestamps as relative time
- Install relative-time package from npm
- Update formatDate functions to use relative-time instead of toLocaleDateString
- Add TypeScript type definitions for relative-time module
- Show human-friendly relative times (e.g., '2 hours ago', 'yesterday')
- Apply to all timestamp displays (bookmark dates, created dates)
2025-10-03 02:03:31 +02:00
Gigi
8fe01d5337 feat(ui): replace user text with profile image in sidebar header
- Replace 'Logged in as: [user]' text with profile avatar
- Use applesauce ProfileModel to fetch user's profile picture
- Display profile image in 33px square (same size as IconButton)
- Show fallback user icon when no profile image available
- Style avatar with same border radius and styling as IconButton
- Add tooltip showing user display name on hover
2025-10-03 02:02:15 +02:00
Gigi
55c4fe9d4e refactor(ui): move user info and logout to sidebar header bar
- Create new SidebarHeader component as bar-shaped container
- Combine collapse button, user info, and logout button in one bar
- Position header bar at top of bookmark sidebar with matching width
- Remove fixed top-right positioning for user header
- Style as cohesive bar with background, border, and spacing
- Update all prop passing from App through Bookmarks to BookmarkList
- Remove old UserHeader component
2025-10-03 02:00:54 +02:00
Gigi
8014ee4ddd refactor(ui): reduce IconButton size by 25%
- Change default size from 44px to 33px (25% reduction)
- Update min-width and min-height in CSS to match
- Apply size reduction to toggle-sidebar-btn as well for consistency
2025-10-03 01:58:42 +02:00
Gigi
365b84ba9d refactor(ui): remove duplicate bookmark title and heading
- Remove bookmark title (h3) from bookmark items
- Remove duplicate h4 heading from individual-bookmarks section
- Keep only the single 'X bookmarks in this list:' line with event link
2025-10-03 01:57:51 +02:00
Gigi
c419679099 refactor(ui): remove horizontal line below collapse button
- Remove border-bottom from bookmarks-header
- Remove padding-bottom for cleaner appearance
2025-10-03 01:56:44 +02:00
Gigi
e644f07828 refactor(ui): move user info to top-right app header
- Create UserHeader component to display user info and logout button
- Move 'Logged in as: user' from sidebar to app-header in top-right
- Remove user info display from BookmarkList and Bookmarks components
- Simplify bookmarks-header layout (only contains collapse button now)
- Update CSS to display user info and logout button inline with proper spacing
2025-10-03 01:56:16 +02:00
Gigi
448c4dac1c feat(bookmarks): classify URLs by type and adjust action buttons
- Add URL classification system (article, video, youtube, image)
- Classify based on domain (youtube) and file extensions
- Update button text: 'READ NOW' for articles, 'WATCH NOW' for videos, 'VIEW NOW' for images
- Update icons: faBookOpen for articles, faPlay for videos, faEye for images
- Apply classification to both individual URL buttons and main action button
2025-10-03 01:53:49 +02:00
Gigi
85695b5934 feat(ui): improve bookmark list heading with event links
- Replace 'Bookmarks (count)' with 'count bookmarks in this list:'
- Replace 'Individual Bookmarks (count):' with 'count bookmarks in this list:'
- Make 'this list' a clickable link to search.dergigi.com/e/{eventId}
- Add event-link CSS styling with blue color and hover effect
2025-10-03 01:52:07 +02:00
Gigi
ef3ce445f5 refactor(ui): move logout button to top-right of app
- Move logout IconButton from sidebar to App component
- Position logout button fixed at top-right corner
- Remove onLogout prop from Bookmarks and BookmarkList components
- Clean up sidebar header by removing logout button
- Add app-header CSS with fixed positioning and high z-index
2025-10-03 01:51:03 +02:00
Gigi
436bbf2b43 refactor(ui): replace logout button text with icon button
- Replace text logout buttons with IconButton component
- Use faRightFromBracket icon for a cleaner, more minimal interface
- Apply changes to both loading state and normal state
- Maintain consistent styling with ghost variant
2025-10-03 01:48:45 +02:00
Gigi
0c2f528a23 refactor(ui): remove 'Your Bookmarks' heading
- Remove the 'Your Bookmarks (count)' heading from the sidebar
- Keep only the user info and action buttons for a cleaner interface
2025-10-03 01:47:20 +02:00
Gigi
d2cf27db42 refactor(ui): remove header text from app
- Remove 'Markr' title and 'A minimal nostr bookmark client' subtitle
- Clean up the app header for a more minimal interface
2025-10-03 01:46:24 +02:00
Gigi
53a6c86d8a feat(ui): add collapse/expand functionality for bookmarks sidebar
- Add toggle button to collapse/expand the bookmarks sidebar completely
- Sidebar collapses to 60px width showing only expand button
- Main content area expands to fill available space when sidebar collapsed
- Smooth transitions when toggling between states
- Use FontAwesome chevron icons for visual feedback
- Preserve all functionality in both collapsed and expanded states
2025-10-03 01:45:42 +02:00
Gigi
0b058440bc refactor(components): improve type safety and simplify IconButton
- Add proper type guards in ContentWithResolvedProfiles to avoid type assertions
- Remove href/link functionality from IconButton component for simplification
- Replace 'as any' with proper type narrowing using type predicates
2025-10-03 01:43:13 +02:00
Gigi
0964156bcc feat(bookmarks): sort by added_at (recently added first), fallback to event time\n\n- Track synthetic added_at on processed items\n- Keep order aligned with append semantics from Kind 10003 guidance (newest at end)\n- Cite: https://nostrbook.dev/kinds/10003 2025-10-03 01:41:11 +02:00
34 changed files with 2936 additions and 394 deletions

View File

@@ -0,0 +1,16 @@
---
description: documentation that's useful when dealing with bookmark events (kind:10003 or kind:30003) or anything related to NIP-51
alwaysApply: false
---
Read the nostrbook to understand how bookmarks work:
- https://nostrbook.dev/kinds/10003
- https://nostrbook.dev/kinds/30003
They are defined in NIP-51:
- https://github.com/nostr-protocol/nips/blob/master/51.md
Also refer to the applesauce bookmark helpers:
- https://github.com/hzrd149/applesauce/blob/17c9dbb0f2c263e2ebd01729ea2fa138eca12bd1/packages/core/src/helpers/bookmarks.ts
Make sure to always use applesauce, and use it properly.

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,12 +1,21 @@
# Markr
# Boris
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
## Features
- **Nostr Authentication**: Connect using your nostr account
- **Nostr Authentication**: Connect using your nostr account via browser extension
- **Bookmark Display**: View your nostr bookmarks as per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)
- **Minimal UI**: Clean, simple interface focused on bookmark management
- **Content Classification**: Automatically detect and classify URLs (articles, videos, YouTube, images)
- **Reader Mode**: View article content inline with readable formatting
- **Collapsible Sidebar**: Expand/collapse bookmark list for focused reading
- **Profile Integration**: Display user profile images using applesauce ProfileModel
- **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
@@ -20,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:
@@ -46,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
@@ -63,11 +74,36 @@ yarn dev
```
src/
├── components/
│ ├── Login.tsx # Authentication component
── Bookmarks.tsx # Bookmark display component
├── App.tsx # Main application component
├── main.tsx # Application entry point
└── index.css # Global styles
│ ├── Login.tsx # Authentication component
── Bookmarks.tsx # Main bookmarks view with layout
│ ├── BookmarkList.tsx # Bookmark list sidebar
│ ├── 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
│ └── kindIcon.ts # Kind-specific icon mapping
├── services/
│ ├── bookmarkService.ts # Main bookmark fetching orchestration
│ ├── 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/
│ ├── bookmarkUtils.tsx # Bookmark rendering utilities
│ └── helpers.ts # General helper functions
├── App.tsx # Main application component
├── main.tsx # Application entry point
└── index.css # Global styles
```
### Private (hidden) bookmarks (Amethyst-style)
@@ -108,15 +144,42 @@ pnpm build
yarn build
```
## TODO
### High Priority
- [ ] **Mobile Responsive Design**: Optimize sidebar and content panel for mobile devices
- [ ] **Keyboard Shortcuts**: Add keyboard navigation (collapse sidebar, navigate bookmarks)
- [ ] **Search & Filter**: Add ability to search bookmarks by title, URL, or content
- [ ] **Error Handling**: Improve error states and retry logic for failed fetches
- [ ] **Loading States**: Better skeleton screens and loading indicators
### Medium Priority
- [ ] **Bookmark Creation**: Add ability to create new bookmarks
- [ ] **Bookmark Editing**: Edit existing bookmark metadata and tags
- [ ] **Bookmark Deletion**: Remove bookmarks from lists
- [ ] **Sorting Options**: Sort by date, title, kind, or custom order
- [ ] **Bulk Actions**: Select and perform actions on multiple bookmarks
- [ ] **Video Embeds**: Inline YouTube and video playback for video bookmarks
### Nice to Have
- [ ] **Dark/Light Mode Toggle**: User preference for color scheme
- [ ] **Export Functionality**: Export bookmarks as JSON, CSV, or HTML
- [ ] **Import Bookmarks**: Import from browser bookmarks or other formats
- [ ] **Tags & Categories**: Better organization with custom tags
- [ ] **Bookmark Collections**: Create and manage custom bookmark collections
- [ ] **Offline Support**: Cache bookmarks for offline viewing
- [ ] **Share Bookmarks**: Generate shareable links to bookmark lists
- [ ] **Performance Optimization**: Virtual scrolling for large bookmark lists
- [ ] **Browser Extension**: Quick bookmark saving from any page
## Contributing
This is a minimal MVP. Future enhancements could include:
Contributions are welcome! Please feel free to submit a Pull Request. Make sure to:
- Bookmark creation and editing
- Bookmark organization and tagging
- Search functionality
- Export capabilities
- Mobile-responsive design improvements
- Follow the existing code style
- Keep files under 210 lines
- Use conventional commits
- Run linter and type checks before submitting
## License

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>

19
kind-icons.txt Normal file
View File

@@ -0,0 +1,19 @@
kind:0 = fa-circle-user
kind:1 = fa-feather
kind:6 = fa-retweet
kind:7 = fa-heart
kind:20 = fa-image
kind:21 = fa-video
kind:22 = fa-video
kind:1063 = fa-file
kind:1337 = fa-laptop-code
kind:1617 = fa-code-pull-request
kind:1621 = fa-bug
kind:1984 = fa-exclamation-triangle
kind:9735 = fa-bolt
kind:9321 = fa-cloud-bolt
kind:9802 = fa-highlighter
kind:30023 = fa-newspaper
kind:10000 = fa-eye-slash
kind:10001 = fa-thumbtack
kind:10003 = fa-bookmark

661
node_modules/.package-lock.json generated vendored
View File

@@ -1,6 +1,6 @@
{
"name": "markr",
"version": "0.0.2",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -1541,9 +1541,17 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -1579,14 +1587,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.25",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz",
"integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1818,7 +1824,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"dev": true,
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
@@ -2476,6 +2481,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2503,6 +2518,36 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-reference-invalid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2523,6 +2568,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2556,9 +2611,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2901,6 +2965,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-util-is-identifier-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3193,6 +3267,56 @@
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"license": "MIT"
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"style-to-js": "^1.0.0",
"unist-util-position": "^5.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3269,6 +3393,46 @@
"dev": true,
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
"license": "MIT"
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-alphanumerical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3292,6 +3456,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -3497,6 +3671,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -3549,6 +3733,167 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-jsx": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-stringify-position": "^4.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdxjs-esm": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -3563,6 +3908,27 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-markdown": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
@@ -3676,6 +4042,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -4304,6 +4791,31 @@
"node": ">=6"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4422,6 +4934,16 @@
"node": ">= 0.8.0"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4478,6 +5000,33 @@
"react": "^18.3.1"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4504,6 +5053,24 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -4520,6 +5087,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-rehype": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"mdast-util-to-hast": "^13.0.0",
"unified": "^11.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
@@ -4713,6 +5297,30 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -4739,6 +5347,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-to-js": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
"integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==",
"license": "MIT",
"dependencies": {
"style-to-object": "1.0.9"
}
},
"node_modules/style-to-object": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz",
"integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.4"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -4772,6 +5398,16 @@
"node": ">=8.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -4873,6 +5509,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",

15
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",
@@ -17,6 +17,7 @@
"applesauce-loaders": "^3.1.0",
"applesauce-react": "^3.1.0",
"applesauce-relay": "^3.1.0",
"date-fns": "^4.1.0",
"nostr-tools": "^2.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -2602,6 +2603,16 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "markr",
"version": "0.1.1",
"name": "boris",
"version": "0.1.4",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {
@@ -19,6 +19,7 @@
"applesauce-loaders": "^3.1.0",
"applesauce-react": "^3.1.0",
"applesauce-relay": "^3.1.0",
"date-fns": "^4.1.0",
"nostr-tools": "^2.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -63,17 +63,12 @@ function App() {
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<div className="app">
<header>
<h1>Markr</h1>
<p>A minimal nostr bookmark client</p>
</header>
{!isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} />
) : (
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
onLogout={() => setIsAuthenticated(false)}
/>
)}
</div>

View File

@@ -1,35 +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 } 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])
@@ -47,6 +54,19 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// use helper from kindIcon.ts
const getIconForUrlType = (url: string) => {
const classification = classifyUrl(url)
switch (classification.type) {
case 'youtube':
case 'video':
return faPlay
case 'image':
return faEye
default:
return faBookOpen
}
}
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
if (!hasUrls) return
const firstUrl = extractedUrls[0]
@@ -58,120 +78,28 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
}
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) => (
<div key={urlIndex} className="url-row">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="bookmark-url"
>
{url}
</a>
<IconButton
icon={faBookOpen}
ariaLabel="Read now"
title="Read now"
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>
)}
const sharedProps = {
bookmark,
index,
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
}
{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 === 'compact') {
return <CompactView {...sharedProps} />
}
{hasUrls && (
<div className="read-now">
<button className="read-now-button" onClick={handleReadNow}>
READ NOW
</button>
</div>
)}
</div>
)
if (viewMode === 'large') {
const previewImage = instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
}
return <CardView {...sharedProps} />
}

View File

@@ -1,36 +1,54 @@
import React from 'react'
import { Bookmark, ActiveAccount } from '../types/bookmarks'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'
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[]
activeAccount: ActiveAccount | null
onLogout: () => void
formatUserDisplay: () => string
onSelectUrl?: (url: string) => void
isCollapsed: boolean
onToggleCollapse: () => void
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
bookmarks,
activeAccount,
onLogout,
formatUserDisplay,
onSelectUrl
onSelectUrl,
isCollapsed,
onToggleCollapse,
onLogout,
viewMode,
onViewModeChange
}) => {
return (
<div className="bookmarks-container">
<div className="bookmarks-header">
<div>
<h2>Your Bookmarks ({bookmarks.length})</h2>
{activeAccount && (
<p className="user-info">Logged in as: {formatUserDisplay()}</p>
)}
</div>
<button onClick={onLogout} className="logout-button">
Logout
if (isCollapsed) {
return (
<div className="bookmarks-container collapsed">
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Expand bookmarks sidebar"
aria-label="Expand bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
</div>
)
}
return (
<div className="bookmarks-container">
<SidebarHeader
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
{bookmarks.length === 0 ? (
<div className="empty-state">
@@ -41,10 +59,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div className="bookmarks-list">
{bookmarks.map((bookmark, index) => (
<div key={`${bookmark.id}-${index}`} className="bookmark-item">
<h3>{bookmark.title}</h3>
{bookmark.bookmarkCount && (
<p className="bookmark-count">
{bookmark.bookmarkCount} bookmarks in this list
{bookmark.bookmarkCount} bookmarks in{' '}
<a
href={`https://search.dergigi.com/e/${bookmark.id}`}
target="_blank"
rel="noopener noreferrer"
className="event-link"
>
this list
</a>
:
</p>
)}
{bookmark.urlReferences && bookmark.urlReferences.length > 0 && (
@@ -59,10 +85,15 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
)}
{bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && (
<div className="individual-bookmarks">
<h4>Individual Bookmarks ({bookmark.individualBookmarks.length}):</h4>
<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

@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
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
@@ -17,22 +20,26 @@ 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()
// Use ProfileModel to get user profile information
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
useEffect(() => {
console.log('Bookmarks useEffect triggered')
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')
}
@@ -56,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)
@@ -72,56 +95,25 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const formatUserDisplay = () => {
if (!activeAccount) return 'Unknown User'
// Debug profile loading
console.log('Profile data:', profile)
console.log('Active account pubkey:', activeAccount.pubkey)
// Use profile data from ProfileModel if available
if (profile?.name) {
return profile.name
}
if (profile?.display_name) {
return profile.display_name
}
if (profile?.nip05) {
return profile.nip05
}
// Fallback to formatted public key to avoid sticky loading text
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
if (loading) {
return (
<div className="bookmarks-container">
<div className="bookmarks-header">
<div>
<h2>Your Bookmarks</h2>
{activeAccount && (
<p className="user-info">Logged in as: {formatUserDisplay()}</p>
)}
</div>
<button onClick={onLogout} className="logout-button">
Logout
</button>
</div>
<div className="loading">Loading bookmarks...</div>
</div>
)
}
return (
<div className="two-pane">
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
activeAccount={activeAccount || null}
onLogout={onLogout}
formatUserDisplay={formatUserDisplay}
onSelectUrl={handleSelectUrl}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
<div className="pane main">
@@ -131,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,20 +12,155 @@ 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="content-panel empty">
<p>Select a bookmark to preview its content.</p>
<div className="reader empty">
<p>Select a bookmark to read its content.</p>
</div>
)
}
if (loading) {
return (
<div className="content-panel loading">
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
<span>Loading content</span>
@@ -32,19 +169,31 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ loading, title, html, markd
)
}
const hasHighlights = relevantHighlights.length > 0
return (
<div className="content-panel">
{title && <h2 className="content-title">{title}</h2>}
<div className="reader">
{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="content-markdown">
<div ref={contentRef} className="reader-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{highlightedMarkdown}
</ReactMarkdown>
</div>
) : html ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: html }} />
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div className="content-panel empty">
<div className="reader empty">
<p>No readable content found for this URL.</p>
</div>
)}

View File

@@ -11,17 +11,19 @@ const ContentWithResolvedProfiles: React.FC<Props> = ({ content }) => {
const matches = extractNprofilePubkeys(content)
const decoded = matches
.map((m) => {
try { return decode(m) } catch { return undefined }
try { return decode(m) } catch { return undefined as undefined }
})
.filter(Boolean)
.filter((v): v is ReturnType<typeof decode> => Boolean(v))
const lookups = decoded.map((res) => getPubkeyFromDecodeResult(res as any)).filter(Boolean) as string[]
const lookups = decoded
.map((res) => getPubkeyFromDecodeResult(res))
.filter((v): v is string => typeof v === 'string')
const profiles = lookups.map((pubkey) => ({ pubkey, profile: useEventModel(Models.ProfileModel, [pubkey]) }))
let rendered = content
matches.forEach((m, i) => {
const pk = getPubkeyFromDecodeResult(decoded[i] as any)
const pk = getPubkeyFromDecodeResult(decoded[i])
const found = profiles.find((p) => p.pubkey === pk)
const name = found?.profile?.name || found?.profile?.display_name || found?.profile?.nip05 || `${pk?.slice(0,8)}...`
if (name) rendered = rendered.replace(m, `@${name}`)

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

@@ -9,9 +9,6 @@ interface IconButtonProps {
ariaLabel?: string
variant?: 'primary' | 'success' | 'ghost'
size?: number
href?: string
target?: string
rel?: string
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -20,35 +17,15 @@ const IconButton: React.FC<IconButtonProps> = ({
title,
ariaLabel,
variant = 'ghost',
size = 44,
href,
target,
rel
size = 33
}) => {
const commonProps = {
className: `icon-button ${variant}`,
title,
'aria-label': ariaLabel || title,
style: { width: size, height: size }
} as const
if (href) {
return (
<a
{...(commonProps as any)}
href={href}
target={target || '_blank'}
rel={rel || 'noopener noreferrer'}
>
<FontAwesomeIcon icon={icon} />
</a>
)
}
return (
<button
{...(commonProps as any)}
className={`icon-button ${variant}`}
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
style={{ width: size, height: size }}
>
<FontAwesomeIcon icon={icon} />
</button>

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

@@ -0,0 +1,89 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
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, viewMode, onViewModeChange }) => {
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const getProfileImage = () => {
return profile?.picture || null
}
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
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>
<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>
</>
)
}
export default SidebarHeader

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,8 @@ export const processApplesauceBookmarks = (
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate
isPrivate,
added_at: Math.floor(Date.now() / 1000)
}))
}
@@ -80,7 +81,8 @@ export const processApplesauceBookmarks = (
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate
isPrivate,
added_at: Math.floor(Date.now() / 1000)
}))
}

View File

@@ -113,7 +113,8 @@ export const fetchBookmarks = async (
...hydrateItems(privateItemsAll, idToEvent)
])
// Sort individual bookmarks by timestamp (newest first)
// Sort individual bookmarks by "added" timestamp first (most recently added first),
// falling back to event created_at when unknown.
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
@@ -121,7 +122,7 @@ export const fetchBookmarks = async (
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,

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

View File

@@ -40,6 +40,8 @@ export interface IndividualBookmark {
type: 'event' | 'article'
isPrivate?: boolean
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)
added_at?: number
}
export interface ActiveAccount {

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

@@ -1,10 +1,12 @@
import React from 'react'
import { formatDistanceToNow } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
export const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString()
const date = new Date(timestamp * 1000)
return formatDistanceToNow(date, { addSuffix: true })
}
// Component to render content with resolved nprofile names

View File

@@ -1,7 +1,3 @@
export const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString()
}
// Extract pubkeys from nprofile strings in content
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
@@ -10,4 +6,34 @@ export const extractNprofilePubkeys = (content: string): string[] => {
return Array.from(unique)
}
export type UrlType = 'video' | 'image' | 'youtube' | 'article'
export interface UrlClassification {
type: UrlType
buttonText: string
}
export const classifyUrl = (url: string): UrlClassification => {
const urlLower = url.toLowerCase()
// Check for YouTube
if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be')) {
return { type: 'youtube', buttonText: 'WATCH NOW' }
}
// Check for video extensions
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v']
if (videoExtensions.some(ext => urlLower.includes(ext))) {
return { type: 'video', buttonText: 'WATCH NOW' }
}
// Check for image extensions
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']
if (imageExtensions.some(ext => urlLower.includes(ext))) {
return { type: 'image', buttonText: 'VIEW NOW' }
}
// Default to article
return { type: 'article', buttonText: 'READ NOW' }
}

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
}