Compare commits

...

334 Commits

Author SHA1 Message Date
Gigi
8da0a06711 chore: bump version to 0.6.20 2025-10-15 21:53:06 +02:00
Gigi
be8d857223 Merge pull request #12 from dergigi/bookmark-filter-buttons
Add bookmark filter buttons by content type
2025-10-15 21:52:37 +02:00
Gigi
d50bcd700e fix(ui): make highlight button fixed to viewport 2025-10-15 21:51:24 +02:00
Gigi
820ab1d902 fix(ui): make highlight button sticky and always visible
- Wrap button in sticky positioned container with height: 0
- Button now floats and stays visible while scrolling
- Remains within reader pane boundaries on desktop
- Uses flexbox to align button to the right side
2025-10-15 21:48:41 +02:00
Gigi
f5e9e5bf61 fix(ui): position highlight button inside reader pane
- Move HighlightButton from fixed viewport positioning to absolute positioning within main pane
- Add position: relative to .pane.main for both desktop and mobile layouts
- Button now stays within the article/reader view instead of floating outside on desktop
- Maintains proper z-index and responsive behavior
2025-10-15 21:47:28 +02:00
Gigi
40b43532e8 style: use faLink icon for external articles
- Replace faArrowUpRightFromSquare with simpler faLink icon
- More concise visual representation for external article links
2025-10-15 21:40:31 +02:00
Gigi
51a3008730 feat: add separate filter for external articles with distinct icon
- Add 'external' type to differentiate external article links from nostr-native articles
- Nostr-native articles (kind:30023) use newspaper icon
- External article links use arrow-up-right icon (faArrowUpRightFromSquare)
- Add new 'External Articles' filter button
- Update classification logic and icon display accordingly
2025-10-15 21:39:10 +02:00
Gigi
e30cbc72c3 style: dramatically reduce whitespace around bookmark filters
- Remove all padding from filter buttons
- Reduce top padding from 0.75rem to 0.25rem
- Reduce bottom margin from 0.5rem to 0.25rem
- Much tighter, more compact layout
2025-10-15 21:35:44 +02:00
Gigi
6f913262f4 style: reduce whitespace around bookmark filters on /me page
- Reduce padding on bookmark filters from 1rem to 0.5rem
- Reduce top padding of tab content when filters are present
- Tighten spacing for more compact layout
2025-10-15 21:35:11 +02:00
Gigi
0f0462e6ac feat: add bookmark filters to /me page bookmarks tab
- Add filter buttons to reading-list tab in Me component
- Apply same filtering logic as main bookmarks sidebar
- Center-align filters and remove border for cleaner look
- Show empty state message when no bookmarks match filter
2025-10-15 21:24:19 +02:00
Gigi
e353f0e2d6 style: refine bookmark filter buttons
- Make buttons smaller (32px) and more compact
- Remove borders for cleaner look
- Active state uses primary color without background
- Match icon styling used on bookmark cards
2025-10-15 21:19:16 +02:00
Gigi
ee1365d3ca feat: add bookmark filter buttons by content type
- Add BookmarkFilters component with icon-based filter buttons
- Create bookmarkTypeClassifier utility for content type classification
- Filter bookmarks by article, video, note, or web types
- Apply filters across all bookmark lists (private, public, web, sets)
- Style filter buttons to match existing UI design
2025-10-15 21:17:27 +02:00
Gigi
a215d0b026 refactor: remove lock icon from individual bookmarks
- Private bookmarks are now grouped in 'Private Bookmarks' section
- No need for redundant lock icon on each individual bookmark
- Cleaner UI with less visual clutter
- Removed faUserLock import and conditional rendering from all three views
2025-10-15 20:37:40 +02:00
Gigi
b8d76c0bd8 feat: move encrypted legacy bookmarks to Private Bookmarks section
- Only non-encrypted legacy bookmarks (kind:30001) now appear in Legacy section
- Encrypted legacy bookmarks are grouped with other private bookmarks
- Improves organization by grouping by privacy level rather than source
2025-10-15 20:36:00 +02:00
Gigi
233169b082 feat: improve bookmark section labels for clarity
- Capitalize all bookmark section labels for consistency
- Change 'Old Bookmarks (Legacy)' to 'Legacy Bookmarks' for cleaner look
- Updated labels in both BookmarkList and Me components
2025-10-15 20:35:19 +02:00
Gigi
72b9a04cd2 docs: update CHANGELOG.md for v0.6.19 2025-10-15 20:01:43 +02:00
Gigi
432715efb6 chore: bump version to 0.6.19 2025-10-15 20:01:07 +02:00
Gigi
8b2b954dde fix: prevent useBookmarksData from overwriting external URL highlights
The issue was that useBookmarksData was fetching general highlights
whenever there was no naddr, which included external URL routes (/r/*).
This caused the URL-specific highlights loaded by useExternalUrlLoader
to be overwritten after a couple seconds.

Now we skip fetching general highlights when viewing external URLs,
letting useExternalUrlLoader manage those highlights instead.
2025-10-15 19:59:54 +02:00
Gigi
c2d2bd8106 fix: prevent highlights from disappearing on external URLs
- Improve error handling in fetchHighlightsForUrl to prevent silent failures
- Remove redundant setHighlights call that was overwriting streamed highlights
- Add logging to help diagnose highlight fetching issues
- Isolate rebroadcast errors so they don't break highlight display
2025-10-15 19:56:07 +02:00
Gigi
a5c3085c59 docs: update CHANGELOG.md for v0.6.18 2025-10-15 19:49:13 +02:00
Gigi
c0332f08d6 chore: bump version to 0.6.18 2025-10-15 19:48:00 +02:00
Gigi
38a1d6caec fix: always show PWA install section with disabled button states 2025-10-15 19:43:44 +02:00
Gigi
39dd607e7b style: make zap preset buttons expand to match slider width on desktop 2025-10-15 19:43:11 +02:00
Gigi
9dc0db3e06 fix: always show App & Airplane Mode section regardless of PWA status 2025-10-15 19:42:27 +02:00
Gigi
b1eb58a385 fix: display zap split share and percentage on same line 2025-10-15 19:41:26 +02:00
Gigi
f3c6404f76 refactor: simplify zap split labels and update terminology 2025-10-15 19:39:04 +02:00
Gigi
1a42a6422d fix: disable PWA install button when installation is not possible on device 2025-10-15 19:37:57 +02:00
Gigi
2e2de4ccda docs: update CHANGELOG.md for v0.6.17 2025-10-15 19:36:50 +02:00
Gigi
4325d3a519 chore: bump version to 0.6.17 2025-10-15 19:35:36 +02:00
Gigi
51115c5f68 refactor: move Default Highlight Visibility back after Paragraph Alignment 2025-10-15 19:34:03 +02:00
Gigi
2aa6fe860b refactor: merge Layout & Navigation and Startup & Behavior into Layout & Behavior section 2025-10-15 19:33:22 +02:00
Gigi
86f39eacf8 refactor: move Default Highlight Visibility after Font Size in reading settings 2025-10-15 19:32:13 +02:00
Gigi
d15daef3ea fix: properly align Font Size buttons to right using setting-control wrapper 2025-10-15 19:31:04 +02:00
Gigi
281c70cdea style: align Font Size buttons to the right to match highlight color buttons 2025-10-15 19:29:22 +02:00
Gigi
d6d6087543 refactor: move Layout & Navigation section below Zap Splits 2025-10-15 19:28:33 +02:00
Gigi
d06e38bc19 refactor: reorder settings sections - move Startup & Behavior after Zap Splits 2025-10-15 19:28:05 +02:00
Gigi
cfc8eb0bbc feat: use friend-highlight color at 50% opacity for right side of zap sliders 2025-10-15 19:26:43 +02:00
Gigi
b85f9b79c3 feat: add zaps.svg illustration to Zap Splits section with responsive layout 2025-10-15 19:26:10 +02:00
Gigi
1b0045c737 refactor: add 50% opacity to slider track highlight color 2025-10-15 19:24:27 +02:00
Gigi
3dc8d7d440 fix: improve lightning bolt icon centering and sizing on slider thumbs 2025-10-15 19:18:57 +02:00
Gigi
bf9ca48d64 feat: replace slider thumb circles with lightning bolt icons for zap splits 2025-10-15 19:17:55 +02:00
Gigi
70441f3d59 refactor: use default highlight color for zap slider 50% mark instead of primary color 2025-10-15 19:16:16 +02:00
Gigi
431f28e861 refactor: update zap split description to match offline-first paragraph style 2025-10-15 19:15:43 +02:00
Gigi
3b1fc095c4 feat: add 50% visual indicators to zap split sliders with gradient background and tick marks 2025-10-15 19:15:14 +02:00
Gigi
9a6c7a29d0 feat: restrict settings page width to 900px matching article view max-width 2025-10-15 19:13:22 +02:00
Gigi
c1d173f40e fix: move offline-first paragraph inside flex container to prevent overlap with image 2025-10-15 19:12:17 +02:00
Gigi
f03ec5df8c refactor: move 'Use local relays as cache' checkbox after local relay paragraph 2025-10-15 19:11:36 +02:00
Gigi
6c74a12636 feat: add offline-first description at the beginning of App & Airplane Mode section 2025-10-15 19:10:38 +02:00
Gigi
39797803d3 refactor: rename section title from 'PWA & Flight Mode' to 'App & Airplane Mode' 2025-10-15 19:07:54 +02:00
Gigi
c66c1e928d refactor: swap paragraph order - Note about relays first, Install Boris second 2025-10-15 19:06:55 +02:00
Gigi
f934b641bb refactor: replace IconButton with plain icon for clear cache trash button 2025-10-15 19:06:22 +02:00
Gigi
1128a11603 refactor: reorder PWA settings - checkboxes first, then paragraphs, then install button 2025-10-15 19:05:03 +02:00
Gigi
9f90718918 refactor: reduce clear cache button size from 28 to 20 2025-10-15 19:03:43 +02:00
Gigi
067a07fc00 refactor: further reduce spacing between PWA settings elements from 0.5rem to 0.25rem 2025-10-15 19:02:09 +02:00
Gigi
1811cf045e refactor: split PWA description into two paragraphs and update text 2025-10-15 19:01:34 +02:00
Gigi
270b4f429f refactor: remove 'Install Boris as a PWA' title from settings section 2025-10-15 18:59:48 +02:00
Gigi
380acbb55f feat: hide PWA SVG illustration on mobile devices 2025-10-15 18:59:24 +02:00
Gigi
c384f0b4fb refactor: reduce spacing between PWA settings elements from 1rem to 0.5rem 2025-10-15 18:58:33 +02:00
Gigi
27cf393a03 refactor: set PWA SVG width to 30% for responsive scaling 2025-10-15 18:57:35 +02:00
Gigi
8831726913 refactor: reduce PWA SVG size to 150px width 2025-10-15 18:57:02 +02:00
Gigi
2f4327874c refactor: format and clean up pwa.svg with proper indentation and Inkscape metadata 2025-10-15 18:55:23 +02:00
Gigi
483845962e refactor: combine relay info text with PWA description into single paragraph 2025-10-15 18:53:38 +02:00
Gigi
c44b1d6349 refactor: set PWA SVG height to 100% with auto width for full vertical span 2025-10-15 18:52:20 +02:00
Gigi
79f28a142d refactor: increase PWA SVG illustration size from 120px to 200px 2025-10-15 18:51:54 +02:00
Gigi
02dd537cd9 refactor: make PWA SVG illustration span full section height 2025-10-15 18:50:50 +02:00
Gigi
5af1f14a0b refactor: merge PWA and Flight Mode settings into single section 2025-10-15 18:49:25 +02:00
Gigi
664f59a9cc refactor: show PWA button state with checkmark when installed instead of hiding section 2025-10-15 18:48:03 +02:00
Gigi
7d3641aab7 refactor: simplify PWA install text to 'Install Boris as a PWA' 2025-10-15 18:45:38 +02:00
Gigi
7924df4c67 refactor: simplify PWA section title to 'App' 2025-10-15 18:45:25 +02:00
Gigi
68a8eed4af refactor: expand PWA install text to include full terminology 2025-10-15 18:45:07 +02:00
Gigi
887db84ce7 refactor: change PWA section title to 'Boris as an App' 2025-10-15 18:44:37 +02:00
Gigi
05348fbfeb feat: add pwa.svg illustration to PWA settings section 2025-10-15 18:44:18 +02:00
Gigi
38eb6716f8 refactor: move PWA settings above Relays section 2025-10-15 18:42:31 +02:00
Gigi
d7f9cd30eb feat: always show PWA install button for testing/styling purposes 2025-10-15 18:41:40 +02:00
Gigi
922d041e0e docs: update CHANGELOG.md for v0.6.16 2025-10-15 18:31:46 +02:00
Gigi
76f4588c85 chore: bump version to 0.6.16 2025-10-15 18:30:36 +02:00
Gigi
e163b92a7e fix: remove unused handleCancelDelete function
Removed handleCancelDelete as it's no longer needed after switching
from ConfirmDialog modal to inline confirmation
2025-10-15 18:30:15 +02:00
Gigi
11925a42b0 style: make trash icon red in delete confirmation
Change from CompactButton to regular button with explicit red color
styling so the trash icon inherits the red color (rgb(220 38 38))
2025-10-15 18:21:44 +02:00
Gigi
acf45530ca refactor: replace delete dialog with inline confirmation
Replace popup modal with inline confirmation UI:
- When delete is clicked, show red trash icon with 'Confirm?' text
- Clicking red trash icon again confirms deletion
- Confirmation appears to left of three-dot menu
- Click outside or reopen menu cancels confirmation
- Remove ConfirmDialog component dependency
2025-10-15 18:15:55 +02:00
Gigi
3792ad6abf refactor: move Highlight Style, Paragraph Alignment, and Default Highlight Visibility to top
Final order:
1. Highlight Style
2. Paragraph Alignment
3. Default Highlight Visibility
4. Reading Font + Font Size
5. My Highlights color
6. Friends Highlights color
7. Nostrverse Highlights color
8. Show highlights checkbox
9. Preview
2025-10-15 17:59:29 +02:00
Gigi
bf98b307e8 style: align setting buttons vertically with fixed label width
Add min-width: 220px to inline setting labels to create consistent
'tab stops' so buttons align vertically regardless of label length.
Remove constraint on mobile where settings stack vertically.
2025-10-15 17:57:33 +02:00
Gigi
d15392f41e refactor: reorder settings with Highlight Style and Paragraph Alignment above Default Highlight Visibility
Final order:
1. Reading Font + Font Size
2. My Highlights color
3. Friends Highlights color
4. Nostrverse Highlights color
5. Highlight Style
6. Paragraph Alignment
7. Default Highlight Visibility
8. Show highlights checkbox
9. Preview
2025-10-15 17:55:58 +02:00
Gigi
f26a024255 refactor: reorder Reading & Display settings
- Highlight Style (first)
- Paragraph Alignment (second)
- Reading Font + Font Size (third)

Better logical grouping with text styling before font selection
2025-10-15 17:54:08 +02:00
Gigi
bf9f894c0d refactor: improve delete dialog UI and simplify message
- Reduce verbose warning text to simple 'This will delete your highlight'
- Add proper CSS styling for confirm dialog with backdrop blur
- Center-aligned text and circular icon with color-coded background
- Modern button styling with proper hover states
- Full-width buttons in action row
- Theme-aware colors using CSS variables
2025-10-15 17:53:26 +02:00
Gigi
53a7b7d1c5 docs: update CHANGELOG.md for v0.6.15 2025-10-15 17:51:38 +02:00
Gigi
a12a883cc6 chore: bump version to 0.6.15 2025-10-15 17:50:47 +02:00
Gigi
0cf076b010 chore: change default paragraph alignment to justify 2025-10-15 17:45:10 +02:00
Gigi
e2c712033f feat: add paragraph alignment setting
- Add paragraphAlignment setting (left/justify) to UserSettings interface
- Add UI control with icon buttons in ReadingDisplaySettings
- Apply alignment via CSS variable to reader content and preview
- Default to left-aligned to maintain current behavior
- Keep headings always left-aligned for better readability
2025-10-15 17:43:31 +02:00
Gigi
e38237ca8e fix: resolve linter errors for unused variables 2025-10-15 17:39:22 +02:00
Gigi
1fff44fc6c chore: update button label from 'Open on Nostr' to 'Open with njump' 2025-10-15 17:37:38 +02:00
Gigi
4e50073e07 feat: update gateway config to use nostr.at and define ants.sh as search portal 2025-10-15 17:31:39 +02:00
Gigi
0ce64fe83f feat: open three-dot menus upward when insufficient space below 2025-10-15 17:28:44 +02:00
Gigi
ef848aa93e feat: update external article menu with Share (Boris link) and Search options 2025-10-15 17:25:29 +02:00
Gigi
67b287d75d feat: add Search option to article menu for ants.sh portal 2025-10-15 17:20:59 +02:00
Gigi
b795dfd2c6 feat: add Copy Link and Copy Original options to article menu 2025-10-15 17:19:20 +02:00
Gigi
c68d855983 feat: add Share and Share Original options to article menu 2025-10-15 17:18:36 +02:00
Gigi
fb1c19e64b docs: update CHANGELOG.md for v0.6.14 2025-10-15 16:30:11 +02:00
Gigi
384c16e29d chore: bump version to 0.6.14 2025-10-15 16:28:55 +02:00
Gigi
789982bd76 Merge pull request #11 from dergigi/bookmarks-reorg
Reorganize bookmarks UI with sections and bookmark sets support
2025-10-15 16:28:25 +02:00
Gigi
8bccc9de48 fix: remove unused articleImage prop from CompactView 2025-10-15 16:27:05 +02:00
Gigi
ec8584b4d2 feat: hide cover images in compact view 2025-10-15 16:25:48 +02:00
Gigi
54bd59fa2d refactor: rename Amethyst-style bookmarks to Old Bookmarks (Legacy) 2025-10-15 16:25:03 +02:00
Gigi
b19f5f55f7 fix: remove borders from compact bookmark cards 2025-10-15 16:21:26 +02:00
Gigi
0964f25f97 refactor: make section dividers more subtle
Changed border color from var(--color-border) to rgba(255, 255, 255, 0.05)
for a much more subtle dividing line between bookmark sections.
2025-10-15 16:20:35 +02:00
Gigi
5f3e6335c1 refactor: reduce section heading bottom padding by half
Changed bottom padding from 0.75rem to 0.375rem for both the section
title and action button to reduce spacing before bookmark items.
2025-10-15 16:20:06 +02:00
Gigi
f30c894c87 fix: align add bookmark button with section heading
- Added matching padding to bookmark-section-action button
- Button now has same vertical padding as section title (1.5rem top, 0.75rem bottom)
- Also handles first section case with reduced padding (0.5rem top)
- Removed unnecessary marginBottom from flex container
2025-10-15 16:19:34 +02:00
Gigi
bec769ac1b refactor: move add bookmark button to web bookmarks section
- Removed add bookmark button from sidebar header
- Added small CompactButton style button next to 'Web bookmarks' heading
- Button only shows when user is logged in and web bookmarks section exists
- Moved bookmark creation logic from SidebarHeader to BookmarkList
- Cleaned up unused imports in SidebarHeader
2025-10-15 16:17:58 +02:00
Gigi
cb3748e06f refactor: remove redundant loading spinner above tabs
Removed the loading spinner that appeared above the tab bar since we now
show spinners in the empty states themselves, making this redundant.
2025-10-15 16:14:04 +02:00
Gigi
d5a24f0a46 refactor: replace empty state messages with spinners
Replaced 'No X yet. Pull to refresh!' messages with spinning loaders for:
- No highlights yet (Me & Explore)
- No bookmarks yet (Me)
- No read articles yet (Me)
- No articles written yet (Me)
- No blog posts yet (Explore)

This provides better UX by showing an active loading state instead of
static empty state messages.
2025-10-15 16:11:05 +02:00
Gigi
401a8241bd fix: resolve lint and type errors
- Changed idToEvent from let to const (prefer-const)
- Fixed TypeScript type narrowing issue by using direct regex test instead of isHexId type guard
- Removed unused isHexId import

All lint and type checks now pass for src directory.
2025-10-15 16:09:46 +02:00
Gigi
2193a7a863 fix: properly handle AddressPointer bookmarks for long-form articles
The issue was that Primal bookmarks long-form articles using 'a' tags
(AddressPointer format: kind:pubkey:identifier) but our code was only
expecting EventPointer objects with 'id' properties.

Changes:
- Updated ApplesauceBookmarks interface to match actual applesauce types
- Added AddressPointer and EventPointer interfaces
- Rewrote processApplesauceBookmarks to handle all bookmark types:
  * notes (EventPointer) - regular notes
  * articles (AddressPointer) - long-form content (kind:30023)
  * hashtags (string[])
  * urls (string[])
- Updated bookmark hydration to query addressable events by coordinates
- Added logging to show hydration stats

This should fix the issue where Primal's Reads bookmarks weren't showing up.
2025-10-15 16:06:03 +02:00
Gigi
e6bc4d7fda chore: update .gitignore 2025-10-15 16:02:30 +02:00
Gigi
aee9f73316 debug: add detailed logging for bookmark event tags
Added logging to show:
- e and a tag counts for all events
- which events survived deduplication
- specific check for Primal reads list (kind:10003 with d='reads')
2025-10-15 15:59:56 +02:00
Gigi
aef7b4cea4 fix: include kind:30003 in default bookmark list detection
Previously, the dedupeNip51Events function was only looking for kind:10003
and kind:30001 when finding the default bookmark list. This excluded
kind:30003 events without a 'd' tag, which is what Primal uses for
bookmarks. Now kind:30003 is properly included in the filter.
2025-10-15 15:54:47 +02:00
Gigi
c9a8a3b91e refactor: remove text shadows from publication date
- Remove text-shadow from CSS for .publish-date-topright
- Remove shadowColor from useAdaptiveTextColor hook
- Only apply adaptive text color, no shadows or backgrounds
- Cleaner appearance with color-based readability only
2025-10-15 15:52:54 +02:00
Gigi
0c7b11bdf8 fix: improve shadow contrast without background overlay
- Increase shadow opacity from 0.5 to 0.8 for better readability
- Revert semi-transparent background approach per user feedback
- Keep debugging logs to diagnose color detection
2025-10-15 15:50:54 +02:00
Gigi
8c151a5855 fix: correct async handling in adaptive color detection
- Remove incorrect await on synchronous getColor method
- Add console logging to debug color detection
- This should fix black-on-black readability issues
2025-10-15 15:49:45 +02:00
Gigi
9b54fa9c14 fix: correct FastAverageColor import to use named export
- Change from default import to named import
- Resolves TypeScript error TS2351
2025-10-15 15:48:02 +02:00
Gigi
99d7705404 feat: add adaptive text color for publication date over images
- Install fast-average-color library for image color detection
- Create useAdaptiveTextColor hook to analyze top-right image corner
- Update ReaderHeader to dynamically adjust date text/shadow colors
- Ensures publication date is readable on both light and dark backgrounds
2025-10-15 15:40:57 +02:00
Gigi
eaa590b8e2 feat: add support for bookmark sets (kind 30003)
- Add setName, setTitle, setDescription, and setImage fields to IndividualBookmark type
- Extract d tag and metadata from kind 30003 events in bookmark processing
- Create helper functions to group bookmarks by set and extract set metadata
- Display bookmark sets as separate sections in BookmarkList UI
- Maintain existing content-type categorization alongside bookmark sets
2025-10-15 15:25:33 +02:00
Gigi
715fd8cf10 refactor: remove duplicate type indicator icon from bookmark cards 2025-10-15 15:11:00 +02:00
Gigi
99a9709605 style: left-align support button, right-align view mode buttons 2025-10-15 15:01:25 +02:00
Gigi
65d330d5ed style: make support heart icon orange using friends color from settings 2025-10-15 14:59:51 +02:00
Gigi
1d1d389a03 feat: move support button to bottom-left of bookmarks bar 2025-10-15 14:58:43 +02:00
Gigi
0392389355 style: change support button icon from lightning bolt to heart 2025-10-15 14:57:24 +02:00
Gigi
cf2a500a07 style(bookmarks): remove border from compact view bookmarks 2025-10-15 14:39:43 +02:00
Gigi
7d3748202e fix(bookmarks): ensure section heading styles override with important 2025-10-15 14:39:00 +02:00
Gigi
d7f90faea9 style(bookmarks): improve section headings with better typography and remove counts 2025-10-15 14:35:18 +02:00
Gigi
cb0066aac9 style(bookmarks): use file-lines icon instead of book for default bookmarks 2025-10-15 14:31:27 +02:00
Gigi
b48397b7a6 feat(bookmarks): use camera icon for image bookmarks 2025-10-15 14:29:35 +02:00
Gigi
82ab8419e3 fix(lint): remove unused variables and fix icon imports 2025-10-15 14:26:02 +02:00
Gigi
142a2414d3 style(bookmarks): use regular icon variants for all classification icons 2025-10-15 14:24:07 +02:00
Gigi
081bd95f60 feat(bookmarks): classify web bookmark URLs to show appropriate content icons 2025-10-15 14:22:13 +02:00
Gigi
300aed0589 style(bookmarks): use regular icon variants for lighter appearance 2025-10-15 14:21:09 +02:00
Gigi
b2b23c66cf feat(bookmarks): add sticky note icon for text-only bookmarks without URLs 2025-10-15 14:20:28 +02:00
Gigi
838bb6aa3d feat(bookmarks): add content type icons to indicate article/video/web 2025-10-15 14:15:01 +02:00
Gigi
f14ecc5acb refactor(bookmarks): simplify filtering to only exclude empty content 2025-10-15 14:14:54 +02:00
Gigi
d533e23dc0 feat(bookmarks): render grouped sections in /me reading-list with global controls 2025-10-15 13:59:10 +02:00
Gigi
eefcf99364 feat(bookmarks): render grouped sections in sidebar with global view mode 2025-10-15 13:59:00 +02:00
Gigi
1c0790bfb6 feat(bookmarks): add grouping and sorting helpers for sections 2025-10-15 13:58:56 +02:00
Gigi
29e351ba78 feat(bookmarks): tag sourceKind in collection for web and list/set items 2025-10-15 13:58:48 +02:00
Gigi
7592c5c327 feat(bookmarks): add sourceKind to IndividualBookmark for grouping 2025-10-15 13:58:41 +02:00
Gigi
f5018204ab docs: update CHANGELOG.md for v0.6.13 release 2025-10-15 12:21:21 +02:00
Gigi
7ae74268fd chore: bump version to 0.6.13 2025-10-15 12:20:13 +02:00
Gigi
52e959a7f5 fix: keep bookmark button visible at top, only hide highlights button
- Bookmark button now visible at top (only hides on scroll down)
- Highlights button hides both at top AND on scroll down
- Separated visibility logic into showBookmarkButton and showHighlightsButton
- Relay status indicator follows bookmark button behavior
2025-10-15 12:19:33 +02:00
Gigi
4f03a2c276 fix: hide highlights button when scrolled to the top
- Highlights button now hidden when at the very top of the page
- Tracks scroll position and hides button when scrollY <= 10px
- Bookmark button remains visible as always
- Highlights button appears when scrolling up from below
- Improves UX by reducing visual clutter at page top
2025-10-15 12:17:44 +02:00
Gigi
bc4c96ee35 feat: add gradient placeholder images for articles without covers
- Blog post cards now show subtle gradient background when no image
- Reader view displays gradient placeholder with newspaper icon
- Large view bookmarks use gradient backgrounds
- Gradients use theme colors (--color-bg-elevated, --color-bg-subtle)
- Placeholder icons have reduced opacity for subtlety
- Adapts automatically to light/dark themes
2025-10-15 12:15:20 +02:00
Gigi
a866040fc1 feat: support nprofile identifiers on /p/ profile pages
- Profile pages now accept both npub and nprofile identifiers (NIP-19)
- Extract pubkey from nprofile.data.pubkey when decoding
- Maintains backward compatibility with existing npub links
- Users can now share profiles with relay metadata included
2025-10-15 12:13:18 +02:00
Gigi
c90fad268a style: improve PWA install section styling in settings
- Add section-title class to heading to match other settings sections
- Replace gradient button with standard zap-preset-btn styling
- Remove inline styles and JavaScript hover effects
- Consistent with app's design system and theme colors
2025-10-15 12:12:12 +02:00
Gigi
8ef1f775f9 fix: improve mobile bookmark button visibility across all pages
- Bookmark button now visible on all pages except settings
- Only hides when scrolling down while reading an article
- Fixes issue where button was hidden on /p/ (profile) and other pages
- Highlights button only shows when viewing article content
- Prevents users from getting stuck without navigation options
2025-10-15 12:10:30 +02:00
Gigi
90af87339c docs: update CHANGELOG.md for v0.6.12 release 2025-10-15 12:06:31 +02:00
Gigi
9007b1ca71 chore: bump version to 0.6.12 2025-10-15 12:05:34 +02:00
Gigi
0b7e6145de style: set horizontal divider opacity to 69% 2025-10-15 11:44:34 +02:00
Gigi
bf1b608d96 style: increase horizontal divider opacity for better visibility 2025-10-15 11:44:04 +02:00
Gigi
7db0f2a05c style: make horizontal dividers more subtle with increased padding 2025-10-15 11:43:33 +02:00
Gigi
165b4d4b9f docs: update CHANGELOG.md for v0.6.11 release 2025-10-15 11:09:16 +02:00
Gigi
a7106138c4 chore: bump version to 0.6.11 2025-10-15 11:08:08 +02:00
Gigi
a498bfab38 feat(mobile): show sidebar buttons on explore page 2025-10-15 11:07:07 +02:00
Gigi
3dd2980283 fix(mobile): memoize toggleSidebar to prevent sidebar from closing immediately on mobile
- Wrapped toggleSidebar in useCallback to prevent function recreation on every render
- Updated route change effect to only close sidebar on actual pathname changes, not state changes
- Fixes issue where bookmarks sidebar wouldn't stay open on mobile PWA
2025-10-15 11:05:17 +02:00
Gigi
2e2a1a2c9d feat: add colored borders to blog post and highlight cards based on relationship
- Add level-based colored borders to blog post cards (mine/friends/nostrverse)
- Updated BlogPostCard to accept and apply level prop
- Modified Explore.tsx to classify blog posts by relationship level
- Added CSS styling using settings colors for visual distinction
- Highlights already had this feature, now writings have it too
2025-10-15 10:32:16 +02:00
Gigi
b9666bf037 docs: update CHANGELOG.md for v0.6.10 release 2025-10-15 10:28:18 +02:00
Gigi
ab1e964d3a chore: bump version to 0.6.10 2025-10-15 10:27:12 +02:00
Gigi
1500744a96 Merge pull request #10 from dergigi/fix-eslint-and-stuff
Improve code quality and explore page UX
2025-10-15 10:26:37 +02:00
Gigi
394311622d refactor: remove subtitle from explore page 2025-10-15 10:22:19 +02:00
Gigi
c7f3991ddd feat: move tabs below filter buttons in explore page 2025-10-15 10:21:26 +02:00
Gigi
e05efaa4f6 feat: make refresh button spin during loading and pull-to-refresh 2025-10-15 10:20:45 +02:00
Gigi
c96347a331 fix: position refresh button next to filter buttons instead of far left 2025-10-15 10:19:52 +02:00
Gigi
d721e84e42 feat: add refresh button to the left of filter buttons in explore page 2025-10-15 10:15:52 +02:00
Gigi
dcbe4bd23e feat: update writings tab icon to use newspaper icon 2025-10-15 10:11:40 +02:00
Gigi
e11184426e feat: default explore filter to friends only 2025-10-15 10:09:55 +02:00
Gigi
ebea872c72 feat: move filter icon buttons to the right in explore page 2025-10-15 10:09:08 +02:00
Gigi
8e57d3d491 refactor: remove unused settings parameter from image cache and bookmark components
- Remove settings parameter from useImageCache and useCacheImageOnLoad hooks as it was never used
- Update all call sites in CardView, CompactView, LargeView, and ReaderHeader
- Remove settings prop from BookmarkItem and its child view components
- Remove settings prop from BookmarkList component
- Update ThreePaneLayout to not pass settings to BookmarkList

This change cascades through the component tree to clean up unused props that were
introduced when we refactored the image caching to use Service Worker instead of
local storage.
2025-10-15 10:01:43 +02:00
Gigi
ca339ac0b2 refactor: remove all eslint-disable statements and fix underlying issues
- Replace @typescript-eslint/no-explicit-any with proper Filter type from nostr-tools/filter in dataFetch.ts and helpers.ts
- Replace @typescript-eslint/no-explicit-any with IAccount and AccountManager types from applesauce-accounts in hooks
- Replace @typescript-eslint/no-explicit-any with unknown type casts in App.tsx for keep-alive subscription
- Fix react-hooks/exhaustive-deps warnings by including all dependencies in useEffect hooks
- Remove unused _settings parameters in useImageCache.ts that were causing no-unused-vars warnings
2025-10-15 09:57:14 +02:00
Gigi
abb6819c40 Merge pull request #9 from dergigi/fix-explore
Fix explore page data loading and simplify fetch/write paths
2025-10-15 09:53:07 +02:00
Gigi
de314894ff fix(explore): properly fix react-hooks/exhaustive-deps warning
Instead of suppressing the warning, use functional setState updates to
check current state without creating dependencies. This allows the effect
to check if blogPosts/highlights are empty without adding them as
dependencies, which would cause infinite re-fetch loops.

The pattern prev.length === 0 ? cached : prev ensures we only seed from
cache on initial load, not on every refresh.
2025-10-15 09:44:57 +02:00
Gigi
2939747ebf fix(lint): resolve all linting and type errors
- Remove unused imports (useRef, faExclamationCircle, getProfileUrl, Observable, UserSettings)
- Remove unused error state and setError calls in Explore and Me components
- Remove unused 'events' variable from exploreService and nostrverseService
- Remove unused '_relays' parameter from saveSettings
- Remove unused '_settings' parameter from publishEvent
- Update all callers of publishEvent and saveSettings to match new signatures
- Add eslint-disable comment for intentional dependency omission in Explore
- Update BookmarkList to use new pull-to-refresh library and RefreshIndicator
- All type checks and linting now pass
2025-10-15 09:42:56 +02:00
Gigi
a4548306e7 fix(ui): update HighlightsPanel to use new pull-to-refresh library
- Replace old usePullToRefresh hook with use-pull-to-refresh library
- Update to use RefreshIndicator component
- Remove ref-based implementation in favor of simpler library API
2025-10-15 09:37:03 +02:00
Gigi
f16c1720a6 fix(ui): remove blocking error screens, show progressive loading with skeletons
- Remove full-screen error messages in Explore and Me
- Show skeletons while loading if no data cached
- Display empty states with 'Pull to refresh!' message
- Allow users to pull-to-refresh to retry on errors
- Keep content visible as data streams in progressively
2025-10-15 09:34:46 +02:00
Gigi
5b2ee94062 feat(ui): replace custom pull-to-refresh with use-pull-to-refresh library for simplicity
- Remove custom usePullToRefresh hook and PullToRefreshIndicator
- Add use-pull-to-refresh library dependency
- Create simple RefreshIndicator component
- Apply pull-to-refresh to Explore and Me screens
- Simplify implementation while maintaining functionality
2025-10-15 09:32:25 +02:00
Gigi
3091ad7fd4 feat(write): add unified publishEvent service and refactor highlight and settings to use it 2025-10-15 09:29:54 +02:00
Gigi
5b7488295c refactor(fetch): migrate nostrverseService, bookmarkService, and libraryService to use queryEvents 2025-10-15 09:28:35 +02:00
Gigi
bea62ddc4b refactor(fetch): migrate exploreService and fetchHighlightsFromAuthors to use queryEvents 2025-10-15 09:26:33 +02:00
Gigi
44d6b1fb2a fix(explore): prevent refresh loop and avoid false empty-follows error; always load nostrverse 2025-10-15 09:19:10 +02:00
Gigi
02ec8dd936 feat(fetch): add unified queryEvents helper and stream contacts partials with extended timeout 2025-10-15 09:17:51 +02:00
Gigi
765ce0ac5e feat(network): centralize relay timeouts and contacts remote timeout 2025-10-15 09:15:48 +02:00
Gigi
a1f7c3e34a Merge pull request #8 from dergigi/support
Add support page with zap receipt display
2025-10-15 01:54:47 +02:00
Gigi
2e5eb08b54 fix: simplify copy 'show up above' to 'show above' 2025-10-15 01:51:24 +02:00
Gigi
46a6d4fe0c chore: restore production thresholds (2100 sats / 69420 sats)
Removed testing values and restored proper production thresholds
2025-10-15 01:44:45 +02:00
Gigi
84ea0df550 refactor: improve support page spacing and visual hierarchy
- Increase hero section spacing (mb-16/20 instead of mb-8/12)
- Larger illustration (w-56/72 instead of w-48/64)
- Bigger heading (text-4xl/5xl instead of 3xl/4xl)
- More generous section spacing throughout
- Larger gaps between avatars (gap-8/10 for legends)
- More columns on larger screens (lg breakpoint added)
- Add subtle border-top separator before footer
- Increase footer spacing (mt-16/20)
- Larger call-to-action text (text-base)
- Change 'Absolute Legends' to 'Legends' (shorter, cleaner)
2025-10-15 01:43:12 +02:00
Gigi
0f58b166ce refactor: make regular supporter avatars smaller and remove border
- Reduced from w-12 h-12 md:w-16 md:h-16 to w-10 h-10 md:w-12 md:h-12
- Removed ring border for regular supporters (keep yellow ring for whales only)
- Simplified styling logic
2025-10-15 01:40:34 +02:00
Gigi
f65d39023c refactor: reorder footer text and make stats smaller
- Move call-to-action above stats
- Reduce stats font size from text-sm to text-xs
- Make call-to-action more prominent
2025-10-15 01:39:57 +02:00
Gigi
0b3c7efbc1 refactor: remove superfluous 'Want to show up here?' question
Make call-to-action more direct and concise
2025-10-15 01:38:58 +02:00
Gigi
ecb462562f feat: link 'Boris' to njump.me profile page
Allow users to easily navigate to Boris profile to send zaps
2025-10-15 01:38:34 +02:00
Gigi
c5a3d00371 feat: link 'meaningful amount of sats' to pricing page
Makes the call-to-action more actionable by linking to payment info
2025-10-15 01:36:29 +02:00
Gigi
d3b7a8ddde feat: add call-to-action message at bottom of support page
Encourage visitors to zap Boris to show up on the supporter list
2025-10-15 01:36:12 +02:00
Gigi
0eee203a9b feat: link 'zaps' to pricing page
Add clickable link on 'zaps' text pointing to https://www.readwithboris.com/#pricing
Helps users learn how to send zaps to support the project
2025-10-15 01:34:35 +02:00
Gigi
cd5a95dea3 refactor: reduce regular supporter avatar size
Changed from w-16 h-16 md:w-20 md:h-20 to w-12 h-12 md:w-16 md:h-16
Makes regular supporters more compact while keeping legends prominent
2025-10-15 01:34:05 +02:00
Gigi
f348ddaf73 revert: restore 'Absolute Legends' heading
Changed back from 'Legends' to 'Absolute Legends'
2025-10-15 01:33:44 +02:00
Gigi
9f09093c80 refactor: simplify whale section heading to 'Legends'
Changed from 'Absolute Legends' to 'Legends' for brevity
2025-10-15 01:30:52 +02:00
Gigi
490c6c9bdc feat: make supporter avatars clickable to view profiles
- Click avatar to navigate to /p/:npub profile page
- Add hover scale effect for visual feedback
- Convert pubkey to npub for navigation
2025-10-15 01:29:57 +02:00
Gigi
4eb0ede76b refactor: remove bolt icon from Absolute Legends header
Keep the bolt badge on whale profile pictures only
2025-10-15 01:29:11 +02:00
Gigi
02c1b6b783 chore: temporarily lower thresholds for testing (2 sats / 21 sats)
- Lower supporter threshold from 2100 to 2 sats
- Lower whale threshold from 69420 to 21 sats
- Add TODO comment to restore production values
- For testing support page with smaller zaps
2025-10-15 01:25:39 +02:00
Gigi
9eed448da6 feat: improve support page copy and add thank-you illustration
- Add exclamation mark to 'Thank You!' heading for warmth
- Simplify description text (remove redundant thank you)
- Rename 'Mega Supporters' to 'Absolute Legends' for more fun tone
- Add thank-you.svg illustration asset
2025-10-15 01:25:00 +02:00
Gigi
f8d621bcdc fix: change support page heading to 'Thank You' 2025-10-15 01:22:17 +02:00
Gigi
5cbe2246d3 fix: apply proper theme colors to support page for readability
- Use CSS variables for background, text, and border colors
- Add min-h-screen wrapper with proper background color
- Replace hardcoded zinc colors with theme-aware variables
- Ensure text is readable in both light and dark themes
2025-10-15 01:21:57 +02:00
Gigi
f29a180cbd feat: add thank-you illustration to support page 2025-10-15 01:20:05 +02:00
Gigi
0ca3771906 fix: improve zap receipt scanning with applesauce helpers and more relays
- Use isValidZap, getZapSender, getZapAmount from applesauce-core/helpers
- Add common zap relays (Mutiny, Alby) to improve coverage
- Add detailed logging to debug zap receipt fetching
- Remove custom extraction functions in favor of applesauce helpers
2025-10-15 01:04:17 +02:00
Gigi
6dab126f88 fix: resolve lint and type errors in support page implementation 2025-10-15 01:01:18 +02:00
Gigi
6c74d04984 docs: add Support page section to FEATURES.md 2025-10-15 00:54:03 +02:00
Gigi
1e00ff5e35 feat: add Support button (bolt icon) to SidebarHeader navigation 2025-10-15 00:53:34 +02:00
Gigi
71fa334f61 feat: add /support route to App routing 2025-10-15 00:53:30 +02:00
Gigi
d3ee995221 feat: wire Support component into Bookmarks with /support detection 2025-10-15 00:53:26 +02:00
Gigi
6812584b8c feat: extend ThreePaneLayout with showSupport and support slot 2025-10-15 00:53:22 +02:00
Gigi
47ddf8ebe1 feat: add Support component to display zappers with avatar grid 2025-10-15 00:53:18 +02:00
Gigi
36897e7f15 feat: add zapReceiptService to fetch and aggregate kind:9735 receipts 2025-10-15 00:53:14 +02:00
Gigi
f18315be02 feat: export BORIS_PUBKEY for reuse in support page 2025-10-15 00:53:09 +02:00
Gigi
38d77b02f5 docs: add FEATURES.md summarizing app features 2025-10-14 22:49:45 +02:00
Gigi
5b77a93bba chore: add MIT License 2025-10-14 16:53:59 +02:00
Gigi
e1c11a7450 docs: update CHANGELOG.md for v0.6.9 2025-10-14 16:50:40 +02:00
Gigi
d96ee50f5a chore: bump version to 0.6.9 2025-10-14 16:48:32 +02:00
Gigi
d4a172ba7e docs: update CHANGELOG.md for v0.6.8 2025-10-14 16:40:08 +02:00
Gigi
52ddb8dd7d chore: bump version to 0.6.8 2025-10-14 16:39:08 +02:00
Gigi
8c16614752 chore: update favicon and app icons to purple theme 2025-10-14 16:38:21 +02:00
Gigi
700d7cc5fa chore: update favicon and app icons 2025-10-14 16:34:21 +02:00
Gigi
017703dab2 fix: use consistent yellow color (#fde047) for default highlight settings 2025-10-14 16:31:55 +02:00
Gigi
c59fdb14f1 docs: update CHANGELOG.md for v0.6.7 2025-10-14 15:46:21 +02:00
Gigi
0c104f95d9 chore: bump version to 0.6.7 2025-10-14 15:44:25 +02:00
Gigi
acbefae501 Merge pull request #7 from dergigi/loading-placeholders
Remove loading spinners in favor of skeleton placeholders
2025-10-14 15:43:55 +02:00
Gigi
2ce83ef88a fix: use React.ReactElement instead of JSX.Element type
Change return type from JSX.Element to React.ReactElement to fix ESLint no-undef error
2025-10-14 15:42:54 +02:00
Gigi
dab3412ecd refactor: remove loading spinner from explore page
Remove incremental loading spinner as pull-to-refresh indicator already provides visual feedback for refresh state. Initial loading continues to use skeleton placeholders.
2025-10-14 15:41:34 +02:00
Gigi
988b3164d2 docs: add loading placeholder guideline to fontawesome rule 2025-10-14 15:40:32 +02:00
Gigi
4161053821 fix: Me - handle undefined viewingPubkey in skeleton loading state 2025-10-14 15:37:03 +02:00
Gigi
60054c4865 feat: ContentPanel - replace spinner with skeleton loaders 2025-10-14 15:36:57 +02:00
Gigi
f4e8aa576c feat: HighlightsPanel - replace spinner with skeleton loaders 2025-10-14 15:35:28 +02:00
Gigi
30a495bcd1 feat: Me - replace spinner with skeleton loaders 2025-10-14 15:35:23 +02:00
Gigi
6dde0eb220 feat: Explore - replace spinner with skeleton loaders 2025-10-14 15:35:17 +02:00
Gigi
90d8ef3423 feat: BookmarkList - replace spinner with skeleton loaders 2025-10-14 15:35:10 +02:00
Gigi
f626a8ec9b feat: add skeleton components and theme provider 2025-10-14 15:35:03 +02:00
Gigi
a7c7535236 feat: add react-loading-skeleton package 2025-10-14 14:53:40 +02:00
Gigi
5b0f2821d6 feat: parse and render nostr identifiers in highlight comments
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments
- Render profiles as clickable links with shortened pubkeys (@abc12345...)
- Render blog posts (kind:30023) as clickable article links
- Shorten other event identifiers to prevent layout breaks
- Add monospace styling for shortened nostr IDs
- Maintains DRY principles by extending existing CommentContent component
2025-10-14 12:58:01 +02:00
Gigi
be045557b8 feat: add nostrverse content and visibility filters to explore page
- Add visibility filter state and UI (mine/friends/nostrverse toggles)
- Create nostrverseService to fetch public content from the entire network
- Fetch both friends content and nostrverse content in parallel
- Apply visibility filters to both highlights and blog posts
- Filter buttons match highlight sidebar styling
- Users can now discover content beyond their friend network
- Maintains performance with sensible limits (50 posts, 100 highlights)
2025-10-14 12:09:12 +02:00
Gigi
a0c92182f9 docs: update CHANGELOG.md for v0.6.6 release 2025-10-14 12:03:53 +02:00
Gigi
f33d33556b chore: bump version to 0.6.6 2025-10-14 12:02:20 +02:00
Gigi
9aff889835 fix: correct profile fetching implementation and dependencies
- Use eventStore.add() directly instead of mapEventsToStore
- Use tap() operator to process and store events as they arrive
- Add eventStore and settings to useEffect dependencies
- Fixes TypeScript and ESLint errors
2025-10-14 12:00:52 +02:00
Gigi
420df1fbdd feat: fetch and cache author profiles in explore page
- Create profileService to fetch and cache kind:0 metadata
- Fetch profiles for all blog post authors on explore page
- Store profiles in event store for immediate access
- Rebroadcast profiles to local/all relays per user settings
- Fixes 'Unknown' author names by ensuring profiles are cached
- Uses mapEventsToStore to automatically populate event store
2025-10-14 11:59:28 +02:00
Gigi
2946ede5ac fix: filter out blog posts with far-future publication dates
- Add filteredBlogPosts useMemo to exclude posts with unreasonable dates
- Allow 1 day into future for clock skew tolerance
- Prevents spam/error posts with dates like '53585 years from now'
- Uses published_at tag or event.created_at as fallback
2025-10-14 11:57:04 +02:00
Gigi
6ec28e6a9d feat: render links and images in highlight comments
- Parse URLs in comment text and render as clickable links
- Detect image URLs and render inline images
- Add CommentContent component for smart URL rendering
- Style links with primary color and underline
- Style images with border and rounded corners
- Images lazy-load and respect max-width
- Links open in new tab with noopener/noreferrer
2025-10-14 11:54:41 +02:00
Gigi
820daa489e feat: hide citation in highlights sidebar for current article
- Add showCitation prop to HighlightItem (defaults to true)
- Set showCitation={false} in HighlightsPanel
- Reduces redundancy since all sidebar highlights are from same article
- Citation still shown in Explore and Me pages where context is needed
2025-10-14 11:52:29 +02:00
Gigi
b162596013 fix: prevent layout breaks from long URLs in highlight comments
- Add word-wrap, overflow-wrap, and word-break to comments
- Set min-width: 0 to allow flex child to shrink
- Prevents horizontal overflow from long URLs or text
- Maintains readable layout with line wrapping
2025-10-14 11:51:16 +02:00
Gigi
e581237e16 docs: update CHANGELOG.md for v0.6.5 release 2025-10-14 11:49:43 +02:00
Gigi
fcc329cc7c chore: bump version to 0.6.5 2025-10-14 11:48:25 +02:00
Gigi
c9544e0fd2 feat: open highlight in native app when clicking timestamp
- Click timestamp to open highlight event in user's native Nostr app
- Reuses existing native link logic (nostr:nevent)
- Simple and DRY implementation
2025-10-14 11:40:30 +02:00
Gigi
d7906cfb95 fix: use article text color for highlight counter
- Change highlight indicator to var(--color-text)
- Matches main article text color for better readability
- More prominent and consistent with content
2025-10-14 11:38:59 +02:00
Gigi
13cd6aeb11 fix: use consistent text color for highlight counter
- Change highlight indicator color to var(--color-text-secondary)
- Matches reading time color for visual consistency
- Better readability in both light and dark modes
2025-10-14 11:38:19 +02:00
Gigi
d4821d18fb fix: improve highlight counter readability in light mode
- Make highlight indicator color theme-aware
- Only force white text color in overlay context (with hero image)
- Let CSS handle text color in regular header for better light mode support
- Fixes hard-to-read white text on light backgrounds
2025-10-14 11:37:13 +02:00
Gigi
b86bf48382 deps: add @fortawesome/free-regular-svg-icons
- Install FontAwesome regular icons package
- Required for faComments icon in HighlightItem component
2025-10-14 11:34:52 +02:00
Gigi
c595f94567 style: switch to regular comments icon
- Use faComments from @fortawesome/free-regular-svg-icons
- Replace solid faComment with regular faComments
- Provides lighter, outlined icon style per FontAwesome regular variant
2025-10-14 11:33:02 +02:00
Gigi
82058c0ef4 style: remove extra indent from highlight comments
- Remove margin-left from comment container
- Icon alone provides sufficient visual indentation
- Cleaner alignment with highlight content
2025-10-14 11:32:16 +02:00
Gigi
a1f3424b38 style: remove background from highlight comments
- Remove background color from comment boxes
- Keep only colored icon for visual distinction
- Cleaner, simpler appearance
2025-10-14 11:31:32 +02:00
Gigi
14ab749ef1 style: color comment icon by highlight level and remove border
- Remove border-left from highlight comments
- Color comment icon based on highlight level (mine/friends/nostrverse)
- Remove opacity from icon for clearer color representation
- Yellow for mine, orange for friends, purple for nostrverse
2025-10-14 11:30:07 +02:00
Gigi
61dd4b2089 style: flip comment icon horizontally
- Add flip='horizontal' prop to comment icon
- Better visual alignment with comment text
2025-10-14 11:29:26 +02:00
Gigi
fb2fe1cc63 feat(highlights): add comment icon to highlight comments
- Import and use faComment icon
- Display comment icon next to comment text
- Style with flexbox layout and slight opacity
- Icon aligns to top with comment text
- Visual indicator that distinguishes comments from highlights
2025-10-14 11:28:51 +02:00
Gigi
720f12ce1c feat(explore): color highlights by author level (mine/friends/nostrverse)
- Import and use classifyHighlights utility
- Track followed pubkeys from contact fetching
- Classify highlights using same logic as highlights sidebar
- Pass classified highlights with level to HighlightItem
- Highlights now show colored borders based on author:
  - Yellow for own highlights (mine)
  - Orange for friends' highlights
  - Purple for nostrverse highlights
- Keep code DRY by reusing existing classification logic
2025-10-14 11:26:19 +02:00
Gigi
423ebb403f fix: add retry mechanism for scroll-to-highlight in article content
- Replace single 100ms delay with retry mechanism
- Try up to 20 times (2 seconds total) to find highlight mark element
- Fixes timing issue when content is still loading from explore page
- Mark elements need time to be rendered after article loads
- Retry every 100ms until element is found or max attempts reached
- Improves reliability of highlight scrolling from external navigation
2025-10-14 11:24:35 +02:00
Gigi
c90fb66bb8 feat(explore): scroll to highlight and open sidebar on click
- Pass highlight ID and openHighlights flag via navigation state
- Add useEffect in Bookmarks to handle navigation state
- Open highlights sidebar when clicking highlight from explore
- Auto-scroll to selected highlight (handled by useHighlightInteractions)
- Clear state after handling to prevent re-triggering
- Enhanced UX for discovering and reading highlighted content
2025-10-14 11:22:34 +02:00
Gigi
188de7ab1d feat(explore): clicking highlight opens source article in reader
- Add handleHighlightClick handler in explore page
- For nostr-native articles: convert eventReference to naddr and navigate to /a/{naddr}
- For web URLs: navigate to /r/{encoded-url}
- Pass onHighlightClick to HighlightItem component
- Users can now click highlights to read the full source content
2025-10-14 11:20:17 +02:00
Gigi
0b1cf267a7 refactor: reorder explore tabs - highlights first, writings second
- Change default tab to highlights on /explore
- Reorder tab buttons in UI (highlights, then writings)
- Update route: /explore shows highlights, /explore/writings shows writings
- Update route detection logic in Bookmarks component
- Highlights are now the primary content on explore page
2025-10-14 11:08:42 +02:00
Gigi
19f68612a5 style: use highlighter icon instead of server icon in highlight items
- Replace faServer with faHighlighter in bottom left indicator
- Update import statement
- Keep plane icon for offline/local-only highlights
- More semantically appropriate icon for highlight items
2025-10-14 11:08:03 +02:00
Gigi
1b1600d6f2 fix: make explore tabs actually span full width
- Set width: 100% and max-width: 100% on tabs
- Add justify-content: flex-start for left alignment
- Tabs now properly extend to match grid width
2025-10-14 11:07:00 +02:00
Gigi
ce67c19ece style: make explore tabs extend to grid width
- Add specific styling for tabs in explore-header
- Tabs now span full width to match the grid below
- Maintain left alignment for consistency with grid layout
2025-10-14 11:04:59 +02:00
Gigi
f754ce3cfe fix: extract author pubkey directly from p tag in highlights
- Pass full highlight object to HighlightCitation component
- Extract author pubkey from p tag as fallback if highlight.author not set
- Add debug logging to track author resolution
- Fix TypeScript type errors with proper guards
- Ensure author profile resolution works correctly
2025-10-14 11:04:24 +02:00
Gigi
19a86525cb debug: add logging to track author pubkey and profile resolution 2025-10-14 11:03:30 +02:00
Gigi
29213ceb1c feat(highlights): add citation attribution to highlight items
- Create HighlightCitation component to show source attribution
- For nostr-native content: display as '— Author, Article Title'
- For web URLs: display hostname as '— domain.com'
- Automatically resolves article titles from event references
- Resolves author names from profile data
- Add styling for citation line below highlight text
- Keep code DRY by reusing existing articleTitleResolver service
2025-10-14 11:01:02 +02:00
Gigi
d25a9b1735 refactor: use existing HighlightItem component for consistency
- Remove custom HighlightCard component
- Use the same HighlightItem component used throughout the app
- Remove custom highlight card styles
- Keep code DRY and UI consistent
2025-10-14 10:57:00 +02:00
Gigi
0f03706166 style: add proper styling for highlight cards in explore grid
- Add gradient header with quote icon for visual distinction
- Style highlight text and comments for card view
- Ensure cards work well in grid layout
- Add mobile responsive styling for highlight cards
2025-10-14 10:56:18 +02:00
Gigi
b1f79e3844 fix: resolve type errors and remove unused code
- Remove unused handleHighlightDelete function
- Fix all TypeScript type errors by using correct Highlight properties
- Use created_at instead of timestamp
- Use content instead of text
- Use urlReference instead of url
- All lint checks and type checks now pass
2025-10-14 10:54:51 +02:00
Gigi
243d9b17ef chore(explore): update subtitle text
- Change subtitle to mention both highlights and blog posts
- Include 'friends and others' to reflect broader content scope
2025-10-14 10:50:51 +02:00
Gigi
50a6cf6499 fix(explore): remove max-width constraint for grid layout
- Remove me-tab-content wrapper that was limiting width to 600px
- Allow explore-grid to use full width for proper multi-column layout
- Blog posts now display in proper grid format
2025-10-14 10:49:36 +02:00
Gigi
8f7991e971 refactor(explore): use grid layout for highlights tab
- Change highlights from list view to grid/card view
- Match the visual style of the writings tab
- Keep tab structure at the top
- Explore page now shows more content at once
2025-10-14 10:46:15 +02:00
Gigi
0aba54bd23 feat(explore): add highlights tab to explore page
- Create fetchHighlightsFromAuthors function for fetching highlights from multiple contacts
- Add tab structure to Explore page (Writings and Highlights tabs)
- Update explore cache to handle both blog posts and highlights
- Add /explore/highlights route
- Keep UI consistent with /me page tab structure
- Implement pull-to-refresh for both tabs
- Add proper caching and streaming for highlights
2025-10-14 10:45:23 +02:00
Gigi
23833b2cff docs: update CHANGELOG.md for v0.6.4 release 2025-10-14 10:40:40 +02:00
Gigi
d5076ff53e chore: bump version to 0.6.4 2025-10-14 10:39:44 +02:00
Gigi
41e452be1e Merge pull request #6 from dergigi/themes
Add comprehensive theme support with light/dark modes
2025-10-14 10:38:54 +02:00
Gigi
f267df8f60 feat(ui): increase bottom padding in highlight cards
- Increase bottom padding from 0.75rem to 2.5rem
- Reduces gap between cards from 1rem to 0.75rem (user edit)
- Provides more breathing room between text and footer
- Improves readability and visual balance
2025-10-14 10:35:34 +02:00
Gigi
7426c9d1fc feat(ui): increase spacing between highlight cards
- Increase gap from 0.75rem to 1rem in highlights list
- Provides better visual breathing room between cards
- Improves overall readability and card separation
2025-10-14 10:34:48 +02:00
Gigi
93d0c1052b feat(ui): align highlight text with footer icons
- Add 1.25rem left padding to highlight text content
- Add 1.25rem left margin to highlight comments
- Text now starts roughly where the fa-server icon ends
- Improves visual alignment and readability of highlight cards
2025-10-14 10:34:07 +02:00
Gigi
6537650757 feat(ui): apply highlight marker style to active Highlights tab
- Use actual highlight visual treatment (marker style) on tab
- Text remains in semantic color (--color-text) for readability
- Background uses 35% highlight color blend with glow effect
- Hover state intensifies to 50% for better interaction feedback
- Creates consistent visual language between tabs and content highlights
2025-10-14 10:32:31 +02:00
Gigi
a95f9b522b refactor(ui): simplify Me page tab labels
- Remove count numbers from all tabs (cleaner UI)
- Rename "Reading List" to "Bookmarks" (clearer naming)
- Keep tab names: Highlights, Bookmarks, Archive, Writings
- Reduces visual clutter and improves readability
2025-10-14 10:31:35 +02:00
Gigi
47d1335842 fix(ui): add background to Highlights tab for better contrast
- Add --color-bg-elevated background to active Highlights tab
- Improves contrast of yellow highlight color in light mode
- Creates visual separation while maintaining highlight color identity
- Keeps yellow text and border for consistent highlight theming
2025-10-14 10:30:27 +02:00
Gigi
168095e133 fix(ui): improve Highlights tab readability in light mode
- Use semantic text color (--color-text) for tab label in active state
- Keep highlight color for icon and bottom border as visual accent
- Ensures text is always readable regardless of theme
- Fixes contrast issues on /me page Highlights tab
2025-10-14 10:29:11 +02:00
Gigi
5c7b413a8d fix(theme): use consistent yellow-300 highlight color across all themes
- Revert to yellow-300 (#fde047) for all light and dark themes
- Use consistent Tailwind palette: yellow-300, orange-500, purple-600
- Previous darker colors were causing inconsistency with design system
- Ensures highlights use the same color values across all theme variants
2025-10-14 10:23:55 +02:00
Gigi
bca6458e44 fix(theme): improve highlight contrast in light themes
- Use darker yellow (yellow-400 instead of yellow-300) for better visibility
- Use darker orange (orange-600 instead of orange-500)
- Sepia theme uses even darker highlights (yellow-500, red-600)
- Ensures text and icons remain visible on highlighted text
- Applies to all light theme variants and system mode
2025-10-14 10:12:22 +02:00
Gigi
ebdfa3b5a3 fix(lint): replace 'any' types with proper type definitions
- Add DarkColorTheme and LightColorTheme type definitions
- Replace 'as any' with proper type assertions
- All eslint and TypeScript checks now pass
2025-10-14 10:10:57 +02:00
Gigi
17480dddbf fix(theme): improve text contrast in dark color themes
- Add text color definitions to all dark theme variants
- Ensure bright text (zinc-200) for readability on dark backgrounds
- Update --color-bg-subtle to be darker for better hierarchy
- Fixes low contrast issue where text was barely readable
2025-10-14 10:06:43 +02:00
Gigi
2a422fbeb9 fix(theme): use darker background for app body
- Change body background from --color-bg to --color-bg-subtle
- Creates visual depth and hierarchy between app bg and content
- Content panels now stand out more against the background
2025-10-14 10:05:10 +02:00
Gigi
22961ee479 fix(theme): update reading progress indicator to use theme colors
- Replace hard-coded dark background with --color-bg-elevated
- Use --color-border for progress track
- Use --color-primary for progress bar
- Use --color-text-muted for percentage text
- Indicator now adapts to light/dark themes
2025-10-14 10:02:43 +02:00
Gigi
18db905974 refactor(theme): rename labels from 'Colors' to 'Theme'
- Change 'Dark Colors' to 'Dark Theme'
- Change 'Light Colors' to 'Light Theme'
- More consistent and clearer labeling
2025-10-14 10:00:48 +02:00
Gigi
689963c041 refactor(theme): change default light theme to sepia
- Update default from paper-white to sepia for warmer reading
- Midnight remains default for dark mode
- Sepia provides warm, eye-friendly tones for light mode
2025-10-14 10:00:21 +02:00
Gigi
3f8869fd75 refactor(theme): show color swatches instead of text labels
- Replace text buttons with color swatches for theme selection
- Use actual background colors to preview each theme
- Add border for white swatch to make it visible
- Tooltips show theme names on hover
2025-10-14 09:58:47 +02:00
Gigi
129aced1a2 feat(theme): add color theme variants for light and dark modes
- Add darkColorTheme: black, midnight (default), charcoal
- Add lightColorTheme: paper-white (default), sepia, ivory
- Extend UserSettings with color theme fields
- Update ThemeSettings UI to show color options
- Add CSS variables for all color theme variants
- Sepia and Ivory have warm, reading-friendly palettes
- Black offers true black for OLED screens
- All color themes sync via Nostr (NIP-78)
2025-10-14 09:39:13 +02:00
Gigi
69febf4356 refactor(theme): remove localStorage, use only Nostr for persistence
- Remove localStorage.setItem/getItem from theme.ts
- Simplify early boot script to just default to system theme
- Theme now loads purely from NIP-78 settings
- Prevents race conditions between localStorage and Nostr settings
2025-10-14 09:34:54 +02:00
Gigi
65051c9c1f fix(theme): apply theme colors to body element
- Add background and text color to body
- Ensures page background changes with theme
2025-10-14 09:25:13 +02:00
Gigi
ba8229d464 refactor(css): update pull-to-refresh to use semantic tokens 2025-10-14 09:24:49 +02:00
Gigi
9251b017d4 refactor(css): migrate remaining components to semantic tokens
- Update icon-button.css, profile.css, me.css to use tokens
- Migrate reader.css to semantic colors for light theme
- Update toast.css with theme-aware colors
- All major UI components now support theme switching
2025-10-14 09:24:31 +02:00
Gigi
1ae76031f3 refactor(css): migrate cards/forms/layout to semantic tokens
- Replace hard-coded colors with CSS variables in cards.css
- Update forms.css, settings.css, modals.css with tokens
- Migrate sidebar.css and highlights.css to use semantic colors
- Update layout/app.css and base/global.css
- Enables proper light/dark theme switching
2025-10-14 09:13:42 +02:00
Gigi
994d834a0b feat(theme): add CSS variable tokens and theme classes
- Define semantic color tokens (--color-bg, --color-text, etc.)
- Add .theme-dark, .theme-light, .theme-system CSS classes
- Create theme.ts utility for theme application
- Add early boot theme script to prevent FOUC
- Support system preference with live updates
2025-10-14 09:11:38 +02:00
Gigi
67a4e17055 feat: add ants link to empty writings state for other users
- Update empty writings message for other users' profiles
- Show 'No articles written. You can find other stuff from this user using ants.'
- Link 'ants' to the ants.sh profile page for that user
- Keep original message for own profile
2025-10-14 01:42:29 +02:00
Gigi
1e82e3f240 fix: change empty state text color from red to gray
- Create new .explore-empty class with muted gray color (zinc-400)
- Keep .explore-error red for actual errors
- Update all empty state divs in Me.tsx to use .explore-empty
- Empty states (no highlights, no bookmarks, etc.) no longer appear as errors
2025-10-14 01:38:35 +02:00
Gigi
f973c75ff5 feat: match highlight comment color to highlight level color
- Remove hardcoded blue color from highlight comments
- Apply level-specific colors (mine/friends/nostrverse) to comment borders
- Use color-mix for subtle background tint matching highlight color
- Comment styling now respects user's highlight color settings
2025-10-14 01:36:59 +02:00
Gigi
28316a71c5 feat: open all profile links within app instead of external portals
- Update nostrUriResolver to return internal /p/:npub links for npub/nprofile
- Replace external profile links with React Router Link components
- Update ResolvedMention, LargeView, and CardView components
- Convert nprofile to npub before routing
- Keep note/nevent links as external (no internal viewer yet)
2025-10-14 01:32:28 +02:00
Gigi
cfc12e2d78 feat: add playful empty state message for other users' profiles
- Show 'You should shame them on nostr!' when viewing profiles with no highlights
- Keep original helpful message for own profile
- Conditional based on isOwnProfile flag
2025-10-14 01:29:54 +02:00
Gigi
7464a8b505 chore: bump version to 0.6.3 2025-10-14 01:27:22 +02:00
Gigi
938d79663b fix: remove unused isRefreshing parameter from PullToRefreshIndicator
- Keep prop in interface for backward compatibility
- Don't destructure unused parameter to satisfy linter
- All lint checks and type checks now pass
2025-10-14 01:13:02 +02:00
Gigi
cc0ad69275 chore: remove COLOR_SYSTEM.md documentation file 2025-10-14 01:11:32 +02:00
Gigi
810ff060f8 feat: make relay status indicator circular FAB on mobile
- Default to collapsed (icon only) on mobile
- Expand to show details when tapped on mobile
- Circular 56px FAB when collapsed, matching highlight button style
- Desktop always shows expanded with details
- Hide on scroll via showOnMobile prop (matches sidepanel buttons)
2025-10-14 01:11:14 +02:00
Gigi
5e03ef70a6 style: improve relay status indicator text layout
- Make subtitle text smaller (0.75rem) with reduced opacity
- Display text in column layout with proper line spacing
- Subtitle now appears on second line below title
- Apply consistent styling to offline and flight mode subtitles
2025-10-14 01:08:28 +02:00
Gigi
f05fb29c7b refactor: remove refresh spinner and text from pull-to-refresh
- Remove 'Refreshing...' text from indicator
- Remove spinner from pull-to-refresh (button already spins)
- Only show indicator when actively pulling, not when refreshing
- Simplify logic and improve UX consistency
2025-10-14 01:05:22 +02:00
Gigi
e737b1f7f0 fix: position relay status indicator in bottom-left corner
- Add fixed positioning (bottom-left) to match highlight button (bottom-right)
- Add modern styling with semi-transparent background, blur, and shadow
- Ensure proper visibility on mobile with smooth transitions
- Maintain responsive behavior for expanded/collapsed states
2025-10-14 01:04:18 +02:00
Gigi
21a7be2f98 fix: unify highlight visibility button styling across app
- Remove blue primary variant from highlight filter buttons
- Use opacity (1.0 active, 0.4 inactive) instead of variant change
- Update settings to use IconButton like sidebar (DRY)
- Consistent styling: ghost variant + opacity + custom colors
- Settings buttons now match sidebar buttons exactly
2025-10-14 01:02:46 +02:00
Gigi
4c720aa049 feat: add public profile pages at /p/:npub
- Make AuthorCard clickable to navigate to user profiles
- Add /p/:npub and /p/:npub/writings routes
- Reuse Me component for public profiles with pubkey prop
- Show highlights and writings tabs for any user
- Hide private tabs (reading-list, archive) on public profiles
- Public profiles show only public data (highlights, writings)
- Private data (bookmarks, read articles) only visible on own profile
- Add clickable author card hover styles with indigo border
- Decode npub to pubkey for profile viewing
- DRY: Single Me component serves both /me and /p/:npub routes
2025-10-14 01:01:10 +02:00
Gigi
6b240b01ec docs: update changelog for v0.6.2 2025-10-14 00:54:28 +02:00
Gigi
945894e3db chore: bump version to 0.6.2 2025-10-14 00:53:17 +02:00
Gigi
667397e528 fix: align title, summary, meta, and body text in reader
- Add consistent 2rem horizontal padding to reader-header on desktop
- Apply same padding to reader-summary-below-image, article-menu-container, and mark-as-read-container
- All content elements now align properly with body text
- Mobile (< 769px) retains base padding only
2025-10-14 00:52:19 +02:00
Gigi
e4b0d6d1cd refactor: unify button styles across sidebars using IconButton
- Convert HighlightsPanel buttons to use IconButton component
- Add style prop support to IconButton for custom styling
- Remove redundant CSS for old button classes (level-toggle-btn, refresh-highlights-btn, etc.)
- Keep only highlight-level-toggles container styling
- Consistent button appearance across left and right sidebars
- DRY: Single IconButton component handles all sidebar buttons
2025-10-14 00:50:52 +02:00
Gigi
3cdda2dcb7 refactor: move bookmark refresh button to footer with view controls
- Remove separate refresh section from bookmarks list
- Add refresh button to view-mode-controls footer
- Show last update time in button tooltip instead of inline text
- Cleaner UI with all controls in one footer section
2025-10-14 00:48:47 +02:00
Gigi
876ecc808d feat: add pull-to-refresh for mobile on all scrollable views
- Create reusable usePullToRefresh hook with touch gesture detection
- Add PullToRefreshIndicator component with visual feedback
- Implement pull-to-refresh on HighlightsPanel (right sidebar)
- Implement pull-to-refresh on Explore page
- Implement pull-to-refresh on Me pages (all tabs)
- Implement pull-to-refresh on BookmarkList (left sidebar)
- Only activates on touch devices for mobile-first experience
- Shows rotating arrow icon that becomes refresh spinner
- Displays contextual messages (pull/release/refreshing)
- Integrates with existing refresh handlers and loading states
2025-10-14 00:47:48 +02:00
Gigi
34671bd067 feat: add three-dot menu for external URLs in /r/ path
- Add menu button with options to open original URL, copy URL, and share
- Reuse existing menu styling for consistency
- Menu positioned at end of article content before mark-as-read button
2025-10-14 00:39:53 +02:00
Gigi
a6285f6a1d fix(highlights): precise normalized-to-original mapping to eliminate intra-word spaces
- Walk original text across node boundaries while tracking normalized positions
- Identify exact start/end nodes and offsets for the match
- Compute combined indices from node spans to create accurate DOM Range
- Eliminates artifacts like 'We b' by preventing whitespace from splitting words
- Keeps strict bounds checks and graceful failures
2025-10-14 00:37:56 +02:00
Gigi
36508d600a fix(highlights): remove existing highlight marks before applying new ones
- Strip all existing mark elements from HTML before re-highlighting
- Prevents old broken highlights from persisting in the DOM
- Ensures clean text is used as the base for new highlight application
- Fixes 'We b' spacing issue caused by corrupted marks from previous buggy renders
- Remove debug logging now that position mapping is working correctly
2025-10-14 00:34:55 +02:00
Gigi
a304bb7c26 debug: add detailed position mapping logging to diagnose spacing issues
- Log search text, match indices, and extracted text during position mapping
- Show sample of combined text around the extracted range
- Help identify where position mapping is going wrong for 'We b' issue
2025-10-14 00:31:59 +02:00
Gigi
04bab96a07 fix(highlights): improve normalized text position mapping to prevent character spacing issues
- Build explicit position map array from normalized to original text indices
- Properly handle whitespace sequences in position mapping
- Ensure each normalized character position maps to correct original position
- Validate mapped positions are within bounds before using
- Fixes spacing issues like 'We b' appearing instead of 'Web' in highlights
2025-10-14 00:27:38 +02:00
Gigi
22ebbff755 fix(highlights): add text validation before applying highlights
- Validate extracted range text matches search text before highlighting
- Check single-node matches are not empty or whitespace-only
- Compare both exact and normalized text to handle whitespace variations
- Prevent broken/corrupted highlights from being applied to DOM
- Add detailed logging for validation failures to aid debugging
2025-10-14 00:25:18 +02:00
Gigi
b43f40597f fix(highlights): add robust validation and error handling for multi-node highlighting
- Implement proper normalized-to-original text position mapping
- Add comprehensive validation for range indices and node offsets
- Verify range is not collapsed before extracting content
- Add try-catch block to handle DOM manipulation errors gracefully
- Add detailed warning logs for debugging failed highlight matches
- Prevent invalid ranges from corrupting the DOM structure
- Fix broken text nodes and visual artifacts in highlighted content
2025-10-14 00:23:43 +02:00
Gigi
fe3af25c5f docs: update changelog for v0.6.1 release 2025-10-14 00:21:31 +02:00
116 changed files with 6312 additions and 1705 deletions

View File

@@ -5,4 +5,4 @@ alwaysApply: false
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.
Never write "Loading" - always show a spinner, and just a spinner.
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).

View File

@@ -4,3 +4,5 @@ alwaysApply: false
---
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ dist
*.log
.DS_Store
# Applesauce Reference
# Reference Projects
applesauce
primal-web-app

View File

@@ -7,6 +7,601 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.19] - 2025-10-15
### Fixed
- Highlights disappearing on external URLs after a few seconds
- Fixed `useBookmarksData` from fetching general highlights when viewing external URLs
- External URL highlights now managed exclusively by `useExternalUrlLoader`
- Removed redundant `setHighlights` call that was overwriting streamed highlights
- Improved error handling in `fetchHighlightsForUrl` to prevent silent failures
- Isolated rebroadcast errors so they don't break highlight display
- Added logging to help diagnose highlight fetching issues
## [0.6.18] - 2025-10-15
### Changed
- Zap split labels simplified and terminology updated
- Removed redundant "Weight: xy" label to save space
- Changed "Author(s) Share" to "Author's Share" (possessive singular)
- Changed "Support Boris" to "Boris' Share" for consistency
- Weight value now shown directly in label (e.g., "Your Share: 50")
- Share and percentage now displayed on same line for cleaner layout
- Zap preset buttons on desktop now expand to match slider width
- Added `flex: 1` to buttons for equal width distribution
- Buttons still wrap properly on smaller screens
- PWA install section now always visible in settings
- Section shows regardless of installation or device capability status
- Button adapts with proper disabled states and visual feedback
- "Installed" state shows checkmark icon and disabled button
- Non-installable state shows disabled button
### Fixed
- PWA install button now properly disabled when installation is not possible on device
- Button only enabled when browser fires `beforeinstallprompt` event
- Removed hardcoded testing state that always showed button as installable
- App & Airplane Mode section now always visible regardless of PWA status
- Image cache and local relay settings always accessible
- Previously entire section was hidden if PWA not installable/installed
- Only PWA-specific install button is conditionally affected
## [0.6.17] - 2025-10-15
### Added
- PWA settings illustration (`pwa.svg`) displayed on right side of section
- Responsive design: hidden on mobile, 30% width on desktop
- Visual enhancement for App & Airplane Mode section
- Zaps illustration (`zaps.svg`) displayed on right side of Zap Splits section
- Matching responsive layout and styling as PWA illustration
- Visual 50% indicators on zap split sliders
- Linear gradient background using highlight colors (yellow/orange) at 50% opacity
- Datalist tick marks at 50% for "Your Share" and "Author(s) Share" sliders
- Tick mark at 5 for "Support Boris" slider
- Lightning bolt icons as slider thumbs for zap splits
- Replaces default circular slider handles
- White lightning bolt SVG embedded in slider thumb background
- 24px square thumb with 4px border radius
- Offline-first description paragraph at beginning of App & Airplane Mode section
- Explains Boris's offline capabilities upfront
- Settings page width constraint (900px max-width)
- Matches article view max-width for consistent reading experience
- Centered layout with proper margins
### Changed
- Settings section reorganization
- "PWA & Flight Mode" merged into single "App & Airplane Mode" section
- "Layout & Navigation" and "Startup & Behavior" merged into "Layout & Behavior"
- Section order: Theme → Reading & Display → Zap Splits → Layout & Behavior → App & Airplane Mode → Relays
- "Startup & Behavior" moved after "Zap Splits"
- "Layout & Navigation" moved below "Zap Splits"
- PWA settings section restructure
- Checkboxes moved to top (image cache, local relays)
- Descriptive paragraphs in middle
- Install button at bottom
- Note about local relays moved before install paragraph
- Zap split sliders styling
- Left side (0-50%): highlight color (yellow) at 50% opacity
- Right side (50-100%): friend-highlight color (orange) at 50% opacity
- Creates visual distinction tied to app's highlight color scheme
- Zap split description text styling
- Now matches offline-first paragraph style with secondary color and smaller font size
- Clear cache button styling
- Replaced `IconButton` with plain `FontAwesomeIcon` for subtler appearance
- No border or background, just icon with opacity
- Font Size buttons alignment
- Now properly align to the right using `setting-control` wrapper
- Matches alignment of highlight color picker buttons
- Default Highlight Visibility position
- Moved back to original position after "Paragraph Alignment"
- Grouped with other reading display controls
- Spacing adjustments in App & Airplane Mode section
- Reduced gap between elements from 1rem → 0.5rem → 0.25rem for tighter layout
### Fixed
- PWA settings paragraph wrapping
- Moved offline-first paragraph inside flex container to prevent extending above image
- Font Size buttons alignment issues
- Properly implemented `setting-control` wrapper for right alignment
- Previously attempted alignment didn't work correctly
- Slider thumb icon centering
- Lightning bolt icons properly centered vertically on slider
- Added `position: relative`, `top: 0`, `margin-top: 0` for accurate positioning
## [0.6.16] - 2025-10-15
### Changed
- Replaced delete dialog popup with inline confirmation UI
- Shows red "Confirm?" text with trash icon when delete is clicked
- Clicking the red trash icon confirms deletion
- No more modal overlay or backdrop
- Click outside or reopen menu to cancel
- Reordered Reading & Display settings for better organization
- Highlight Style, Paragraph Alignment, and Default Highlight Visibility moved to top
- Followed by Reading Font, Font Size, and color pickers
- Setting buttons now align vertically with fixed label width (220px)
- Creates consistent "tab stops" for cleaner visual alignment
### Fixed
- Removed unused `handleCancelDelete` function after dialog removal
## [0.6.15] - 2025-10-15
### Added
- Paragraph alignment setting with left-aligned and justified text options
- Icon buttons in Reading & Display settings for switching alignment
- CSS variable system for applying alignment to reader content
- Real-time preview of alignment changes in settings
- Headings remain left-aligned for optimal readability
### Changed
- Default paragraph alignment changed to justified for improved reading experience
- Applies to paragraphs, list items, divs, and blockquotes
- Settings stored and synced via Nostr (NIP-78)
## [0.6.14] - 2025-10-15
### Added
- Support for bookmark sets (NIP-51 kind:30003)
- Bookmark sets now display alongside regular bookmark lists
- Properly handles AddressPointer bookmarks for long-form articles
- Content type icons for bookmarks
- Article, video, web, and image icons to indicate bookmark content type
- Camera icon for image bookmarks
- Sticky note icon for text-only bookmarks without URLs
- Bookmark grouping and sections
- Grouped sections in sidebar and `/me` reading-list
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
- Grouping and sorting helpers for organizing bookmark sections
- Adaptive text color for publication date over hero images
- Automatically detects image brightness and adjusts text color
- Improved contrast for better readability
### Changed
- Renamed "Amethyst-style bookmarks" to "Old Bookmarks (Legacy)"
- Hide cover images in compact view for cleaner layout
- Support button improvements
- Moved to bottom-left of bookmarks bar
- Changed icon from lightning bolt to heart (orange color)
- Left-aligned support button, right-aligned view mode buttons
- Section headings improved with better typography (removed counts)
- Icon changed from book to file-lines for default bookmarks
- Use regular (outlined) icon variants for lighter, more refined appearance
- Add bookmark button moved to web bookmarks section
- Empty state messages replaced with loading spinners
- Section dividers made more subtle
- Simplified bookmark filtering to only exclude empty content
### Fixed
- Removed borders from compact bookmark cards for cleaner look
- Removed duplicate type indicator icons from bookmark cards
- Reduced section heading bottom padding for better spacing
- Aligned add bookmark button with section heading
- Removed redundant loading spinner above tabs
- Resolved linter and type errors
- Include kind:30003 in default bookmark list detection
- Removed text shadows from publication date for cleaner look
- Improved shadow contrast without background overlay
- Corrected async handling in adaptive color detection
- Corrected FastAverageColor import to use named export
- Section heading styles now properly override with `!important`
- Removed unused articleImage prop from CompactView
## [0.6.13] - 2025-10-15
### Added
- Support for `nprofile` identifiers on `/p/` profile pages (NIP-19)
- Profile pages now accept both `npub` and `nprofile` identifiers
- Extracts pubkey from nprofile data structure
- Users can share profiles with relay metadata included
- Gradient placeholder images for articles without cover images
- Blog post cards show subtle diagonal gradient using theme colors
- Reader view displays gradient background with newspaper icon
- Placeholders adapt automatically to light/dark themes
- Large view bookmarks use matching gradient backgrounds
### Changed
- PWA install section styling in settings
- Heading now matches other section headings with proper styling
- Install button uses standard app button styling instead of custom gradient
- Consistent with app's design system and theme colors
### Fixed
- Mobile bookmark button visibility across all pages
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
- Only hidden on settings page or when scrolling down while reading
- Prevents users from getting stuck without navigation options
- Mobile highlights button behavior at page top
- Hidden when scrolled to the very top of the page
- Appears when scrolling up from below
- Bookmark button remains visible at top (only hides on scroll down)
- Separate visibility logic for each button improves UX
## [0.6.12] - 2025-10-15
### Changed
- Horizontal dividers (`<hr>`) in blog posts now display with more subtle styling
- Reduced visual weight with 69% opacity for better readability
- Added increased vertical padding (2.5rem) above and below dividers
- Improved visual separation without disrupting reading flow
## [0.6.11] - 2025-10-15
### Added
- Colored borders to blog post and highlight cards based on relationship
- Mine: yellow border
- Friends: orange border
- Nostrverse: purple border
- Visual distinction helps identify content source at a glance
- Mobile sidebar toggle buttons on explore page
- Bookmark and highlights buttons now visible on explore page
- Improves mobile navigation UX
### Fixed
- Mobile bookmarks sidebar opening and closing immediately
- Memoized `toggleSidebar` function to prevent unnecessary re-renders
- Updated route-change effect to only close sidebar on actual pathname changes
- Sidebar now stays open when opened on mobile PWA
## [0.6.10] - 2025-10-15
### Added
- Support page (`/support`) displaying zappers with avatar grid
- Shows "Absolute Legends" (69420+ sats) and regular supporters (2100+ sats)
- Clickable supporter avatars link to profiles
- Bolt icon button in sidebar navigation
- Thank-you illustration and call-to-action
- Links to pricing page and Boris profile
- Refresh button to explore page
- Positioned next to filter buttons
- Spinning animation during loading and pull-to-refresh
- Unified event publishing and querying services
- `publishEvent` service for highlights and settings
- `queryEvents` helper with local-first fetching
- Centralized relay timeouts configuration
- FEATURES.md documentation file
- MIT License
### Changed
- Explore page improvements
- Filter defaults to friends only (instead of all)
- Tabs moved below filter buttons
- Filter buttons positioned on the right
- Writings tab now uses newspaper icon
- Subtitle removed for cleaner layout
- Pull-to-refresh library
- Replaced custom implementation with `use-pull-to-refresh`
- Updated HighlightsPanel to use new library
- Loading states now show progressive loading with skeletons instead of blocking error screens
- All event fetching services migrated to unified `queryEvents` helper
- `nostrverseService`, `bookmarkService`, `libraryService`
- `exploreService`, `fetchHighlightsFromAuthors`
- Contact streaming with extended timeout and partial results
### Fixed
- All ESLint and TypeScript linting errors
- Removed all `eslint-disable` statements
- Fixed `react-hooks/exhaustive-deps` warnings
- Resolved all type errors
- Explore page refresh loop and false empty-follows error
- Zap receipt scanning with applesauce helpers and more relays
- Support page theme colors for proper readability
### Refactored
- Event publishing to use unified `publishEvent` service
- Event fetching to use unified `queryEvents` helper
- Image cache and bookmark components (removed unused settings parameter)
- Support page spacing and visual hierarchy
## [0.6.9] - 2025-10-14
### Documentation
- Minor changelog formatting updates
## [0.6.8] - 2025-10-14
### Changed
- Updated favicon and app icons to purple theme
- Replaced all 8 icon files (apple-touch-icon, favicon variants, and Android Chrome icons)
- New purple color scheme for better brand recognition
## [0.6.7] - 2025-10-14
### Added
- Skeleton loading placeholders using `react-loading-skeleton` package
- Replaced loading spinners with skeleton loaders across all major components
- BookmarkList, Explore, Me, ContentPanel, and HighlightsPanel now use skeleton placeholders
- Theme-aware skeleton animations matching app color scheme
- Nostr identifier parsing and rendering in highlight comments
- Support for `nostr:npub`, `nostr:nprofile`, `nostr:naddr`, `nostr:note`, `nostr:nevent`
- Clickable links to profiles and articles from highlight comments
- Shortened display format for better readability
- Visibility filters for explore page content
- Toggle filters for nostrverse, friends, and own content
- Icon buttons with color coding matching highlight levels
- Filter state persists across tab switches
### Changed
- Loading states now use skeleton placeholders instead of spinners for more polished UX
- Removed incremental loading spinner from explore page refresh
- Pull-to-refresh indicator provides refresh state feedback
### Fixed
- Type error in `HighlightItem.tsx` using `React.ReactElement` instead of `JSX.Element`
- Me page skeleton loading now handles undefined `viewingPubkey` gracefully
### Documentation
- Updated FontAwesome rule to prefer skeleton placeholders over loading text or spinners
## [0.6.6] - 2025-10-14
### Added
- Profile fetching and caching for explore page
- Automatically fetches kind:0 metadata for all blog post authors
- Stores profiles in event store for instant access across app
- Rebroadcasts profiles to local/all relays per user settings
- Fixes "Unknown" author names by proactively caching profiles
- Rich content rendering in highlight comments
- URLs automatically detected and rendered as clickable links
- Image URLs (jpg, png, gif, webp, etc.) render as inline images
- Images lazy-load with responsive sizing and rounded borders
- Links open in new tab with security attributes
### Changed
- Hide citation in highlights sidebar when viewing article
- Citation removed from sidebar since all highlights are from same source
- Citation still shown in Explore and Me pages where context is needed
- Reduces visual clutter and redundant information
### Fixed
- Blog posts with far-future publication dates no longer appear in explore
- Filter excludes posts with dates more than 1 day in future
- Allows 1 day tolerance for clock skew between systems
- Prevents spam posts with unrealistic dates (e.g., "53585 years from now")
- Layout breaks from long URLs in highlight comments
- Added word-wrap, overflow-wrap, and word-break CSS properties
- Set min-width: 0 to allow flex child to shrink properly
- Long URLs now wrap correctly instead of causing horizontal overflow
- Profile fetching implementation
- Use eventStore.add() directly for immediate profile storage
- Use tap() operator to process events as they arrive
- Correct TypeScript types and dependency array
## [0.6.5] - 2025-10-14
### Added
- Highlights tab on `/explore` page
- View highlights from friends and followed users
- Tab structure matching `/me` and profile pages
- Grid layout for highlights with cards
- Highlights shown first, writings second
- Clicking highlight opens source article and scrolls to position
- Opens highlights sidebar automatically when clicking from explore
- Citation attribution on highlight items
- Shows "— Author, Article Title" for Nostr-native content
- Shows "— domain.com" for web URLs
- Resolves author profiles and article titles automatically
- Comment icon (fa-comments) for highlights with comments
- Flipped horizontally for better visual alignment
- Colored based on highlight level (mine/friends/nostrverse)
- No background or extra indent for cleaner look
- Click timestamp to open highlight in native Nostr app
- Uses nostr:nevent links for native app integration
### Changed
- Highlight counter text color now matches article text (var(--color-text))
- Better readability in both light and dark modes
- Only forces white in overlay context (hero images)
- Highlight level colors applied to explore page highlights
- Yellow for own highlights
- Orange for friends' highlights
- Purple for nostrverse highlights
- Explore page tab order: Highlights first, Writings second
- Explore page tabs now extend full width to match content grid
### Fixed
- Highlight counter readability in light mode
- Theme-aware text color instead of hardcoded blue
- Consistent with reading time indicator styling
- Scroll-to-highlight reliability in article view
- Added retry mechanism for asynchronous content loading
- Attempts to find highlight element up to 20 times over 2 seconds
- Author attribution in highlight citations
- Correctly extracts author pubkey from highlight's p tag
- No more "Unknown" author names
- Explore page grid layout
- Removed max-width constraint blocking full-width display
- Tabs and content now properly aligned
### Style
- Replaced server icon with highlighter icon in highlight items
- Switch from solid comment icon to outlined comments icon (fa-regular)
- Removed background from highlight comments for cleaner appearance
- Removed extra left margin from comments (icon provides sufficient indent)
- Comment icon colored by highlight level with no opacity
### Dependencies
- Added @fortawesome/free-regular-svg-icons package for outlined icons
## [0.6.4] - 2025-10-14
### Added
- Color theme variants for light and dark modes
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
- Color swatches shown in theme selector instead of text labels
- CSS variable tokens and theme classes for consistent theming
- Playful empty state message for other users' profiles
- Profile links now open within app instead of external portals
### Changed
- Default light theme changed to sepia for better readability
- Theme setting labels renamed from 'Colors' to 'Theme'
- Highlight text now aligns properly with footer icons
- Increased spacing between highlight cards for better visual separation
- Increased bottom padding in highlight cards
- Simplified Me page tab labels for cleaner UI
- Highlight marker style applied to active Highlights tab
- All profile links open internally instead of via external Nostr portals
- Match highlight comment color to highlight level color
### Fixed
- Consistent yellow-300 highlight color across all themes
- Highlight contrast improved in light themes
- Text contrast improved in dark color themes
- Darker background for app body in dark themes
- Reading progress indicator now uses theme colors
- Highlights tab readability improved in light mode with proper background
- Empty state text color changed from red to gray for better aesthetics
- Replaced 'any' types with proper type definitions for better type safety
### Refactored
- Migrated entire codebase to semantic token system
- Pull-to-refresh components updated to use semantic tokens
- Cards, forms, and layout components migrated to semantic tokens
- All remaining components converted to semantic token usage
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
- Theme colors applied to body element for consistent theming
## [0.6.3] - 2025-10-14
### Added
- Ants link to empty writings state for other users
### Changed
- Empty state text color from red to gray
### Fixed
- Match highlight comment color to highlight level color
- Open all profile links within app instead of external portals
- Playful empty state message for other users' profiles
## [0.6.2] - 2025-01-27
### Added
- Pull-to-refresh gesture on mobile for all scrollable views
- HighlightsPanel (right sidebar) - refresh highlights for current article
- Explore page - refresh blog posts from friends
- Me pages (all tabs) - refresh user data
- BookmarkList (left sidebar) - refresh bookmarks
- Touch-only activation using coarse pointer detection
- Visual indicator with rotating arrow and contextual messages
- Three-dot menu for external URLs in reader (`/r/` path)
- Open in browser, Copy URL, Share URL actions
- Consistent with article menu functionality
### Changed
- Bookmark refresh button moved to footer alongside view mode controls
- Last update time now shown in button tooltip
- Cleaner UI with all controls consolidated in footer
- Unified button styles across left and right sidebars
- All sidebar buttons now use IconButton component for consistency
- Removed 73 lines of redundant CSS for old button classes
- Highlights panel buttons match bookmark sidebar styling
### Fixed
- Reader content alignment on desktop
- Title, summary, metadata, and body text now properly aligned
- All reader elements now have consistent 2rem horizontal padding
- Mobile layout retains compact padding
- Highlight text matching with multiple improvements
- Precise normalized-to-original character position mapping
- Remove existing highlight marks before applying new ones
- Robust validation and error handling for multi-node highlights
- Prevent character spacing issues in highlighted text
- Add text validation before applying highlights
- Eliminate intra-word spaces in highlighted text
## [0.6.1] - 2025-10-13
### Added
- Writings tab on `/me` page to display user's published articles
- Comprehensive headline styling (h1-h6) with Tailwind typography
- List styling for ordered and unordered lists in articles
- Blockquote styling with indentation and italics
- Vertical padding to blockquotes for better readability
- Horizontal padding for reader text content on desktop
- Drop-shadows to sidebars for visual depth
- MutationObserver for tracking highlight DOM changes
### Changed
- Article titles now larger and more prominent
- Article summaries now display properly in reader header
- Zap splits settings UI with preset buttons and full-width sliders
- Sidebars now extend to 100vh height
- Blockquote styling simplified to minimal indent and italic
- Improved zap splits settings visual design
### Fixed
- Horizontal overflow from code blocks and wide content on mobile
- Settings view now mobile-friendly with proper width constraints
- Long relay URLs no longer cause horizontal overflow on mobile
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
- Video titles now show filename instead of 'Error Loading Content'
- AddBookmarkModal z-index issue fixed using React Portal
- Highlight matching for text spanning multiple DOM nodes/inline elements
- Highlights now appear as single continuous element across DOM nodes
- Highlights display immediately after creation with synchronous render
- Scroll-to-highlight functionality restored after DOM updates
- Padding gaps around sidebars removed
- TypeScript errors in video-meta.ts resolved
### Refactored
- Migrated entire color system to Tailwind v4 color palette
- Migrated all CSS files (sidebar, highlights, cards, forms, reader, etc.) to Tailwind colors
- Updated default highlight colors to yellow-400 for markers and yellow-300 for other contexts
- Added comprehensive color system documentation (COLOR_SYSTEM.md)
- Cleaned up legacy.css removing unused debugging styles
## [0.6.0] - 2025-10-13
### Added
@@ -1012,7 +1607,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.19...HEAD
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
[0.6.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11
[0.6.10]: https://github.com/dergigi/boris/compare/v0.6.9...v0.6.10
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
[0.6.7]: https://github.com/dergigi/boris/compare/v0.6.6...v0.6.7
[0.6.6]: https://github.com/dergigi/boris/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/dergigi/boris/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
[0.5.7]: https://github.com/dergigi/boris/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/dergigi/boris/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1

View File

@@ -1,98 +0,0 @@
# Boris Color System
All colors now use Tailwind CSS color palette for consistency and maintainability.
## Semantic Color Aliases (Tailwind Config)
```javascript
'app-bg': '#18181b', // zinc-900 - Main backgrounds
'app-bg-elevated': '#27272a', // zinc-800 - Elevated surfaces (cards, modals)
'app-bg-subtle': '#1e1e1e', // Custom ~zinc-850 - Subtle backgrounds
'app-border': '#3f3f46', // zinc-700 - Primary borders
'app-border-subtle': '#52525b', // zinc-600 - Subtle borders
'app-text': '#e4e4e7', // zinc-200 - Primary text
'app-text-secondary': '#a1a1aa', // zinc-400 - Secondary text
'app-text-muted': '#71717a', // zinc-500 - Muted text
'primary': '#6366f1', // indigo-500 - Primary accent
'primary-hover': '#4f46e5', // indigo-600 - Primary hover state
'highlight-mine': '#fde047', // yellow-300 - User highlights
'highlight-friends': '#f97316', // orange-500 - Friends highlights
'highlight-nostrverse': '#9333ea', // purple-600 - Nostrverse highlights
```
## Highlight Colors (User-Settable)
Default colors in the color picker:
- **Yellow** (default): `#fde047` - yellow-300
- **Orange**: `#f97316` - orange-500
- **Pink**: `#ec4899` - pink-500
- **Green**: `#22c55e` - green-500
- **Blue**: `#3b82f6` - blue-500
- **Purple**: `#9333ea` - purple-600
## Common Color Mappings
| Old Hex | Tailwind Color | Usage |
|-----------|----------------|-------|
| `#18181b` | zinc-900 | Main app background |
| `#1a1a1a` | zinc-900 | Component backgrounds |
| `#1e1e1e` | ~zinc-850 | Code blocks, subtle surfaces |
| `#252525` | zinc-800 | Hover states |
| `#27272a` | zinc-800 | Elevated surfaces |
| `#2a2a2a` | zinc-800 | Buttons, inputs |
| `#333` | zinc-700 | Primary borders |
| `#3f3f46` | zinc-700 | Component borders |
| `#444` | zinc-600 | Subtle borders |
| `#52525b` | zinc-600 | Input borders |
| `#555` | zinc-500 | Hover borders |
| `#666` | zinc-500 | Muted text |
| `#71717a` | zinc-500 | Secondary text |
| `#888` | zinc-400 | Secondary text |
| `#999` | zinc-400 | Muted labels |
| `#a1a1aa` | zinc-400 | Placeholder text |
| `#aaa` | zinc-300 | Light text |
| `#ccc` | zinc-300 | Primary text on dark |
| `#ddd` | zinc-200 | Bright text |
| `#e4e4e7` | zinc-200 | Primary text |
| `#646cff` | indigo-500 | Primary accent |
| `#535bf2` | indigo-600 | Primary hover |
| `#fde047` | yellow-300 | Default highlight (brighter) |
| `#f97316` | orange-500 | Friends highlights |
| `#9333ea` | purple-600 | Nostrverse highlights |
## Usage Guidelines
### In CSS
Use Tailwind utilities whenever possible:
```css
.example {
background: rgb(24 24 27); /* zinc-900 */
border: 1px solid rgb(63 63 70); /* zinc-700 */
color: rgb(228 228 231); /* zinc-200 */
}
```
### In TSX
Use Tailwind classes:
```tsx
<div className="bg-zinc-900 border border-zinc-700 text-zinc-200">
```
Or semantic aliases:
```tsx
<div className="bg-app-bg border border-app-border text-app-text">
```
### CSS Variables (User-Settable)
For colors that users can customize:
```css
background: var(--highlight-color-mine, #fde047);
```
## Notes
- All hex colors are now Tailwind palette colors
- CSS variables remain for user-customizable colors
- Semantic aliases provide easier maintenance
- RGB format in CSS allows for opacity control

89
FEATURES.md Normal file
View File

@@ -0,0 +1,89 @@
# Boris Features
## Overview
- **Purpose**: A calm, fast, Nostrfirst reader that turns your bookmarks into a focused reading app.
- **Layout**: Threepane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
- **Content**: Renders both Nostr longform posts (kind:30023) and regular web URLs.
- **Social layer**: Highlights shown by level — mine, friends, nostrverse — each with its own color and visibility toggle.
## Reader Experience
- **Distractionfree view**: Clean typography, optional hero image, summary, and published date.
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
- **Progress**: Reading progress indicator with completion state.
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
## Highlights (NIP84)
- **Levels**: Mine, friends, nostrverse; toggle per level; colors configurable in settings.
- **Interactions**: Click a highlight to scroll to its position; count indicator in the header.
- **Creation**: Select text and use the floating highlighter button to publish a highlight.
- **Attribution**: Automatically tags article authors for Nostr posts so they can see highlights.
## Zap Splits (NIP57)
- **Configurable splits**: Weightbased sliders for highlighter, author(s), and Boris (defaults 50/50/2.1).
- **Presets**: Quick buttons for common split configurations.
- **Respect source**: If the source article has zap tags, author weights are proportionally preserved.
## Bookmarks & Reading List (NIP51 + Web)
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
- **Web bookmarks**: Supports NIPB0 (kind:39701) for standalone URL bookmarks.
- **Add Bookmark**: Modal with auto title/description extraction and keywords/tags suggestion (adds “boris” when helpful).
- **Views**: Reading list in compact, cards, or large preview modes; quick toggles to switch.
- **Archive**: “Read” items appear in your archive; can mark articles/web pages as read.
## Explore & Profiles
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
## Support
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
- **Stats**: Total supporter count and zap count displayed at the bottom.
## Video
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.
- **Metadata**: Fetches YouTube title/description/transcript when available.
- **Deep links**: Open in native apps via platformspecific URL schemes.
## Settings (NIP78 Application Data)
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paperwhite/sepia/ivory).
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), perlevel colors.
- **Layout & startup**: Default view modes, autocollapse preferences, show/hide highlights.
- **Zap Splits**: Weight sliders and presets for NIP57 splits.
- **Offline/Flight Mode**: Local image cache with size limit and clear controls; “use local relay as cache”; rebroadcast preferences.
- **Relays**: Relay overview and status in Settings; educational links.
- **PWA**: Install prompt when available.
## Offline, PWA, and Sync
- **PWA**: Installable; service worker registered; periodic update checks with inapp toast.
- **Flight Mode**: Operates with local relays only; highlights created offline are stored locally and synced later.
- **Relay indicator**: Floating status indicator shows Connecting/Offline/Flight Mode and connected counts.
## Relays & Accounts
- **Applesauce stack**: Accounts, event store, relay pool, and blueprints power Nostr interactions.
- **Multirelay**: Grouped connections with keepalive subscription; local+remote partitioning for fast queries.
- **Persistence**: Accounts restored from local storage; settings saved to NIP78 and watched for updates.
## Privacy
- **Identity**: No email or new account; uses your existing Nostr signer/identity.
- **Data**: Bookmarks and highlights live on Nostr; reading/rendering happens locally in your browser.
## Conveniences
- **Share/copy**: Oneclick copy or share for articles and videos.
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for longform articles.
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Gigi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -25,6 +25,11 @@
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<!-- Default to system theme until settings load from Nostr -->
<script>
document.documentElement.className = 'theme-system';
</script>
</head>
<body>
<div id="root"></div>

49
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "boris",
"version": "0.5.7",
"version": "0.6.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.5.7",
"version": "0.6.13",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
@@ -21,17 +22,20 @@
"applesauce-react": "^4.0.0",
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
@@ -2263,6 +2267,18 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
@@ -6071,6 +6087,15 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-average-color": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -9807,6 +9832,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-loading-skeleton": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -11672,6 +11706,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-pull-to-refresh": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz",
"integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==",
"license": "MIT",
"peerDependencies": {
"react": "18.x || 19.x"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.1",
"version": "0.6.20",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -12,6 +12,7 @@
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5",
@@ -24,17 +25,20 @@
"applesauce-react": "^4.0.0",
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"fast-average-color": "^9.5.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"use-pull-to-refresh": "^2.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

215
public/pwa.svg Normal file
View File

@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="649.67538"
height="568.22024"
viewBox="0 0 649.67538 568.22024"
role="img"
artist="Katerina Limpitsouni"
source="https://undraw.co/"
version="1.1"
id="svg31"
sodipodi:docname="pwa.svg"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs31" />
<sodipodi:namedview
id="namedview31"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.6866359"
inkscape:cx="303.56285"
inkscape:cy="531.82789"
inkscape:window-width="3840"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg31" />
<path
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
transform="translate(-275.16231 -165.88988)"
fill="#f2f2f2"
id="path1" />
<path
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path2" />
<path
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path3" />
<path
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path4" />
<path
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path5" />
<path
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path6" />
<path
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path7" />
<path
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path8" />
<path
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path9" />
<path
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path10" />
<path
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path11" />
<path
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path12" />
<path
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path13" />
<path
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path14" />
<path
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path15" />
<path
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path16" />
<polygon
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
fill="#9e616a"
id="polygon16" />
<path
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path17" />
<polygon
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
fill="#9e616a"
id="polygon17" />
<path
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path18" />
<path
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
transform="translate(-275.16231 -165.88988)"
fill="#9e616a"
id="path19" />
<path
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path20" />
<circle
cx="443.5739"
cy="133.65539"
r="26.72083"
fill="#9e616a"
id="circle20" />
<rect
x="722.98731"
y="465.33587"
width="24.29166"
height="31.57916"
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
fill="#2f2e41"
id="rect20" />
<path
d="M593.23271,362.65743"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path21" />
<path
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path22" />
<path
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path23" />
<path
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
transform="translate(-275.16231 -165.88988)"
fill="#9e616a"
id="path24" />
<path
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
transform="translate(-275.16231 -165.88988)"
fill="#e4e4e4"
id="path25" />
<path
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
transform="translate(-275.16231 -165.88988)"
fill="#2f2e41"
id="path26" />
<path
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
transform="translate(-275.16231 -165.88988)"
fill="#6c63ff"
id="path27" />
<path
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path28" />
<path
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
transform="translate(-275.16231 -165.88988)"
fill="#3f3d56"
id="path29" />
<path
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
transform="translate(-275.16231 -165.88988)"
fill="#fff"
id="path31" />
<circle
cx="255.31291"
cy="19.52819"
r="2"
fill="#fff"
id="circle31" />
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

1
public/thank-you.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

235
public/zaps.svg Normal file
View File

@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="720.44"
height="718.635"
viewBox="0 0 720.44 718.635"
role="img"
artist="Katerina Limpitsouni"
source="https://undraw.co/"
version="1.1"
id="svg30"
sodipodi:docname="zaps.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs30" /><sodipodi:namedview
id="namedview30"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6746164"
inkscape:cx="38.510195"
inkscape:cy="485.67712"
inkscape:window-width="3840"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="g27" /><g
transform="translate(-600 -181)"
id="g30"><g
transform="translate(783.85 181)"
id="g2"><path
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
transform="translate(-266.365 -79.193)"
fill="#090814"
id="path1" /><path
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
transform="translate(-266.577 -79.397)"
fill="#fff"
id="path2" /></g><path
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
transform="translate(828 265)"
fill="#f2f2f2"
id="path3" /><path
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
transform="translate(828 358.398)"
fill="#f2f2f2"
id="path4" /><g
transform="translate(623.104 296.398)"
id="g9"><rect
width="278.304"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect4" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect5" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-53.047 -325.676)"
fill="#6c63ff"
id="path5" /><g
transform="translate(17.038 13.546)"
id="g7"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path6" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(72.886 17.036)"
fill="#e6e6e6"
id="path8" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(72.886 37.98)"
fill="#e6e6e6"
id="path9" /></g><g
transform="translate(1003.278 402.469)"
id="g14"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect9" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect10" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.751 -325.287)"
fill="#6c63ff"
id="path10" /><g
transform="translate(17.334 13.936)"
id="g12"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path11" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-7"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.181 17.426)"
fill="#e6e6e6"
id="path13" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.181 38.369)"
fill="#e6e6e6"
id="path14" /></g><g
transform="translate(663.012 510.639)"
id="g19"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect14" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect15" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.814 -325.25)"
fill="#6c63ff"
id="path15" /><g
transform="translate(17.272 13.972)"
id="g17"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path16" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-0"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.119 17.463)"
fill="#e6e6e6"
id="path18" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.119 38.406)"
fill="#e6e6e6"
id="path19" /></g><g
transform="translate(1041.086 616.711)"
id="g24"><rect
width="279.354"
height="70.364"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect19" /><rect
width="272.003"
height="63.012"
rx="15"
transform="translate(4.201 4.201)"
fill="#fff"
id="rect20" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.163 -324.86)"
fill="#6c63ff"
id="path20" /><g
transform="translate(17.922 14.362)"
id="g22"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path21" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-9"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.77 17.853)"
fill="#e6e6e6"
id="path23" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.77 38.796)"
fill="#e6e6e6"
id="path24" /></g><g
transform="translate(600 723.832)"
id="g29"><rect
width="279.354"
height="69.313"
rx="16"
transform="translate(0 0)"
fill="#090814"
id="rect24" /><rect
width="273.053"
height="63.012"
rx="15"
transform="translate(3.151 3.151)"
fill="#fff"
id="rect25" /><path
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
transform="translate(-52.631 -325.518)"
fill="#6c63ff"
id="path25" /><g
transform="translate(17.454 13.704)"
id="g27"><path
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
transform="translate(0 0)"
fill="#6c63ff"
id="path26" /><path
fill="#ffffff"
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
id="path2-8-2-98"
style="stroke-width:0.575581" /></g><path
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.301 17.194)"
fill="#e6e6e6"
id="path28" /><path
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
transform="translate(73.301 38.138)"
fill="#e6e6e6"
id="path29" /></g></g></svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -13,6 +13,7 @@ import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -61,6 +62,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/support"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={
@@ -70,6 +80,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/explore/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me"
element={<Navigate to="/me/highlights" replace />}
@@ -110,6 +129,24 @@ function AppRoutes({
/>
}
/>
<Route
path="/p/:npub"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -178,8 +215,7 @@ function App() {
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(pool as any)._keepAliveSubscription = keepAliveSub
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
// Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, {
@@ -198,10 +234,9 @@ function App() {
accountsSub.unsubscribe()
activeSub.unsubscribe()
// Clean up keep-alive subscription if it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((pool as any)._keepAliveSubscription) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pool as any)._keepAliveSubscription.unsubscribe()
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
poolWithSub._keepAliveSubscription.unsubscribe()
}
}
}
@@ -244,22 +279,24 @@ function App() {
}
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
<SkeletonThemeProvider>
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
</SkeletonThemeProvider>
)
}

View File

@@ -140,8 +140,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
clearTimeout(fetchTimeoutRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
}, [url, title, description, tagsInput])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

View File

@@ -1,14 +1,18 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
interface AuthorCardProps {
authorPubkey: string
clickable?: boolean
}
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
const navigate = useNavigate()
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
@@ -20,8 +24,19 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about
const handleClick = () => {
if (clickable) {
const npub = nip19.npubEncode(authorPubkey)
navigate(`/p/${npub}`)
}
}
return (
<div className="author-card">
<div
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
onClick={handleClick}
style={clickable ? { cursor: 'pointer' } : undefined}
>
<div className="author-card-avatar">
{authorImage ? (
<img src={authorImage} alt={getAuthorName()} />

View File

@@ -10,9 +10,10 @@ import { Models } from 'applesauce-core'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
level?: 'mine' | 'friends' | 'nostrverse'
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
@@ -25,7 +26,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
return (
<Link
to={href}
className="blog-post-card"
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<div className="blog-post-card-image">

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
interface BookmarkFiltersProps {
selectedFilter: BookmarkFilterType
onFilterChange: (filter: BookmarkFilterType) => void
}
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
selectedFilter,
onFilterChange
}) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
]
return (
<div className="bookmark-filters">
{filters.map(filter => (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
))}
</div>
)
}
export default BookmarkFilters

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react'
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
@@ -11,17 +13,15 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
import { UserSettings } from '../services/settingsService'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
settings?: UserSettings
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
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)}`
@@ -68,18 +68,41 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return short(bookmark.pubkey) // fallback to short pubkey
}
// use helper from kindIcon.ts
// Get content type icon based on bookmark kind and URL classification
const getContentTypeIcon = (): IconDefinition => {
if (isArticle) return faNewspaper // Nostr-native article
// For web bookmarks, classify the URL to determine icon
if (isWebBookmark && firstUrlClassification) {
switch (firstUrlClassification.type) {
case 'youtube':
case 'video':
return faCirclePlay
case 'image':
return faCamera
case 'article':
return faLink // External article
default:
return faGlobe
}
}
if (!hasUrls) return faStickyNote // Just a text note
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
if (firstUrlClassification?.type === 'article') return faLink // External article
return faFileLines
}
const getIconForUrlType = (url: string) => {
const classification = classifyUrl(url)
switch (classification.type) {
case 'youtube':
case 'video':
return faPlay
return faCirclePlay
case 'image':
return faEye
return faCamera
default:
return faBookOpen
return faFileLines
}
}
@@ -116,11 +139,13 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
handleReadNow,
articleImage,
articleSummary,
settings
contentTypeIcon: getContentTypeIcon()
}
if (viewMode === 'compact') {
return <CompactView {...sharedProps} />
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { articleImage, ...compactProps } = sharedProps
return <CompactView {...compactProps} />
}
if (viewMode === 'large') {
@@ -128,5 +153,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
}
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
return <CardView {...sharedProps} articleImage={articleImage} />
}

View File

@@ -1,15 +1,26 @@
import React from 'react'
import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import CompactButton from './CompactButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -26,8 +37,8 @@ interface BookmarkListProps {
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
isMobile?: boolean
settings?: UserSettings
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -45,39 +56,64 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
lastFetchTime,
loading = false,
relayPool,
settings,
isMobile = false
isMobile = false,
settings
}) => {
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
// Check if has content (text)
const hasContent = ib.content && ib.content.trim().length > 0
// Check if has URL
let hasUrl = false
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
// For other bookmarks, extract URLs from content
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
const navigate = useNavigate()
const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
// Always show articles (kind:30023) as they have special handling
if (ib.kind === 30023) return true
// Otherwise, must have either content or URL
return hasContent || hasUrl
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
// Pull-to-refresh for bookmarks
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
// Re-sort after flattening to ensure newest first across all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.filter(hasContent)
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks as before
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -107,14 +143,28 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
relayPool={relayPool}
isMobile={isMobile}
/>
{allIndividualBookmarks.length === 0 ? (
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={selectedFilter}
onFilterChange={setSelectedFilter}
/>
)}
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
<div className="empty-state">
<p>No bookmarks match this filter.</p>
</div>
) : allIndividualBookmarks.length === 0 ? (
loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
) : (
<div className="empty-state">
@@ -124,72 +174,95 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
</div>
)
) : (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) =>
<BookmarkItem
key={`${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
settings={settings}
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
<div
ref={bookmarksListRef}
className="bookmarks-list"
>
<RefreshIndicator
isRefreshing={isPulling || isRefreshing || false}
pullPosition={pullPosition}
/>
{sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
{section.key === 'web' && activeAccount && (
<CompactButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add web bookmark"
ariaLabel="Add web bookmark"
className="bookmark-section-action"
/>
)}
</div>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
/>
))}
</div>
</div>
)}
))}
</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 className="view-mode-left">
<IconButton
icon={faHeart}
onClick={() => navigate('/support')}
title="Support Boris"
ariaLabel="Support"
variant="ghost"
style={{ color: friendsColor }}
/>
</div>
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<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>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</div>
)
}

View File

@@ -1,16 +1,15 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
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'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
import { getEventUrl } from '../../config/nostrGateways'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -18,14 +17,13 @@ interface CardViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
settings?: UserSettings
contentTypeIcon: IconDefinition
}
export const CardView: React.FC<CardViewProps> = ({
@@ -34,14 +32,13 @@ export const CardView: React.FC<CardViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
getIconForUrlType,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary,
settings
contentTypeIcon
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
@@ -54,11 +51,10 @@ export const CardView: React.FC<CardViewProps> = ({
const contentLength = (bookmark.content || '').length
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined, settings)
const cachedImage = useImageCache(previewImage || undefined)
// Fetch OG image if we don't have any other image
React.useEffect(() => {
@@ -94,19 +90,7 @@ export const CardView: React.FC<CardViewProps> = ({
)}
<div className="bookmark-header">
<span className="bookmark-type">
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{eventNevent ? (
@@ -129,23 +113,14 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-urls">
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
return (
<div key={urlIndex} className="url-row">
<button
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
>
{url}
</button>
<IconButton
icon={getIconForUrlType(url)}
ariaLabel="Open"
title="Open"
variant="success"
size={32}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
/>
</div>
<button
key={urlIndex}
className="bookmark-url"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
title="Open in reader"
>
{url}
</button>
)
})}
{extractedUrls.length > 1 && (
@@ -190,16 +165,14 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<a
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
title="Open author in search"
title="Open author profile"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</a>
</Link>
</div>
{/* CTA removed */}
</div>

View File

@@ -1,11 +1,9 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -13,9 +11,8 @@ interface CompactViewProps {
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleImage?: string
articleSummary?: string
settings?: UserSettings
contentTypeIcon: IconDefinition
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -24,17 +21,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
hasUrls,
extractedUrls,
onSelectUrl,
articleImage,
articleSummary,
settings
contentTypeIcon
}) => {
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
// Get cached image for thumbnail
const cachedImage = useImageCache(articleImage || undefined, settings)
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -58,27 +51,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{/* Thumbnail image */}
{cachedImage && (
<div className="compact-thumbnail">
<img src={cachedImage} alt="" />
</div>
)}
<span className="bookmark-type-compact">
{isWebBookmark ? (
<span className="fa-layers fa-fw">
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
</span>
) : bookmark.isPrivate ? (
<>
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
</>
) : (
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
)}
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
<div className="compact-text">

View File

@@ -1,12 +1,13 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
import { getEventUrl } from '../../config/nostrGateways'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -21,7 +22,7 @@ interface LargeViewProps {
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
settings?: UserSettings
contentTypeIcon: IconDefinition
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -37,9 +38,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName,
handleReadNow,
articleSummary,
settings
contentTypeIcon
}) => {
const cachedImage = useImageCache(previewImage || undefined, settings)
const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
@@ -92,16 +93,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
)}
<div className="large-footer">
<span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
<span className="large-author">
<a
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
<Link
to={`/p/${authorNpub}`}
className="author-link-minimal"
onClick={(e) => e.stopPropagation()}
>
{getAuthorDisplayName()}
</a>
</Link>
</span>
{eventNevent && (

View File

@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -15,6 +16,7 @@ import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -25,18 +27,26 @@ interface BookmarksProps {
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
// Check for highlight navigation state
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3))
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
@@ -45,12 +55,30 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
location.pathname === '/me/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Track previous location for going back from settings/me/explore
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub or nprofile to pubkey for profile view
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
} else if (decoded.type === 'nprofile') {
profilePubkey = decoded.data.pubkey
}
} catch (err) {
console.error('Failed to decode npub/nprofile:', err)
}
}
// Track previous location for going back from settings/me/explore/profile
useEffect(() => {
if (!showSettings && !showMe && !showExplore) {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore])
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -100,12 +128,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useBookmarksUI({ settings })
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
const prevPathnameRef = useRef<string>(location.pathname)
useEffect(() => {
if (isMobile && isSidebarOpen) {
// Only close if pathname actually changed, not on initial render or other state changes
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
toggleSidebar()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
prevPathnameRef.current = location.pathname
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
// Handle highlight navigation from explore page
useEffect(() => {
if (navigationState?.highlightId && navigationState?.openHighlights) {
// Open the highlights sidebar
setIsHighlightsCollapsed(false)
// Select the highlight (scroll happens automatically in useHighlightInteractions)
setSelectedHighlightId(navigationState.highlightId)
// Clear the state after handling to avoid re-triggering
navigate(location.pathname, { replace: true, state: {} })
}
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
const {
bookmarks,
@@ -124,6 +167,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
@@ -212,6 +256,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
showSupport={showSupport}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -267,11 +313,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -6,9 +6,10 @@ import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
@@ -98,8 +99,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
@@ -145,15 +151,47 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu) {
if (showArticleMenu || showVideoMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu])
}, [showArticleMenu, showVideoMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
if (!menuRef.current) return
const menuWrapper = menuRef.current
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
if (!menuElement) return
const rect = menuWrapper.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
// Open upward if there's not enough space below (with 20px buffer)
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
}
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -212,9 +250,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
relays: relayHints
})
// Check for source URL in 'r' tags
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
native: `nostr:${naddr}`,
naddr,
sourceUrl,
borisUrl: `${window.location.origin}/a/${naddr}`
}
}
@@ -239,6 +283,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
setShowArticleMenu(false)
}
const handleShareBoris = async () => {
try {
if (!articleLinks) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.borisUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleShareOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.sourceUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.sourceUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyBoris = async () => {
try {
if (!articleLinks) return
await navigator.clipboard.writeText(articleLinks.borisUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
await navigator.clipboard.writeText(articleLinks.sourceUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleOpenSearch = () => {
if (articleLinks) {
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
@@ -280,6 +391,51 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
const handleOpenExternalUrl = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowExternalMenu(false)
}
const handleCopyExternalUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowExternalMenu(false)
}
}
const handleShareExternalUrl = async () => {
try {
if (!selectedUrl) return
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: borisUrl
})
} else {
await navigator.clipboard.writeText(borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowExternalMenu(false)
}
}
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
setShowExternalMenu(false)
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -369,10 +525,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (loading) {
return (
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}
@@ -465,7 +619,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className="article-menu">
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
@@ -533,6 +687,54 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
className="article-menu-btn"
onClick={toggleExternalMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showExternalMenu && (
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
<button
className="article-menu-item"
onClick={handleCopyExternalUrl}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">
@@ -546,13 +748,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
{showArticleMenu && (
<div className="article-menu">
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleShareBoris}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleShareOriginal}
>
<FontAwesomeIcon icon={faShare} />
<span>Share Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleCopyBoris}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Link</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleCopyOriginal}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleOpenSearch}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="article-menu-item"

View File

@@ -1,41 +1,81 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
import { HighlightItem } from './HighlightItem'
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
interface ExploreProps {
relayPool: RelayPool
eventStore: IEventStore
settings?: UserSettings
activeTab?: TabType
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
type TabType = 'writings' | 'highlights'
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([])
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings, or friends only)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false
})
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
useEffect(() => {
const loadBlogPosts = async () => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
return
}
try {
// show spinner but keep existing posts
// show spinner but keep existing data
setLoading(true)
setError(null)
// Seed from in-memory cache if available to avoid empty flash
const cached = getCachedPosts(activeAccount.pubkey)
if (cached && cached.length > 0 && blogPosts.length === 0) {
setBlogPosts(cached)
// Use functional update to check current state without creating dependency
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
}
// Fetch the user's contacts (friends)
@@ -43,15 +83,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
relayPool,
activeAccount.pubkey,
(partial) => {
// When local contacts are available, kick off early posts fetch
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
// When local contacts are available, kick off early fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors(
relayPool,
Array.from(partial),
partialArray,
relayUrls,
(post) => {
// merge into UI and cache as we stream
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
@@ -65,7 +109,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
// Ensure union of streamed + final is displayed
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
@@ -78,45 +121,107 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return merged
})
})
// Fetch highlights
fetchHighlightsFromAuthors(
relayPool,
partialArray,
(highlight) => {
setHighlights((prev) => {
const exists = prev.some(h => h.id === highlight.id)
if (exists) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setLoading(false)
return
}
// Always proceed to load nostrverse content even if no contacts
// (removed blocking error for empty contacts)
// After full contacts, do a final pass for completeness
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// Fetch both friends content and nostrverse content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of posts) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount, blogPosts.length])
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !activeAccount
})
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -132,47 +237,222 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return `/a/${naddr}`
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
const handleHighlightClick = (highlightId: string) => {
const highlight = highlights.find(h => h.id === highlightId)
if (!highlight) return
// For nostr-native articles
if (highlight.eventReference) {
// Convert eventReference to naddr
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
} else {
// Already an naddr
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
}
}
// For web URLs
else if (highlight.urlReference) {
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
}
}
// Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => {
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine' && !visibility.mine) return false
if (h.level === 'friends' && !visibility.friends) return false
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
return true
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Filter blog posts by future dates and visibility, and add level classification
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const isNostrverse = !isMine && !isFriend
if (isMine && !visibility.mine) return false
if (isFriend && !visibility.friends) return false
if (isNostrverse && !visibility.nostrverse) return false
return true
})
.map(post => {
// Add level classification
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
{filteredBlogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
level={post.level}
/>
))}
</div>
)
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return classifiedHighlights.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
{classifiedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
relayPool={relayPool}
onHighlightClick={handleHighlightClick}
/>
))}
</div>
)
default:
return null
}
}
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = loading && !hasData
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
</p>
</div>
{loading && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
{/* Visibility filters */}
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<IconButton
icon={faArrowsRotate}
onClick={() => setRefreshTrigger(prev => prev + 1)}
title="Refresh content"
ariaLabel="Refresh content"
variant="ghost"
spin={loading || isRefreshing}
disabled={loading || isRefreshing}
/>
))}
{!loading && blogPosts.length === 0 && (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No blog posts found yet.</p>
</div>
)}
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
style={{
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: visibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: visibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: visibility.mine ? 1 : 0.4
}}
/>
</div>
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/explore')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/explore/writings')}
>
<FontAwesomeIcon icon={faNewspaper} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
{renderTabContent()}
</div>
)
}

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { fetchArticleTitle } from '../services/articleTitleResolver'
import { Highlight } from '../types/highlights'
interface HighlightCitationProps {
highlight: Highlight
relayPool?: RelayPool | null
}
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
highlight,
relayPool
}) => {
const [articleTitle, setArticleTitle] = useState<string>()
// Extract author pubkey from p tag directly
const authorPubkey = useMemo(() => {
// First try the extracted author from highlight.author
if (highlight.author) {
return highlight.author
}
// Fallback: extract directly from p tag
const pTag = highlight.tags.find(t => t[0] === 'p')
if (pTag && pTag[1]) {
console.log('📝 Found author from p tag:', pTag[1])
return pTag[1]
}
return undefined
}, [highlight.author, highlight.tags])
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
useEffect(() => {
if (!highlight.eventReference || !relayPool) {
return
}
const loadTitle = async () => {
try {
if (!highlight.eventReference) return
// Convert eventReference to naddr if needed
let naddr: string
if (highlight.eventReference.includes(':')) {
const parts = highlight.eventReference.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
naddr = nip19.naddrEncode({
kind,
pubkey,
identifier
})
} else {
naddr = highlight.eventReference
}
const title = await fetchArticleTitle(relayPool, naddr)
if (title) {
setArticleTitle(title)
}
} catch (error) {
console.error('Failed to load article title:', error)
}
}
loadTitle()
}, [highlight.eventReference, relayPool])
const authorName = authorProfile?.name || authorProfile?.display_name
// For nostr-native content with article reference
if (highlight.eventReference && (authorName || articleTitle)) {
return (
<div className="highlight-citation">
{authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
</div>
)
}
// For web URLs
if (highlight.urlReference) {
try {
const url = new URL(highlight.urlReference)
return (
<div className="highlight-citation">
{url.hostname}
</div>
)
} catch {
return null
}
}
return null
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
@@ -12,9 +13,161 @@ import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
} catch {
return false
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
const parts = text.split(urlPattern)
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
if (isImageUrl(part)) {
return (
<img
key={index}
src={part}
alt="Comment attachment"
className="highlight-comment-image"
loading="lazy"
/>
)
} else {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
)
}
}
return <span key={index}>{part}</span>
})}
</>
)
}
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -29,6 +182,7 @@ interface HighlightItemProps {
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
showCitation?: boolean
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
@@ -39,7 +193,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool,
eventStore,
onHighlightUpdate,
onHighlightDelete
onHighlightDelete,
showCitation = true
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
@@ -101,21 +256,22 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [isSelected])
// Close menu when clicking outside
// Close menu and reset delete confirm when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false)
setShowDeleteConfirm(false)
}
}
if (showMenu) {
if (showMenu || showDeleteConfirm) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showMenu])
}, [showMenu, showDeleteConfirm])
const handleItemClick = () => {
if (onHighlightClick) {
@@ -208,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Show server icon with relay info if available
// Show highlighter icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faServer,
icon: isLocalOrOffline ? faPlane : faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -225,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -236,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
icon: faHighlighter,
tooltip: relayNames.join('\n'),
spin: false
}
@@ -278,12 +434,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
const handleMenuToggle = (e: React.MouseEvent) => {
e.stopPropagation()
// Reset delete confirm state when opening/closing menu
if (!showMenu) {
setShowDeleteConfirm(false)
}
setShowMenu(!showMenu)
}
@@ -305,6 +461,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowDeleteConfirm(true)
}
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
handleConfirmDelete()
}
return (
<>
<div
@@ -318,7 +479,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton
className="highlight-timestamp"
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
window.location.href = highlightLinks.native
}}
>
{formatDateCompact(highlight.created_at)}
</CompactButton>
@@ -338,9 +502,19 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{highlight.content}
</blockquote>
{showCitation && (
<HighlightCitation
highlight={highlight}
relayPool={relayPool}
/>
)}
{highlight.comment && (
<div className="highlight-comment">
{highlight.comment}
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
<div className="highlight-comment-text">
<CommentContent text={highlight.comment} />
</div>
</div>
)}
@@ -364,6 +538,33 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
<div className="highlight-menu-wrapper" ref={menuRef}>
{showDeleteConfirm && canDelete && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
<button
onClick={handleConfirmDeleteClick}
disabled={isDeleting}
title="Confirm deletion"
style={{
color: 'rgb(220 38 38)',
background: 'rgba(220, 38, 38, 0.1)',
border: '1px solid rgb(220 38 38)',
borderRadius: '4px',
padding: '0.375rem',
cursor: isDeleting ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '33px',
minHeight: '33px',
transition: 'all 0.2s'
}}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</button>
</div>
)}
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
@@ -377,7 +578,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="highlight-menu-item"
@@ -402,17 +603,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -4,11 +4,14 @@ import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import { usePullToRefresh } from 'use-pull-to-refresh'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import RefreshIndicator from './RefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
import { HighlightSkeleton } from './Skeletons'
export interface HighlightVisibility {
nostrverse: boolean
@@ -63,6 +66,18 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
setShowHighlights(newValue)
onToggleHighlights?.(newValue)
}
// Pull-to-refresh for highlights
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !onRefresh
})
// Keep track of highlight updates
React.useEffect(() => {
@@ -113,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
/>
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">
<FontAwesomeIcon icon={faHighlighter} spin />
<div className="highlights-list" aria-busy="true">
{Array.from({ length: 4 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">
@@ -128,6 +145,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
</div>
) : (
<div className="highlights-list">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
{filteredHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
@@ -139,6 +160,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
showCitation={false}
/>
))}
</div>

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
import IconButton from '../IconButton'
interface HighlightsPanelHeaderProps {
loading: boolean
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
<IconButton
icon={faNetworkWired}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
ariaLabel="Toggle nostrverse highlights"
variant="ghost"
style={{
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
ariaLabel="Toggle friends highlights"
variant="ghost"
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
ariaLabel="Toggle my highlights"
variant="ghost"
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
</div>
)}
{onRefresh && (
<button
<IconButton
icon={faRotate}
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
ariaLabel="Refresh highlights"
variant="ghost"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
spin={loading}
/>
)}
{hasHighlights && (
<button
<IconButton
icon={showHighlights ? faEye : faEyeSlash}
onClick={onToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
variant="ghost"
/>
)}
</div>
<button
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
</div>
</div>
)

View File

@@ -12,6 +12,7 @@ interface IconButtonProps {
disabled?: boolean
spin?: boolean
className?: string
style?: React.CSSProperties
}
const IconButton: React.FC<IconButtonProps> = ({
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
size = 33,
disabled = false,
spin = false,
className = ''
className = '',
style
}) => {
return (
<button
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
style={{ width: size, height: size }}
style={{ width: size, height: size, ...style }}
disabled={disabled}
>
<FontAwesomeIcon icon={icon} spin={spin} />

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
@@ -18,28 +19,38 @@ import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
interface MeProps {
relayPool: RelayPool
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
// Update local state when prop changes
useEffect(() => {
@@ -50,66 +61,83 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to view your data')
if (!viewingPubkey) {
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Seed from cache if available to avoid empty flash
const cached = getCachedMeData(activeAccount.pubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
// Seed from cache if available to avoid empty flash (own profile only)
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
}
// Fetch highlights, read articles, and writings
const [userHighlights, userReadArticles, userWritings] = await Promise.all([
fetchHighlights(relayPool, activeAccount.pubkey),
fetchReadArticlesWithData(relayPool, activeAccount.pubkey),
fetchBlogPostsFromAuthors(relayPool, [activeAccount.pubkey], RELAYS)
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setReadArticles(userReadArticles)
setWritings(userWritings)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Update cache with all fetched data
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) {
console.error('Failed to load data:', err)
setError('Failed to load data. Please try again.')
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
}
}
loadData()
}, [relayPool, activeAccount])
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !viewingPubkey
})
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted
if (activeAccount) {
updateCachedHighlights(activeAccount.pubkey, updated)
// Update cache when highlight is deleted (own profile only)
if (isOwnProfile && viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated)
}
return updated
})
@@ -125,23 +153,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
return `/a/${naddr}`
}
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
const hasContentOrUrl = (ib: IndividualBookmark) => {
const hasContent = ib.content && ib.content.trim().length > 0
let hasUrl = false
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
}
if (ib.kind === 30023) return true
return hasContent || hasUrl
}
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route
@@ -161,41 +172,40 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
}
}
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
// Only show full loading screen if we don't have any data yet
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
.filter(hasContent)
if (loading && !hasData) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
</div>
)
}
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
const groups = groupIndividualBookmarks(filteredBookmarks)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
}
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return highlights.length === 0 ? (
<div className="explore-error">
<p>No highlights yet. Start highlighting content to see them here!</p>
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="highlights-list me-highlights-list">
@@ -211,23 +221,50 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
)
case 'reading-list':
if (showSkeletons) {
return (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
)
}
return allIndividualBookmarks.length === 0 ? (
<div className="explore-error">
<p>No bookmarks yet. Bookmark articles to see them here!</p>
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) => (
<BookmarkItem
key={`${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
onSelectUrl={handleSelectUrl}
/>
))}
</div>
{allIndividualBookmarks.length > 0 && (
<BookmarkFilters
selectedFilter={bookmarkFilter}
onFilterChange={setBookmarkFilter}
/>
)}
{filteredBookmarks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No bookmarks match this filter.
</div>
) : (
sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<h3 className="bookmarks-section-title">{section.title}</h3>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
onSelectUrl={handleSelectUrl}
/>
))}
</div>
</div>
)))}
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
@@ -262,9 +299,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
)
case 'archive':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return readArticles.length === 0 ? (
<div className="explore-error">
<p>No read articles yet. Mark articles as read to see them here!</p>
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
@@ -279,9 +325,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
)
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return writings.length === 0 ? (
<div className="explore-error">
<p>No articles written yet. Publish your first article to see it here!</p>
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
<div className="explore-grid">
@@ -302,51 +357,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
{loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/me/highlights')}
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
<span className="tab-count">({highlights.length})</span>
</button>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Reading List</span>
<span className="tab-count">({allIndividualBookmarks.length})</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
<span className="tab-count">({readArticles.length})</span>
</button>
{isOwnProfile && (
<>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
</button>
</>
)}
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate('/me/writings')}
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
<span className="tab-count">({writings.length})</span>
</button>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
import { UserSettings } from '../services/settingsService'
import { Highlight, HighlightLevel } from '../types/highlights'
import { HighlightVisibility } from './HighlightsPanel'
@@ -33,12 +34,13 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
}) => {
const cachedImage = useImageCache(image, settings)
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
// Determine the dominant highlight color based on visibility and priority
const highlightIndicatorStyles = useMemo(() => {
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
if (!highlights.length) return undefined
// Count highlights by level that are visible
@@ -65,17 +67,30 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
return {
backgroundColor: `rgba(${rgb}, 0.1)`,
borderColor: `rgba(${rgb}, 0.3)`,
color: '#fff'
// Only force white color in overlay context, otherwise let CSS handle it
...(isOverlay && { color: '#fff' })
}
}, [highlights, highlightVisibility, settings])
if (cachedImage) {
// Show hero section if we have an image OR a title
if (cachedImage || title) {
return (
<>
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{cachedImage ? (
<img src={cachedImage} alt={title || 'Article image'} />
) : (
<div className="reader-hero-placeholder">
<FontAwesomeIcon icon={faNewspaper} />
</div>
)}
{formattedDate && (
<div className="publish-date-topright">
<div
className="publish-date-topright"
style={{
color: textColor
}}
>
{formattedDate}
</div>
)}
@@ -93,7 +108,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && (
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
style={getHighlightIndicatorStyles(true)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -117,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{title && (
<div className="reader-header">
{formattedDate && (
<div className="publish-date-topright">
<div
className="publish-date-topright"
style={{
color: textColor
}}
>
{formattedDate}
</div>
)}
@@ -133,7 +153,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && (
<div
className="highlight-indicator"
style={highlightIndicatorStyles}
style={getHighlightIndicatorStyles(false)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -29,28 +29,39 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
return (
<div
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] bg-[rgba(26,26,26,0.85)] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
style={{
'--left-offset': leftOffset,
'--right-offset': rightOffset
'--right-offset': rightOffset,
backgroundColor: 'var(--color-bg-elevated)',
opacity: 0.95
} as React.CSSProperties}
>
<div className="flex-1 h-0.5 bg-white/10 rounded-full overflow-hidden relative">
<div
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
style={{ backgroundColor: 'var(--color-border)' }}
>
<div
className={`h-full rounded-full transition-all duration-300 relative ${
isComplete
? 'bg-green-500'
: 'bg-indigo-500'
: ''
}`}
style={{ width: `${clampedProgress}%` }}
style={{
width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
</div>
</div>
{showPercentage && (
<div className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : 'text-gray-500'
}`}>
<div
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : ''
}`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
>
{isComplete ? '✓' : `${clampedProgress}%`}
</div>
)}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'
interface RefreshIndicatorProps {
isRefreshing: boolean
pullPosition: number
}
const THRESHOLD = 80
/**
* Simple pull-to-refresh visual indicator
*/
const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
isRefreshing,
pullPosition
}) => {
const isVisible = isRefreshing || pullPosition > 0
if (!isVisible) return null
const opacity = Math.min(pullPosition / THRESHOLD, 1)
const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3
return (
<div
style={{
position: 'fixed',
top: `${translateY}px`,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
opacity,
transition: isRefreshing ? 'opacity 0.2s' : 'none'
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: 'var(--surface-secondary, #ffffff)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<FontAwesomeIcon
icon={faArrowRotateRight}
style={{
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
color: 'var(--accent-color, #3b82f6)'
}}
className={isRefreshing ? 'fa-spin' : ''}
/>
</div>
</div>
)
}
export default RefreshIndicator

View File

@@ -70,8 +70,12 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
}
}
// On mobile, default to collapsed (icon only). On desktop, always show details.
const showDetails = !isMobile || isExpanded
// On mobile when collapsed, make it circular like the highlight button
const isCollapsedOnMobile = isMobile && !isExpanded
return (
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
@@ -85,25 +89,75 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
) : undefined
}
onClick={handleClick}
style={{ cursor: isMobile ? 'pointer' : 'default' }}
style={{
position: 'fixed',
bottom: '32px',
left: '32px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: showDetails ? '0.5rem' : '0',
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
width: isCollapsedOnMobile ? '56px' : 'auto',
height: isCollapsedOnMobile ? '56px' : 'auto',
backgroundColor: 'rgba(39, 39, 42, 0.9)',
backdropFilter: 'blur(8px)',
border: '1px solid rgb(82, 82, 91)',
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
color: 'rgb(228, 228, 231)',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
cursor: isMobile ? 'pointer' : 'default',
opacity: isMobile && !showOnMobile ? 0 : 1,
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
transition: 'all 0.3s ease',
userSelect: 'none',
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
}}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
{showDetails && (
<>
<div className="relay-status-text">
<div
className="relay-status-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.125rem'
}}
>
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
No relays connected
</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
</span>
</>
)}
</div>

View File

@@ -1,8 +1,8 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
import { getProfileUrl } from '../config/nostrGateways'
const { getPubkeyFromDecodeResult } = Helpers
@@ -25,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
if (npub) {
return (
<a
href={getProfileUrl(npub)}
<Link
to={`/p/${npub}`}
className="nostr-mention"
target="_blank"
rel="noopener noreferrer"
>
@{display}
</a>
</Link>
)
}

View File

@@ -4,11 +4,10 @@ import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
@@ -22,10 +21,10 @@ const DEFAULT_SETTINGS: UserSettings = {
readingFont: 'source-serif-4',
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColor: '#fde047',
highlightColorNostrverse: '#9333ea',
highlightColorFriends: '#f97316',
highlightColorMine: '#ffff00',
highlightColorMine: '#fde047',
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
@@ -34,6 +33,7 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
}
interface SettingsProps {
@@ -159,13 +159,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
</div>
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
</div>
)

View File

@@ -1,16 +1,58 @@
import React from 'react'
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface StartupPreferencesSettingsProps {
interface LayoutBehaviorSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup & Behavior</h3>
<h3 className="section-title">Layout & Behavior</h3>
<div className="setting-group setting-inline">
<label>Default Bookmark View</label>
<div className="setting-buttons">
<IconButton
icon={faList}
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
title="Compact list view"
ariaLabel="Compact list view"
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
title="Cards view"
ariaLabel="Cards view"
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onUpdate({ defaultViewMode: 'large' })}
title="Large preview view"
ariaLabel="Large preview view"
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
<input
id="collapseOnArticleOpen"
type="checkbox"
checked={settings.collapseOnArticleOpen !== false}
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
className="setting-checkbox"
/>
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
@@ -66,5 +108,5 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
)
}
export default StartupPreferencesSettings
export default LayoutBehaviorSettings

View File

@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface LayoutNavigationSettingsProps {
interface LayoutBehaviorSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Layout & Navigation</h3>
<h3 className="section-title">Layout & Behavior</h3>
<div className="setting-group setting-inline">
<label>Default Bookmark View</label>
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={settings.sidebarCollapsed !== false}
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={settings.highlightsCollapsed !== false}
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with highlights panel collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<span>Rebroadcast events while browsing</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div>
)
}
export default LayoutNavigationSettings
export default LayoutBehaviorSettings

View File

@@ -1,173 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<IconButton
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
variant="ghost"
size={28}
/>
</div>
)}
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div>
)
}
export default OfflineModeSettings

View File

@@ -1,80 +1,206 @@
import React from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall'
import { useIsMobile } from '../../hooks/useMediaQuery'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
const PWASettings: React.FC = () => {
interface PWASettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const { isInstallable, isInstalled, installApp } = usePWAInstall()
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleInstall = async () => {
if (isInstalled) return
const success = await installApp()
if (success) {
console.log('App installed successfully')
}
}
if (isInstalled) {
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
<span>Boris is installed as an app</span>
</div>
<p className="setting-description">
You can launch Boris from your home screen or app drawer.
</p>
</div>
</div>
)
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
if (!isInstallable) {
return null
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
<span>Install Boris as an app</span>
<h3 className="section-title">App & Airplane Mode</h3>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Boris is offlinefirst by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
</p>
{/* Flight Mode Section - Checkboxes First */}
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<FontAwesomeIcon
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
/>
</div>
)}
</div>
{/* PWA Install Section - Paragraphs */}
<div className="setting-group">
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div className="setting-group">
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Install Boris on your device for a native app experience.
</p>
<button
onClick={handleInstall}
className="zap-preset-btn"
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
disabled={isInstalled || !isInstallable}
>
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
{isInstalled ? 'Installed' : 'Install App'}
</button>
</div>
</div>
<p className="setting-description">
Install Boris on your device for a native app experience with offline support.
</p>
<button
onClick={handleInstall}
className="install-button"
style={{
marginTop: '12px',
padding: '8px 16px',
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
{!isMobile && (
<img
src="/pwa.svg"
alt="Progressive Web App"
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
/>
)}
</div>
</div>
)

View File

@@ -1,6 +1,5 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
@@ -20,35 +19,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="settings-section">
<h3 className="section-title">Reading & Display</h3>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
<label>Font Size</label>
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
@@ -69,6 +39,94 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Paragraph Alignment</label>
<div className="setting-buttons">
<IconButton
icon={faAlignLeft}
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
title="Left aligned"
ariaLabel="Left aligned"
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faAlignJustify}
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
title="Justified"
ariaLabel="Justified"
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant="ghost"
style={{
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
}}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Font Size</label>
<div className="setting-control">
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">My Highlights</label>
<div className="setting-control">
@@ -99,39 +157,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
@@ -152,7 +177,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
interface ThemeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
const currentTheme = settings.theme ?? 'system'
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
const currentLightColor = settings.lightColorTheme ?? 'sepia'
// Determine which color picker to show based on current theme
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
// Color definitions for swatches
const darkColors = {
black: '#000000',
midnight: '#18181b',
charcoal: '#1c1c1e'
}
const lightColors = {
'paper-white': '#ffffff',
sepia: '#f4f1ea',
ivory: '#fffff0'
}
return (
<div className="settings-section">
<h3 className="section-title">Theme</h3>
<div className="setting-group setting-inline">
<label>Appearance</label>
<div className="setting-buttons">
<IconButton
icon={faSun}
onClick={() => onUpdate({ theme: 'light' })}
title="Light theme"
ariaLabel="Light theme"
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faMoon}
onClick={() => onUpdate({ theme: 'dark' })}
title="Dark theme"
ariaLabel="Dark theme"
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faDesktop}
onClick={() => onUpdate({ theme: 'system' })}
title="Use system preference"
ariaLabel="Use system preference"
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
/>
</div>
</div>
{showDarkColors && (
<div className="setting-group setting-inline">
<label>Dark Theme</label>
<div className="color-picker">
{Object.entries(darkColors).map(([key, color]) => (
<div
key={key}
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
title={key.charAt(0).toUpperCase() + key.slice(1)}
/>
))}
</div>
</div>
)}
{showLightColors && (
<div className="setting-group setting-inline">
<label>Light Theme</label>
<div className="color-picker">
{Object.entries(lightColors).map(([key, color]) => (
<div
key={key}
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
style={{
backgroundColor: color,
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
}}
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
/>
))}
</div>
</div>
)}
</div>
)
}
export default ThemeSettings

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { UserSettings } from '../../services/settingsService'
import { useIsMobile } from '../../hooks/useMediaQuery'
interface ZapSettingsProps {
settings: UserSettings
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
}
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const isMobile = useIsMobile()
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
const authorWeight = settings.zapSplitAuthorWeight ?? 50
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
<div className="settings-section">
<h3 className="section-title">Zap Splits</h3>
<div className="setting-group">
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡
</button>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Your Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {highlighterWeight}</span>
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div className="setting-group">
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡
</button>
</div>
</div>
<input
type="range"
min="0"
max="100"
value={highlighterWeight}
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Author(s) Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {authorWeight}</span>
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={highlighterWeight}
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
className="zap-split-slider"
list="highlighter-ticks"
/>
<datalist id="highlighter-ticks">
<option value="50" label="50%"></option>
</datalist>
</div>
</div>
<input
type="range"
min="0"
max="100"
value={authorWeight}
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Support Boris</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Author's Share: {authorWeight}</span>
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={authorWeight}
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
className="zap-split-slider"
list="author-ticks"
/>
<datalist id="author-ticks">
<option value="50" label="50%"></option>
</datalist>
</div>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={borisWeight}
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="zap-split-description">
Weights determine zap splits when highlighting nostr-native content.
If the content has multiple authors, their share is divided proportionally.
<div className="setting-group">
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={borisWeight}
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
className="zap-split-slider"
list="boris-ticks"
/>
<datalist id="boris-ticks">
<option value="5" label="5"></option>
</datalist>
</div>
</div>
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Weights determine zap splits when highlighting nostr-native content.
If the content has multiple authors, their share is divided proportionally.
</p>
</div>
{!isMobile && (
<img
src="/zaps.svg"
alt="Zap Splits"
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
/>
)}
</div>
</div>
)

View File

@@ -1,28 +1,22 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import { RelayPool } from 'applesauce-relay'
import IconButton from './IconButton'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
onOpenSettings: () => void
relayPool: RelayPool | null
isMobile?: boolean
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -54,14 +48,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
const profileImage = getProfileImage()
return (
@@ -124,15 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add bookmark"
ariaLabel="Add bookmark"
variant="ghost"
/>
)}
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -152,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
)}
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</>
)
}

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const BlogPostSkeleton: React.FC = () => {
return (
<div
className="blog-post-card"
style={{
textDecoration: 'none',
color: 'inherit',
display: 'block'
}}
aria-hidden="true"
>
<div className="blog-post-card-image">
<Skeleton height={200} style={{ display: 'block' }} />
</div>
<div className="blog-post-card-content">
<Skeleton
height={24}
width="85%"
style={{ marginBottom: '0.75rem' }}
className="blog-post-card-title"
/>
<Skeleton
count={2}
style={{ marginBottom: '0.5rem' }}
className="blog-post-card-summary"
/>
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={100} height={14} />
</span>
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={80} height={14} />
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
import { ViewMode } from '../Bookmarks'
interface BookmarkSkeletonProps {
viewMode: ViewMode
}
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
if (viewMode === 'compact') {
return (
<div
className="bookmark-item-compact"
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
aria-hidden="true"
>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
<Skeleton width={40} height={40} />
<div style={{ flex: 1, minWidth: 0 }}>
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="60%" height={14} />
</div>
</div>
</div>
)
}
if (viewMode === 'cards') {
return (
<div
className="bookmark-card"
style={{
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)',
marginBottom: '1rem'
}}
aria-hidden="true"
>
<Skeleton height={160} style={{ display: 'block' }} />
<div style={{ padding: '1rem' }}>
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<Skeleton width={80} height={14} />
<Skeleton width={60} height={14} />
</div>
</div>
</div>
)
}
// large view
return (
<div
className="bookmark-large"
style={{
marginBottom: '1.5rem',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
<Skeleton height={240} style={{ display: 'block' }} />
<div style={{ padding: '1.5rem' }}>
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<Skeleton circle width={32} height={32} />
<div style={{ flex: 1 }}>
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={100} height={12} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const ContentSkeleton: React.FC = () => {
return (
<div
className="reader-content"
style={{
maxWidth: '900px',
margin: '0 auto',
padding: '2rem 1rem'
}}
aria-hidden="true"
>
{/* Title */}
<Skeleton
height={48}
width="90%"
style={{ marginBottom: '1rem' }}
/>
{/* Byline / Meta */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
<Skeleton circle width={40} height={40} />
<div style={{ flex: 1 }}>
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={200} height={14} />
</div>
</div>
{/* Cover image */}
<Skeleton
height={400}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
{/* Paragraphs */}
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="80%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="65%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="90%" />
</div>
{/* Another image placeholder */}
<Skeleton
height={300}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="75%" />
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const HighlightSkeleton: React.FC = () => {
return (
<div
className="highlight-item"
style={{
padding: '1rem',
marginBottom: '0.75rem',
borderRadius: '8px',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
{/* Author line with avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<Skeleton circle width={24} height={24} />
<Skeleton width={120} height={14} />
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
</div>
{/* Highlight content */}
<div style={{ marginBottom: '0.5rem' }}>
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="70%" />
</div>
{/* Citation/context */}
<div style={{ marginTop: '0.75rem' }}>
<Skeleton width="90%" height={12} />
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react'
import { SkeletonTheme } from 'react-loading-skeleton'
interface SkeletonThemeProviderProps {
children: React.ReactNode
}
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
const [colors, setColors] = useState({
baseColor: '#27272a',
highlightColor: '#52525b'
})
useEffect(() => {
const updateColors = () => {
const rootStyles = getComputedStyle(document.documentElement)
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
setColors({ baseColor, highlightColor })
}
// Initial update
updateColors()
// Watch for theme changes via MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateColors()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
return (
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
{children}
</SkeletonTheme>
)
}

View File

@@ -0,0 +1,6 @@
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
export { BookmarkSkeleton } from './BookmarkSkeleton'
export { BlogPostSkeleton } from './BlogPostSkeleton'
export { HighlightSkeleton } from './HighlightSkeleton'
export { ContentSkeleton } from './ContentSkeleton'

235
src/components/Support.tsx Normal file
View File

@@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
import { fetchProfiles } from '../services/profileService'
import { UserSettings } from '../services/settingsService'
import { Models } from 'applesauce-core'
import { useEventModel } from 'applesauce-react/hooks'
import { useNavigate } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
interface SupportProps {
relayPool: RelayPool
eventStore: IEventStore
settings: UserSettings
}
type SupporterProfile = ZapSender
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadSupporters = async () => {
setLoading(true)
try {
const zappers = await fetchBorisZappers(relayPool)
if (zappers.length > 0) {
const pubkeys = zappers.map(z => z.pubkey)
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
}
setSupporters(zappers)
} catch (error) {
console.error('Failed to load supporters:', error)
} finally {
setLoading(false)
}
}
loadSupporters()
}, [relayPool, eventStore, settings])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
</div>
)
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
<div className="text-center mb-16 md:mb-20">
<div className="flex justify-center mb-8">
<img
src="/thank-you.svg"
alt="Thank you"
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
/>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
Thank You!
</h1>
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
Your{' '}
<a
href="https://www.readwithboris.com/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
zaps
</a>
{' '}help keep this project alive.
</p>
</div>
{supporters.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
<p>No supporters yet. Be the first to zap Boris!</p>
</div>
) : (
<>
{/* Whales Section */}
{supporters.filter(s => s.isWhale).length > 0 && (
<div className="mb-16 md:mb-20">
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
Legends
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
{supporters.filter(s => s.isWhale).map(supporter => (
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
))}
</div>
</div>
)}
{/* Regular Supporters Section */}
{supporters.filter(s => !s.isWhale).length > 0 && (
<div className="mb-12">
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
Supporters
</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
{supporters.filter(s => !s.isWhale).map(supporter => (
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
))}
</div>
</div>
)}
</>
)}
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<div className="text-center space-y-4">
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
Zap{' '}
<a
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
Boris
</a>
{' '}a{' '}
<a
href="https://www.readwithboris.com/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
meaningful amount of sats
</a>
{' '}and your avatar will show above.
</p>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Total supporters: {supporters.length}
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
</p>
</div>
</div>
</div>
</div>
)
}
interface SupporterCardProps {
supporter: SupporterProfile
isWhale: boolean
}
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
const navigate = useNavigate()
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
const picture = profile?.picture
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
const handleClick = () => {
const npub = nip19.npubEncode(supporter.pubkey)
navigate(`/p/${npub}`)
}
return (
<div className="flex flex-col items-center">
<div className="relative">
{/* Avatar */}
<div
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
`}
style={{
backgroundColor: 'var(--color-bg-elevated)'
}}
title={`${name}${supporter.totalSats.toLocaleString()} sats`}
onClick={handleClick}
>
{picture ? (
<img
src={picture}
alt={name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<FontAwesomeIcon
icon={faUserCircle}
className={isWhale ? 'text-5xl' : 'text-3xl'}
style={{ color: 'var(--color-border)' }}
/>
)}
</div>
{/* Whale Badge */}
{isWhale && (
<div
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
style={{ borderColor: 'var(--color-bg)' }}
>
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
</div>
)}
</div>
{/* Name and Total */}
<div className="mt-2 text-center">
<p
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
style={{ color: 'var(--color-text)' }}
>
{name}
</p>
<p
className={isWhale ? 'text-xs' : 'text-[10px]'}
style={{ color: 'var(--color-text-muted)' }}
>
{supporter.totalSats.toLocaleString()} sats
</p>
</div>
</div>
)
}
export default Support

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
@@ -31,6 +31,8 @@ interface ThreePaneLayoutProps {
showSettings: boolean
showExplore?: boolean
showMe?: boolean
showProfile?: boolean
showSupport?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -89,6 +91,12 @@ interface ThreePaneLayoutProps {
// Optional Me content
me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
// Optional Support content
support?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -97,13 +105,33 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const highlightsRef = useRef<HTMLDivElement>(null)
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// Now using window scroll (document scroll) instead of pane scroll
// Detect scroll direction and position to hide/show mobile buttons
// Only hide on scroll down when viewing article content
const isViewingArticle = !!(props.selectedUrl)
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
})
const showMobileButtons = scrollDirection !== 'down'
// Track if we're at the top of the page
const [isAtTop, setIsAtTop] = useState(true)
useEffect(() => {
if (!isMobile || !isViewingArticle) return
const handleScroll = () => {
setIsAtTop(window.scrollY <= 10)
}
handleScroll() // Check initial position
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [isMobile, isViewingArticle])
// Bookmark button: hide only when scrolling down
const showBookmarkButton = scrollDirection !== 'down'
// Highlights button: hide when scrolling down OR at the top
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
// Lock body scroll when mobile sidebar or highlights is open
useEffect(() => {
@@ -221,11 +249,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
{/* Mobile bookmark button - always show except on settings page */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
<button
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
@@ -241,11 +269,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button>
)}
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
{/* Mobile highlights button - only show when viewing article content */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
<button
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
@@ -295,8 +323,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
settings={props.settings}
/>
</div>
<div
@@ -320,6 +348,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.me}
</>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : props.showSupport && props.support ? (
// Render Support inside the main pane to keep side panels
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -385,7 +423,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
)}
<RelayStatusIndicator
relayPool={props.relayPool}
showOnMobile={showMobileButtons}
showOnMobile={showBookmarkButton}
/>
{props.toastMessage && (
<Toast

12
src/config/network.ts Normal file
View File

@@ -0,0 +1,12 @@
// Centralized network configuration for relay queries
// Keep timeouts modest for local-first, longer for remote; tweak per use-case
export const LOCAL_TIMEOUT_MS = 1200
export const REMOTE_TIMEOUT_MS = 6000
// Contacts often need a bit more time on mobile networks
export const CONTACTS_REMOTE_TIMEOUT_MS = 9000
// Future knobs could live here (e.g., max limits per kind)

View File

@@ -2,20 +2,21 @@
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
export const NOSTR_GATEWAY = 'https://nostr.at' as const
export const SEARCH_PORTAL = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
return `${NOSTR_GATEWAY}/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
return `${NOSTR_GATEWAY}/${nevent}`
}
/**
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// Check the prefix to determine if it's a profile or event
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
return `${NOSTR_GATEWAY}/p/${identifier}`
}
// Everything else (note, nevent, naddr) goes to /e/
return `${NOSTR_GATEWAY}/e/${identifier}`
// nostr.at uses simple /{identifier} format for all types
return `${NOSTR_GATEWAY}/${identifier}`
}
/**
* Get a search portal URL with a query
*/
export function getSearchUrl(query: string): string {
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { FastAverageColor } from 'fast-average-color'
interface AdaptiveTextColor {
textColor: string
}
/**
* Hook to determine optimal text color based on image background
* Samples the top-right corner of the image to ensure publication date is readable
*
* @param imageUrl - The URL of the image to analyze
* @returns Object containing textColor for optimal contrast
*/
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
const [colors, setColors] = useState<AdaptiveTextColor>({
textColor: '#ffffff'
})
useEffect(() => {
if (!imageUrl) {
// No image, use default white text
setColors({
textColor: '#ffffff'
})
return
}
const fac = new FastAverageColor()
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const width = img.naturalWidth
const height = img.naturalHeight
// Sample top-right corner (last 25% width, first 25% height)
const color = fac.getColor(img, {
left: Math.floor(width * 0.75),
top: 0,
width: Math.floor(width * 0.25),
height: Math.floor(height * 0.25)
})
console.log('Adaptive color detected:', {
hex: color.hex,
rgb: color.rgb,
isLight: color.isLight,
isDark: color.isDark
})
// Use library's built-in isLight check for optimal contrast
if (color.isLight) {
console.log('Light background detected, using black text')
setColors({
textColor: '#000000'
})
} else {
console.log('Dark background detected, using white text')
setColors({
textColor: '#ffffff'
})
}
} catch (error) {
// Fallback to default on error
console.error('Error analyzing image color:', error)
setColors({
textColor: '#ffffff'
})
}
}
img.onerror = () => {
// Fallback to default if image fails to load
setColors({
textColor: '#ffffff'
})
}
img.src = imageUrl
return () => {
fac.destroy()
}
}, [imageUrl])
return colors
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount, AccountManager } from 'applesauce-accounts'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
@@ -9,11 +10,10 @@ import { UserSettings } from '../services/settingsService'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
accountManager: any
activeAccount: IAccount | undefined
accountManager: AccountManager
naddr?: string
externalUrl?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
@@ -24,6 +24,7 @@ export const useBookmarksData = ({
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
@@ -116,11 +117,13 @@ export const useBookmarksData = ({
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!naddr) {
// Only fetch general highlights when not viewing an article (naddr) or external URL
// External URLs have their highlights fetched by useExternalUrlLoader
if (!naddr && !externalUrl) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService'
@@ -47,9 +47,9 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
})
}, [settings])
const toggleSidebar = () => {
const toggleSidebar = useCallback(() => {
setIsSidebarOpen(prev => !prev)
}
}, [])
return {
isMobile,

View File

@@ -71,7 +71,7 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl(
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
@@ -84,9 +84,9 @@ export function useExternalUrlLoader({
})
}
)
// Ensure final list is sorted and contains all items
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
// Highlights are already set via the streaming callback
// No need to set them again as that could cause a flash/disappearance
console.log(`📌 Finished fetching highlights for URL`)
} else {
console.log('📌 Highlight fetching for URLs not yet implemented')
}

View File

@@ -3,6 +3,7 @@ import { flushSync } from 'react-dom'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { IAccount } from 'applesauce-accounts'
import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService'
import { createHighlight } from '../services/highlightCreationService'
@@ -10,8 +11,7 @@ import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService'
interface UseHighlightCreationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any
activeAccount: IAccount | undefined
relayPool: RelayPool | null
eventStore: IEventStore | null
currentArticle: NostrEvent | undefined

View File

@@ -58,12 +58,15 @@ export const useHighlightInteractions = ({
}
}, [onHighlightClick, contentVersion])
// Scroll to selected highlight
// Scroll to selected highlight with retry mechanism
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
// Use a small delay to ensure DOM is updated
const timeoutId = setTimeout(() => {
let attempts = 0
const maxAttempts = 20 // Try for up to 2 seconds
const retryDelay = 100
const tryScroll = () => {
if (!contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
@@ -76,10 +79,16 @@ export const useHighlightInteractions = ({
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
} else if (attempts < maxAttempts) {
attempts++
setTimeout(tryScroll, retryDelay)
} else {
console.warn('Could not find mark element for highlight:', selectedHighlightId)
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
}
}, 100)
}
// Start trying after a small initial delay
const timeoutId = setTimeout(tryScroll, 100)
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])

View File

@@ -1,5 +1,3 @@
import { UserSettings } from '../services/settingsService'
/**
* Hook to return image URL for display
* Service Worker handles all caching transparently
@@ -9,9 +7,7 @@ import { UserSettings } from '../services/settingsService'
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
imageUrl: string | undefined
): string | undefined {
// Service Worker handles everything - just return the URL as-is
return imageUrl
@@ -22,9 +18,7 @@ export function useImageCache(
* Triggers a fetch so the SW can cache it even if not visible yet
*/
export function useCacheImageOnLoad(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
imageUrl: string | undefined
): void {
// Service Worker will cache on first fetch
// This hook is now a no-op, kept for API compatibility

View File

@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
interface UseSettingsParams {
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
// Apply theme with color variants (defaults to 'system' if not set)
applyTheme(
settings.theme ?? 'system',
settings.darkColorTheme ?? 'midnight',
settings.lightColorTheme ?? 'sepia'
)
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
@@ -65,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
console.log('✅ All styles applied')
}
@@ -77,7 +88,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const fullAccount = accountManager.getActive()
if (!fullAccount) throw new Error('No active account')
const factory = new EventFactory({ signer: fullAccount })
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
await saveSettings(relayPool, eventStore, factory, newSettings)
setSettings(newSettings)
setToastType('success')
setToastMessage('Settings saved')

View File

@@ -12,6 +12,8 @@
@import './styles/components/reader.css';
@import './styles/components/settings.css';
@import './styles/components/me.css';
@import './styles/components/pull-to-refresh.css';
@import './styles/components/skeletons.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
@import './styles/utils/legacy.css';

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {

View File

@@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30001)
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))

View File

@@ -16,11 +16,24 @@ export interface BookmarkData {
tags?: string[][]
}
export interface AddressPointer {
kind: number
pubkey: string
identifier: string
relays?: string[]
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
}
export interface ApplesauceBookmarks {
notes?: BookmarkData[]
articles?: BookmarkData[]
hashtags?: BookmarkData[]
urls?: BookmarkData[]
notes?: EventPointer[]
articles?: AddressPointer[]
hashtags?: string[]
urls?: string[]
}
export interface AccountWithExtension {
@@ -55,25 +68,83 @@ export const processApplesauceBookmarks = (
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
const allItems: BookmarkData[] = []
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
const allItems: IndividualBookmark[] = []
// Process notes (EventPointer[])
if (applesauceBookmarks.notes) {
applesauceBookmarks.notes.forEach((note: EventPointer) => {
allItems.push({
id: note.id,
content: '',
created_at: Math.floor(Date.now() / 1000),
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process articles (AddressPointer[])
if (applesauceBookmarks.articles) {
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
allItems.push({
id: coordinate,
content: '',
created_at: Math.floor(Date.now() / 1000),
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process hashtags (string[])
if (applesauceBookmarks.hashtags) {
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
// Process URLs (string[])
if (applesauceBookmarks.urls) {
applesauceBookmarks.urls.forEach((url: string) => {
allItems.push({
id: `url-${url}`,
content: url,
created_at: Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
})
})
}
return allItems
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
}))
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]

View File

@@ -33,6 +33,12 @@ export async function collectBookmarksFromEvents(
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
publicItemsAll.push({
@@ -45,13 +51,27 @@ export async function collectBookmarksFromEvents(
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000)
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
try {
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
@@ -94,7 +114,16 @@ export async function collectBookmarksFromEvents(
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
privateItemsAll.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
// Don't set latestContent to decrypted JSON - it's not user-facing content
@@ -106,7 +135,16 @@ export async function collectBookmarksFromEvents(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
privateItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
}
} catch {
// ignore individual event failures

View File

@@ -1,12 +1,10 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import {
AccountWithExtension,
NostrEvent,
dedupeNip51Events,
hydrateItems,
isAccountWithExtension,
isHexId,
hasNip04Decrypt,
hasNip44Decrypt,
dedupeBookmarksById,
@@ -16,7 +14,7 @@ import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { queryEvents } from './dataFetch'
@@ -31,23 +29,14 @@ export const fetchBookmarks = async (
if (!isAccountWithExtension(activeAccount)) {
throw new Error('Invalid account object provided')
}
// Get relay URLs from the pool
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
// Try local-first quickly, then full set fallback
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
console.log('🔍 Fetching bookmark events')
const rawEvents = await queryEvents(
relayPool,
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
{}
)
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
@@ -67,11 +56,28 @@ export const fetchBookmarks = async (
rawEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
})
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
// Log which events made it through deduplication
bookmarkListEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
})
// Check specifically for Primal's "reads" list
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
if (primalReads) {
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
} else {
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
}
if (bookmarkListEvents.length === 0) {
// Keep existing bookmarks visible; do not clear list if nothing new found
return
@@ -107,23 +113,88 @@ export const fetchBookmarks = async (
)
const allItems = [...publicItemsAll, ...privateItemsAll]
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
let idToEvent: Map<string, NostrEvent> = new Map()
// Separate hex IDs (regular events) from coordinates (addressable events)
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
// Check if it's a hex ID (64 character hex string)
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
// Coordinate format: kind:pubkey:identifier
coordinates.push(i.id)
}
})
const idToEvent: Map<string, NostrEvent> = new Map()
// Fetch regular events by ID
if (noteIds.length > 0) {
try {
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
const localHydrate$ = localHydrate.length > 0
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remoteHydrate$ = remoteHydrate.length > 0
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
const events = await queryEvents(
relayPool,
{ ids: Array.from(new Set(noteIds)) },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
events.forEach((e: NostrEvent) => {
idToEvent.set(e.id, e)
// Also store by coordinate if it's an addressable event
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
}
})
} catch (error) {
console.warn('Failed to fetch events for hydration:', error)
console.warn('Failed to fetch events by ID:', error)
}
}
// Fetch addressable events by coordinates
if (coordinates.length > 0) {
try {
// Group by kind for more efficient querying
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
coordinates.forEach(coord => {
const parts = coord.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
if (!byKind.has(kind)) {
byKind.set(kind, [])
}
byKind.get(kind)!.push({ pubkey, identifier })
})
// Query each kind group
for (const [kind, items] of byKind.entries()) {
const authors = Array.from(new Set(items.map(i => i.pubkey)))
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [kind], authors, '#d': identifiers },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
events.forEach((e: NostrEvent) => {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
// Also store by event ID
idToEvent.set(e.id, e)
})
}
} catch (error) {
console.warn('Failed to fetch addressable events:', error)
}
}
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)

View File

@@ -1,6 +1,7 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
import { queryEvents } from './dataFetch'
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
/**
* Fetches the contact list (follows) for a specific user
@@ -15,24 +16,27 @@ export const fetchContacts = async (
): Promise<Set<string>> => {
try {
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
// Local-first quick attempt
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const events = await lastValueFrom(
merge(local$, remote$).pipe(toArray())
const partialFollowed = new Set<string>()
const events = await queryEvents(
relayPool,
{ kinds: [3], authors: [pubkey] },
{
relayUrls,
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
onEvent: (event: { created_at: number; tags: string[][] }) => {
// Stream partials as we see any contact list
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1]) {
partialFollowed.add(tag[1])
}
}
if (onPartial && partialFollowed.size > 0) {
onPartial(new Set(partialFollowed))
}
}
}
)
const followed = new Set<string>()
if (events.length > 0) {

70
src/services/dataFetch.ts Normal file
View File

@@ -0,0 +1,70 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Filter } from 'nostr-tools/filter'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
export interface QueryOptions {
relayUrls?: string[]
localTimeoutMs?: number
remoteTimeoutMs?: number
onEvent?: (event: NostrEvent) => void
}
/**
* Unified local-first query helper with optional streaming callback.
* Returns all collected events (deduped by id) after both streams complete or time out.
*/
export async function queryEvents(
relayPool: RelayPool,
filter: Filter,
options: QueryOptions = {}
): Promise<NostrEvent[]> {
const {
relayUrls,
localTimeoutMs = LOCAL_TIMEOUT_MS,
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
onEvent
} = options
const urls = relayUrls && relayUrls.length > 0
? relayUrls
: Array.from(relayPool.relays.values()).map(r => r.url)
const ordered = prioritizeLocalRelays(urls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const local$: Observable<NostrEvent> = localRelays.length > 0
? relayPool
.req(localRelays, filter)
.pipe(
onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(),
takeUntil(timer(localTimeoutMs))
) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$: Observable<NostrEvent> = remoteRelays.length > 0
? relayPool
.req(remoteRelays, filter)
.pipe(
onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(),
takeUntil(timer(remoteTimeoutMs))
) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
// Deduplicate by id (callers can perform higher-level replaceable grouping if needed)
const byId = new Map<string, NostrEvent>()
for (const ev of events) {
if (!byId.has(ev.id)) byId.set(ev.id, ev)
}
return Array.from(byId.values())
}

View File

@@ -1,4 +1,5 @@
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
export interface CachedBlogPostPreview {
event: NostrEvent
@@ -11,6 +12,7 @@ export interface CachedBlogPostPreview {
type CacheValue = {
posts: CachedBlogPostPreview[]
highlights: Highlight[]
timestamp: number
}
@@ -22,8 +24,28 @@ export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
return entry.posts
}
export function getCachedHighlights(pubkey: string): Highlight[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.highlights
}
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts,
highlights: current?.highlights || [],
timestamp: Date.now()
})
}
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
const current = exploreCache.get(pubkey)
exploreCache.set(pubkey, {
posts: current?.posts || [],
highlights,
timestamp: Date.now()
})
}
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
@@ -39,4 +61,13 @@ export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): C
return merged
}
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
const current = exploreCache.get(pubkey)?.highlights || []
const byId = new Map(current.map(h => [h.id, h]))
byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(pubkey, merged)
return merged
}

View File

@@ -1,8 +1,7 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { queryEvents } from './dataFetch'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -35,49 +34,38 @@ export const fetchBlogPostsFromAuthors = async (
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
// Emit as we incorporate
if (onPost) {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
await queryEvents(
relayPool,
{ kinds: [30023], authors: pubkeys, limit: 100 },
{
relayUrls,
onEvent: (event: NostrEvent) => {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
// Emit as we incorporate
if (onPost) {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
onPost(post)
}
onPost(post)
}
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)

View File

@@ -7,12 +7,12 @@ import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { publishEvent } from './writeService'
// Boris pubkey for zap splits
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const {
getHighlightText,
@@ -118,59 +118,26 @@ export async function createHighlight(
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
// Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(signedEvent)
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
// Check current connection status - are we online or in flight mode?
// Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent)
// Check current connection status for UI feedback
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
const hasRemoteConnection = connectedRelays.some(url =>
!url.includes('localhost') && !url.includes('127.0.0.1')
)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Highlight relay status:', {
targetRelays: targetRelays.length,
expectedSuccessRelays,
isLocalOnly,
hasRemoteConnection,
eventId: signedEvent.id
})
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info and return IMMEDIATELY
const highlight = eventToHighlight(signedEvent)
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
highlight.publishedRelays = expectedSuccessRelays
highlight.isLocalOnly = isLocalOnly
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
// Publish to relays in the background (non-blocking)
// This allows instant UI updates while publishing happens asynchronously
relayPool.publish(targetRelays, signedEvent)
.then(() => {
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
})
.catch((error) => {
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
})
// Return the highlight immediately for instant UI updates
highlight.isOfflineCreated = isLocalOnly
return highlight
}

View File

@@ -1,5 +1,5 @@
export * from './highlights/fetchForArticle'
export * from './highlights/fetchForUrl'
export * from './highlights/fetchByAuthor'
export * from './highlights/fetchFromAuthors'

View File

@@ -14,10 +14,11 @@ export const fetchHighlightsForUrl = async (
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
@@ -45,11 +46,23 @@ export const fetchHighlightsForUrl = async (
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
// Rebroadcast events - but don't let errors here break the highlight display
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
} catch (err) {
console.error('Error fetching highlights for URL:', err)
// Return highlights that were already streamed via callback
// Don't return empty array as that would clear already-displayed highlights
return []
}
}

View File

@@ -0,0 +1,52 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch'
/**
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch highlights from')
return []
}
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], authors: pubkeys, limit: 200 },
{
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
}
)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights from authors:', error)
return []
}
}

View File

@@ -1,11 +1,10 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -28,58 +27,11 @@ export async function fetchReadArticles(
userPubkey: string
): Promise<ReadArticle[]> {
try {
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch kind:7 reactions (nostr-native articles)
const kind7Local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [7], authors: [userPubkey] })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const kind7Remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const kind7Events: NostrEvent[] = await lastValueFrom(
merge(kind7Local$, kind7Remote$).pipe(toArray())
)
// Fetch kind:17 reactions (external URLs)
const kind17Local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [17], authors: [userPubkey] })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const kind17Remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const kind17Events: NostrEvent[] = await lastValueFrom(
merge(kind17Local$, kind17Remote$).pipe(toArray())
)
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
])
const readArticles: ReadArticle[] = []
@@ -157,34 +109,13 @@ export async function fetchReadArticlesWithData(
return []
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the actual article events
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], ids: eventIds })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], ids: eventIds })
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const articleEvents: NostrEvent[] = await lastValueFrom(
merge(local$, remote$).pipe(toArray())
const articleEvents = await queryEvents(
relayPool,
{ kinds: [30023], ids: eventIds },
{ relayUrls: RELAYS }
)
// Deduplicate article events by ID

View File

@@ -0,0 +1,102 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { queryEvents } from './dataFetch'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
/**
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
await queryEvents(
relayPool,
{ kinds: [30023], limit },
{
relayUrls,
onEvent: (event: NostrEvent) => {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
}
)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch nostrverse blog posts:', error)
return []
}
}
/**
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], limit },
{}
)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch nostrverse highlights:', error)
return []
}
}

View File

@@ -0,0 +1,81 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { rebroadcastEvents } from './rebroadcastService'
import { UserSettings } from './settingsService'
/**
* Fetches profile metadata (kind:0) for a list of pubkeys
* Stores profiles in the event store and optionally to local relays
*/
export const fetchProfiles = async (
relayPool: RelayPool,
eventStore: IEventStore,
pubkeys: string[],
settings?: UserSettings
): Promise<NostrEvent[]> => {
try {
if (pubkeys.length === 0) {
return []
}
const uniquePubkeys = Array.from(new Set(pubkeys))
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Keep only the most recent profile for each pubkey
const profilesByPubkey = new Map<string, NostrEvent>()
const processEvent = (event: NostrEvent) => {
const existing = profilesByPubkey.get(event.pubkey)
if (!existing || event.created_at > existing.created_at) {
profilesByPubkey.set(event.pubkey, event)
// Store in event store immediately
eventStore.add(event)
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => processEvent(event)),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const profiles = Array.from(profilesByPubkey.values())
console.log('✅ Fetched', profiles.length, 'unique profiles')
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {
await rebroadcastEvents(profiles, relayPool, settings)
}
return profiles
} catch (error) {
console.error('Failed to fetch profiles:', error)
return []
}
}

View File

@@ -3,6 +3,7 @@ import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { firstValueFrom } from 'rxjs'
import { publishEvent } from './writeService'
const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
const APP_DATA_KIND = 30078 // NIP-78 Application Data
@@ -47,6 +48,12 @@ export interface UserSettings {
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
// Mobile settings
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
// Theme preference
theme?: 'dark' | 'light' | 'system' // default: system
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify
}
export async function loadSettings(
@@ -143,11 +150,10 @@ export async function saveSettings(
relayPool: RelayPool,
eventStore: IEventStore,
factory: EventFactory,
settings: UserSettings,
relays: string[]
settings: UserSettings
): Promise<void> {
console.log('💾 Saving settings to nostr:', settings)
// Create NIP-78 application data event manually
// Note: AppDataBlueprint is not available in the npm package
const draft = await factory.create(async () => ({
@@ -156,14 +162,12 @@ export async function saveSettings(
tags: [['d', SETTINGS_IDENTIFIER]],
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
eventStore.add(signed)
await relayPool.publish(relays, signed)
// Use unified write service
await publishEvent(relayPool, eventStore, signed)
console.log('✅ Settings published successfully')
}

View File

@@ -0,0 +1,57 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
/**
* Unified write helper: add event to EventStore, detect connectivity,
* mark for offline sync if needed, and publish in background.
*/
export async function publishEvent(
relayPool: RelayPool,
eventStore: IEventStore,
event: NostrEvent
): Promise<void> {
// Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(event)
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
// Check current connection status - are we online or in flight mode?
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(isLocalRelay)
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Event relay status:', {
targetRelays: RELAYS.length,
expectedSuccessRelays: expectedSuccessRelays.length,
isLocalOnly,
hasRemoteConnection,
eventId: event.id.slice(0, 8)
})
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
markEventAsOfflineCreated(event.id)
}
// Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event)
.then(() => {
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
})
.catch((error) => {
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
})
}

View File

@@ -0,0 +1,127 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { isValidZap, getZapSender, getZapAmount } from 'applesauce-core/helpers'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { BORIS_PUBKEY } from './highlightCreationService'
import { RELAYS } from '../config/relays'
export interface ZapSender {
pubkey: string
totalSats: number
zapCount: number
isWhale: boolean // >= 69420 sats
}
/**
* Fetches zap receipts (kind:9735) for Boris and aggregates by sender
* @param relayPool - The relay pool to query
* @returns Array of senders who zapped >= 2100 sats, sorted by total desc
*/
export async function fetchBorisZappers(
relayPool: RelayPool
): Promise<ZapSender[]> {
try {
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
// Use all configured relays plus specific zap-heavy relays
const zapRelays = [
...RELAYS,
'wss://nostr.mutinywallet.com', // Common zap relay
'wss://relay.getalby.com/v1', // Alby zap relay
]
const prioritized = prioritizeLocalRelays(zapRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Fetch zap receipts with Boris as recipient
const filter = {
kinds: [9735],
'#p': [BORIS_PUBKEY]
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const zapReceipts = await lastValueFrom(
merge(local$, remote$).pipe(toArray())
)
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
// Dedupe by event ID and validate
const uniqueReceipts = new Map<string, NostrEvent>()
let invalidCount = 0
zapReceipts.forEach(receipt => {
if (!uniqueReceipts.has(receipt.id)) {
if (isValidZap(receipt)) {
uniqueReceipts.set(receipt.id, receipt)
} else {
invalidCount++
}
}
})
console.log(`${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
// Aggregate by sender using applesauce helpers
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
for (const receipt of uniqueReceipts.values()) {
const senderPubkey = getZapSender(receipt)
const amountMsats = getZapAmount(receipt)
if (!senderPubkey || !amountMsats || amountMsats === 0) {
console.warn('Invalid zap receipt - missing sender or amount:', receipt.id)
continue
}
const amountSats = Math.floor(amountMsats / 1000)
const existing = senderTotals.get(senderPubkey) || { totalSats: 0, zapCount: 0 }
senderTotals.set(senderPubkey, {
totalSats: existing.totalSats + amountSats,
zapCount: existing.zapCount + 1
})
}
console.log(`👥 Found ${senderTotals.size} unique senders`)
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
const zappers: ZapSender[] = Array.from(senderTotals.entries())
.filter(([, data]) => data.totalSats >= 2100)
.map(([pubkey, data]) => ({
pubkey,
totalSats: data.totalSats,
zapCount: data.zapCount,
isWhale: data.totalSats >= 69420
}))
.sort((a, b) => b.totalSats - a.totalSats)
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
return zappers
} catch (error) {
console.error('Failed to fetch zap receipts:', error)
return []
}
}

View File

@@ -2,6 +2,8 @@
/* Body - keep only app-specific overrides */
body {
background: var(--color-bg-subtle);
color: var(--color-text);
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
overflow-x: hidden;
@@ -22,7 +24,7 @@ body.mobile-sidebar-open {
justify-content: center;
text-align: center;
padding: 2rem;
color: rgb(212 212 216); /* zinc-300 */
color: var(--color-text);
}

View File

@@ -4,10 +4,6 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -47,10 +43,246 @@
--safe-area-right: env(safe-area-inset-right, 0px);
}
/* Dark theme (default) */
:root.theme-dark {
color-scheme: dark;
--color-bg: #18181b; /* zinc-900 */
--color-bg-elevated: #27272a; /* zinc-800 */
--color-bg-subtle: #1e1e1e; /* between zinc-800 and zinc-900 */
--color-border: #3f3f46; /* zinc-700 */
--color-border-subtle: #52525b; /* zinc-600 */
--color-text: #e4e4e7; /* zinc-200 */
--color-text-secondary: #a1a1aa; /* zinc-400 */
--color-text-muted: #71717a; /* zinc-500 */
--color-primary: #6366f1; /* indigo-500 */
--color-primary-hover: #4f46e5; /* indigo-600 */
}
/* Light theme */
:root.theme-light {
color-scheme: light;
--color-bg: #ffffff; /* white */
--color-bg-elevated: #f5f5f5; /* gray-100 */
--color-bg-subtle: #fafafa; /* gray-50 */
--color-border: #e5e7eb; /* gray-200 */
--color-border-subtle: #d1d5db; /* gray-300 */
--color-text: #111827; /* gray-900 */
--color-text-secondary: #374151; /* gray-700 */
--color-text-muted: #6b7280; /* gray-500 */
--color-primary: #4f46e5; /* indigo-600 */
--color-primary-hover: #4338ca; /* indigo-700 */
/* Highlight colors for light theme - use same Tailwind colors */
--highlight-color-mine: #fde047; /* yellow-300 */
--highlight-color-friends: #f97316; /* orange-500 */
--highlight-color-nostrverse: #9333ea; /* purple-600 */
}
/* System theme - follow OS preference */
:root.theme-system {
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root.theme-system {
--color-bg: #18181b;
--color-bg-elevated: #27272a;
--color-bg-subtle: #1e1e1e;
--color-border: #3f3f46;
--color-border-subtle: #52525b;
--color-text: #e4e4e7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
}
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
:root.theme-system {
--color-bg: #ffffff;
--color-bg-elevated: #f5f5f5;
--color-bg-subtle: #fafafa;
--color-border: #e5e7eb;
--color-border-subtle: #d1d5db;
--color-text: #111827;
--color-text-secondary: #374151;
--color-text-muted: #6b7280;
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
/* Standard highlight colors */
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
}
/* Dark Color Theme Variants */
/* Midnight (default) - current zinc palette */
:root.dark-midnight {
--color-bg: #18181b; /* zinc-900 */
--color-bg-elevated: #27272a; /* zinc-800 */
--color-bg-subtle: #0f0f11; /* darker than zinc-900 */
--color-border: #3f3f46; /* zinc-700 */
--color-border-subtle: #52525b; /* zinc-600 */
--color-text: #e4e4e7; /* zinc-200 */
--color-text-secondary: #a1a1aa; /* zinc-400 */
--color-text-muted: #71717a; /* zinc-500 */
}
/* Black - true black for OLED */
:root.dark-black {
--color-bg: #000000; /* true black */
--color-bg-elevated: #0a0a0a; /* very dark gray */
--color-bg-subtle: #000000; /* true black for body */
--color-border: #1a1a1a;
--color-border-subtle: #2a2a2a;
--color-text: #e4e4e7; /* zinc-200 */
--color-text-secondary: #a1a1aa; /* zinc-400 */
--color-text-muted: #71717a; /* zinc-500 */
}
/* Charcoal - warmer, softer dark */
:root.dark-charcoal {
--color-bg: #1c1c1e; /* warmer dark */
--color-bg-elevated: #2c2c2e;
--color-bg-subtle: #16161a; /* darker charcoal */
--color-border: #3a3a3c;
--color-border-subtle: #48484a;
--color-text: #e4e4e7; /* zinc-200 */
--color-text-secondary: #a1a1aa; /* zinc-400 */
--color-text-muted: #71717a; /* zinc-500 */
}
/* Light Color Theme Variants */
/* Paper White (default) - pure white */
:root.light-paper-white {
--color-bg: #ffffff; /* white */
--color-bg-elevated: #f5f5f5; /* gray-100 */
--color-bg-subtle: #fafafa; /* gray-50 */
--color-border: #e5e7eb; /* gray-200 */
--color-border-subtle: #d1d5db; /* gray-300 */
/* Standard highlight colors */
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
/* Sepia - warm, reading-friendly */
:root.light-sepia {
--color-bg: #f4f1ea; /* warm beige */
--color-bg-elevated: #ebe6db; /* darker beige */
--color-bg-subtle: #f9f6f0; /* lighter beige */
--color-border: #d4cfc4; /* warm gray border */
--color-border-subtle: #c4bfb4;
--color-text: #2d2a24; /* warm dark brown */
--color-text-secondary: #5d5a54;
--color-text-muted: #8d8a84;
/* Standard highlight colors */
--highlight-color-mine: #fde047; /* yellow-300 */
--highlight-color-friends: #f97316; /* orange-500 */
--highlight-color-nostrverse: #9333ea; /* purple-600 */
}
/* Ivory - soft, creamy */
:root.light-ivory {
--color-bg: #fffff0; /* ivory */
--color-bg-elevated: #faf8f0; /* cream */
--color-bg-subtle: #fefef8;
--color-border: #e8e6de;
--color-border-subtle: #d8d6ce;
--color-text: #1a1a18; /* near black with warm tint */
--color-text-secondary: #4a4a48;
--color-text-muted: #7a7a78;
/* Standard highlight colors */
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
/* System theme color variants */
@media (prefers-color-scheme: dark) {
:root.theme-system.dark-midnight {
--color-bg: #18181b;
--color-bg-elevated: #27272a;
--color-bg-subtle: #0f0f11;
--color-border: #3f3f46;
--color-border-subtle: #52525b;
--color-text: #e4e4e7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
}
:root.theme-system.dark-black {
--color-bg: #000000;
--color-bg-elevated: #0a0a0a;
--color-bg-subtle: #000000;
--color-border: #1a1a1a;
--color-border-subtle: #2a2a2a;
--color-text: #e4e4e7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
}
:root.theme-system.dark-charcoal {
--color-bg: #1c1c1e;
--color-bg-elevated: #2c2c2e;
--color-bg-subtle: #16161a;
--color-border: #3a3a3c;
--color-border-subtle: #48484a;
--color-text: #e4e4e7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
}
}
@media (prefers-color-scheme: light) {
:root.theme-system.light-paper-white {
--color-bg: #ffffff;
--color-bg-elevated: #f5f5f5;
--color-bg-subtle: #fafafa;
--color-border: #e5e7eb;
--color-border-subtle: #d1d5db;
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
:root.theme-system.light-sepia {
--color-bg: #f4f1ea;
--color-bg-elevated: #ebe6db;
--color-bg-subtle: #f9f6f0;
--color-border: #d4cfc4;
--color-border-subtle: #c4bfb4;
--color-text: #2d2a24;
--color-text-secondary: #5d5a54;
--color-text-muted: #8d8a84;
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
:root.theme-system.light-ivory {
--color-bg: #fffff0;
--color-bg-elevated: #faf8f0;
--color-bg-subtle: #fefef8;
--color-border: #e8e6de;
--color-border-subtle: #d8d6ce;
--color-text: #1a1a18;
--color-text-secondary: #4a4a48;
--color-text-muted: #7a7a78;
--highlight-color-mine: #fde047;
--highlight-color-friends: #f97316;
--highlight-color-nostrverse: #9333ea;
}
}

View File

@@ -1,14 +1,35 @@
/* Bookmark item and blog post cards */
.bookmark-item { background: rgb(24 24 27); /* zinc-900 */ padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.bookmark-item { background: var(--color-bg); padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.bookmark-item:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); }
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: rgb(255 255 255); /* white */ font-size: 1.2rem; }
.bookmark-url { color: rgb(99 102 241); /* indigo-500 */ text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: var(--color-text); font-size: 1.2rem; }
.bookmark-url { color: var(--color-primary); text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-url:hover { text-decoration: underline; }
.bookmark-content { color: rgb(212 212 216); /* zinc-300 */ margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: rgb(161 161 170); /* zinc-400 */ font-size: 0.9rem; margin-top: 0.5rem; }
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
.bookmarks-section-title {
font-size: 0.75rem !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
color: var(--color-text-muted) !important;
padding: 1.5rem 0.5rem 0.375rem !important;
margin: 0 !important;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.bookmarks-section:first-of-type .bookmarks-section-title {
border-top: none;
padding-top: 0.5rem !important;
}
.bookmark-section-action {
padding: 1.5rem 0.5rem 0.375rem;
}
.bookmarks-section:first-of-type .bookmark-section-action {
padding-top: 0.5rem;
}
.individual-bookmarks { margin: 1rem 0; }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: rgb(255 255 255); /* white */ }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
@@ -19,74 +40,79 @@
.bookmarks-grid.bookmarks-large { gap: 1rem; }
}
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid rgb(39 39 42); /* zinc-800 */ word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
.individual-bookmark:hover { border-color: rgb(63 63 70); /* zinc-700 */ background: rgb(39 39 42); /* zinc-800 */ }
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid var(--color-bg-elevated); word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid rgb(39 39 42); /* zinc-800 */ border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: rgb(39 39 42); /* zinc-800 */ border-bottom-color: rgb(63 63 70); /* zinc-700 */ transform: none; box-shadow: none; }
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.compact-row.clickable { cursor: pointer; }
.compact-row.clickable:active { opacity: 0.8; }
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: rgb(99 102 241); /* indigo-500 */ font-size: 0.85rem; flex-shrink: 0; }
.compact-text { flex: 1; min-width: 0; color: rgb(212 212 216); /* zinc-300 */ font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-date-compact { font-size: 0.7rem; color: rgb(113 113 122); /* zinc-500 */ flex-shrink: 0; white-space: nowrap; }
.compact-read-btn { background: transparent; color: rgb(161 161 170); /* zinc-400 */ border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
.compact-read-btn:hover { color: rgb(212 212 216); /* zinc-300 */ }
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: var(--color-primary); font-size: 0.85rem; flex-shrink: 0; }
.compact-text { flex: 1; min-width: 0; color: var(--color-text); font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-date-compact { font-size: 0.7rem; color: var(--color-text-muted); flex-shrink: 0; white-space: nowrap; }
.compact-read-btn { background: transparent; color: var(--color-text-secondary); border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
.compact-read-btn:hover { color: var(--color-text); }
.compact-read-btn:active { transform: translateY(1px); }
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
.bookmark-type { color: rgb(99 102 241); /* indigo-500 */ font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ background: rgb(24 24 27); /* zinc-900 */ padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ }
.bookmark-date-link { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: rgb(96 165 250); /* blue-400 */ text-decoration: underline; }
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: rgb(212 212 216); /* zinc-300 */ line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: rgb(161 161 170); /* zinc-400 */ cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
.expand-toggle:hover { color: rgb(212 212 216); /* zinc-300 */ }
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: var(--color-primary); text-decoration: underline; }
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: var(--color-text); line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: var(--color-text-secondary); cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
.expand-toggle:hover { color: var(--color-text); }
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
.bookmark-meta-minimal { font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ }
.author-link-minimal { color: rgb(161 161 170); /* zinc-400 */ text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: rgb(212 212 216); /* zinc-300 */ }
.read-now-button-minimal { background: rgb(99 102 241); /* indigo-500 */ color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
.read-now-button-minimal:hover { background: rgb(79 70 229); /* indigo-600 */ }
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: rgb(99 102 241); /* indigo-500 */ cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: rgb(129 140 248); /* indigo-400 */ }
.bookmark-meta-minimal { font-size: 0.8rem; color: var(--color-text-secondary); }
.author-link-minimal { color: var(--color-text-secondary); text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: var(--color-text); }
.read-now-button-minimal { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
.read-now-button-minimal:hover { background: var(--color-primary-hover); }
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
/* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgb(39 39 42); /* zinc-800 */ }
.large-preview-image { width: 100%; height: 180px; background: rgb(24 24 27); /* zinc-900 */ background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ position: relative; }
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
.large-preview-image:hover { opacity: 0.9; }
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
.preview-placeholder { font-size: 3rem; color: rgb(82 82 91); /* zinc-600 */ }
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; }
.large-content { padding: 1.25rem; }
.large-text { color: rgb(212 212 216); /* zinc-300 */ font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ padding-top: 0.75rem; border-top: 1px solid rgb(63 63 70); /* zinc-700 */ }
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
.large-author { flex: 1; }
.large-read-button { background: rgb(99 102 241); /* indigo-500 */ color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
.large-read-button:hover { background: rgb(79 70 229); /* indigo-600 */ }
.large-read-button { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
.large-read-button:hover { background: var(--color-primary-hover); }
/* Blog cards (Explore) */
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
.explore-header { text-align: center; margin-bottom: 3rem; }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: rgb(99 102 241); /* indigo-500 */ display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
.explore-loading, .explore-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: rgba(255, 255, 255, 0.7); }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
.explore-header .me-tabs { text-align: left; margin-top: 2rem; width: 100%; max-width: 100%; justify-content: flex-start; }
.explore-loading, .explore-error, .explore-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: var(--color-text-secondary); }
.explore-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: rgb(239 68 68); /* red-500 */ }
.explore-empty { color: var(--color-text-secondary); }
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
.blog-post-card { background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: rgb(99 102 241); /* indigo-500 */ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: rgb(9 9 11); /* zinc-950 */ display: flex; align-items: center; justify-content: center; }
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
.blog-post-card.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
.blog-post-card.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); display: flex; align-items: center; justify-content: center; position: relative; }
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
.blog-post-image-placeholder { font-size: 3rem; color: rgb(82 82 91); /* zinc-600 */ display: flex; align-items: center; justify-content: center; }
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: rgba(255, 255, 255, 0.95); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-post-card-summary { font-size: 0.875rem; color: rgba(255, 255, 255, 0.6); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid rgb(63 63 70); /* zinc-700 */ font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; }
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
@media (max-width: 768px) {
@@ -97,4 +123,3 @@
.blog-post-card-content { padding: 1rem; }
}

View File

@@ -3,29 +3,29 @@
.setting-group.setting-inline { display: flex; align-items: center; gap: 1rem; }
.setting-label { text-align: left; flex: 1; }
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
.setting-group.setting-inline label { margin-bottom: 0; }
.setting-group label { display: block; margin-bottom: 0.5rem; color: rgb(212 212 216); /* zinc-300 */ font-weight: 500; text-align: left; }
.setting-group.setting-inline label { margin-bottom: 0; min-width: 220px; }
.setting-group label { display: block; margin-bottom: 0.5rem; color: var(--color-text); font-weight: 500; text-align: left; }
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
.color-swatch { width: 33px; height: 33px; border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
.color-swatch:hover { border-color: rgb(161 161 170); /* zinc-400 */ }
.color-swatch.active { border-color: rgb(99 102 241); /* indigo-500 */ box-shadow: 0 0 0 2px rgb(99 102 241); /* indigo-500 */ }
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); /* black */ font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); /* white */ }
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.font-size-btn:hover { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ }
.font-size-btn.active { background: rgb(99 102 241); /* indigo-500 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
.color-swatch { width: 33px; height: 33px; border: 1px solid var(--color-border-subtle); border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
.color-swatch:hover { border-color: var(--color-text-secondary); }
.color-swatch.active { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary); }
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); }
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.font-size-btn:hover { background: var(--color-border); border-color: var(--color-text-muted); }
.font-size-btn.active { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.setting-preview {
margin: 1.5rem 0;
padding: 1rem;
background: rgb(24 24 27); /* zinc-900 */
border: 1px solid rgb(63 63 70); /* zinc-700 */
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
max-width: 100%;
overflow: hidden;
}
.preview-label { font-size: 0.875rem; color: rgb(161 161 170); /* zinc-400 */ margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-label { font-size: 0.875rem; color: var(--color-text-secondary); margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-content {
color: rgb(228 228 231); /* zinc-200 */
color: var(--color-text);
line-height: 1.7;
max-width: 100%;
overflow-wrap: break-word;
@@ -35,20 +35,21 @@
.preview-content h3 {
margin: 0 0 1rem 0;
font-size: 1.5em;
color: rgb(255 255 255); /* white */
color: var(--color-text);
word-wrap: break-word;
}
.preview-content p {
margin: 0.75rem 0;
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.setting-select { width: 100%; padding: 0.5rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 4px; color: rgb(255 255 255); /* white */ font-size: 1rem; }
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: rgb(99 102 241); /* indigo-500 */ }
.setting-select:focus { outline: none; border-color: var(--color-primary); }
.font-select option { padding: 0.5rem; font-size: 1rem; }
.checkbox-label { display: flex !important; align-items: center; gap: 0.75rem; cursor: pointer; user-select: none; text-align: left; justify-content: flex-start; margin-bottom: 0 !important; font-weight: normal !important; }
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: rgb(99 102 241); /* indigo-500 */ }
.checkbox-label span { color: rgb(228 228 231); /* zinc-200 */ text-align: left; font-weight: 500; }
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: var(--color-primary); }
.checkbox-label span { color: var(--color-text); text-align: left; font-weight: 500; }
/* Mobile responsive styles */
@media (max-width: 768px) {
@@ -58,6 +59,10 @@
gap: 0.75rem;
}
.setting-group.setting-inline label {
min-width: unset;
}
.setting-inline .setting-select {
width: 100%;
min-width: unset;
@@ -81,4 +86,3 @@
}
}

View File

@@ -3,10 +3,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgb(82 82 91); /* zinc-600 */
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
background: rgb(39 39 42); /* zinc-800 */
color: rgb(228 228 231); /* zinc-200 */
background: var(--color-bg-elevated);
color: var(--color-text);
cursor: pointer;
min-width: 33px;
min-height: 33px;
@@ -14,16 +14,16 @@
box-sizing: border-box;
}
.icon-button:hover { background: rgb(63 63 70); /* zinc-700 */ }
.icon-button:hover { background: var(--color-border); }
.icon-button:active { transform: translateY(1px); }
.icon-button.primary { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
.icon-button.primary { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.icon-button.primary:hover { filter: brightness(1.05); }
.icon-button.success { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
.icon-button.success { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.icon-button.success:hover { filter: brightness(1.1); }
.icon-button.ghost { background: rgb(39 39 42); /* zinc-800 */ }
.icon-button.ghost { background: var(--color-bg-elevated); }
/* Mobile touch target improvements */
@media (max-width: 768px) {
@@ -42,9 +42,8 @@
/* Disable hover effects on touch devices */
@media (pointer: coarse) {
.icon-button:hover { background: rgb(39 39 42); /* zinc-800 */ }
.icon-button.ghost:hover { background: rgb(39 39 42); /* zinc-800 */ }
.icon-button:active { background: rgb(63 63 70); /* zinc-700 */ }
.icon-button:hover { background: var(--color-bg-elevated); }
.icon-button.ghost:hover { background: var(--color-bg-elevated); }
.icon-button:active { background: var(--color-border); }
}

View File

@@ -3,7 +3,7 @@
display: flex;
gap: 0.5rem;
margin-top: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
max-width: 600px;
margin-left: auto;
@@ -18,7 +18,7 @@
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, rgb(161 161 170)); /* zinc-400 */
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
@@ -28,25 +28,32 @@
}
.me-tab:hover {
color: var(--text-primary, rgb(228 228 231)); /* zinc-200 */
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
background: var(--color-bg-elevated);
}
.me-tab.active {
color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
border-bottom-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* Highlights tab uses the user's custom "my highlights" color */
/* Highlights tab uses the actual highlight style when active */
.me-tab[data-tab="highlights"].active {
color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
color: var(--color-text);
border-bottom-color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 20%, transparent);
}
.me-tab[data-tab="highlights"].active:hover {
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 30%, transparent);
}
/* Reading List tab uses blue color to match bookmarks icon */
.me-tab[data-tab="reading-list"].active {
color: rgb(99 102 241); /* indigo-500 */
border-bottom-color: rgb(99 102 241); /* indigo-500 */
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.me-tab svg {
@@ -60,6 +67,10 @@
width: 100%;
}
.me-tab-content:has(.bookmark-filters) {
padding-top: 0.25rem;
}
/* Align highlight list width with profile card width on /me */
.me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
@@ -72,6 +83,15 @@
text-align: left; /* Override center alignment from .app */
}
/* Bookmark filters in Me page */
.me-tab-content .bookmark-filters {
background: transparent;
border: none;
padding: 0;
justify-content: center;
margin-bottom: 0.25rem;
}
/* Ensure all reading list elements are left-aligned */
.bookmarks-list .individual-bookmark,
.bookmarks-list .individual-bookmark * {
@@ -80,25 +100,25 @@
/* Enhanced border styling for reading list cards */
.bookmarks-list .individual-bookmark {
border: 1px solid rgb(82 82 91) !important; /* zinc-600 */
background: rgb(24 24 27) !important; /* zinc-900 */
border: 1px solid var(--color-border-subtle) !important;
background: var(--color-bg) !important;
}
.bookmarks-list .individual-bookmark:hover {
border-color: rgb(113 113 122) !important; /* zinc-500 */
background: rgb(39 39 42) !important; /* zinc-800 */
border-color: var(--color-border) !important;
background: var(--color-bg-elevated) !important;
}
.bookmark-item {
padding: 1rem;
background: var(--card-bg, rgb(255 255 255)); /* white */
border: 1px solid var(--border-color, rgb(228 228 231)); /* zinc-200 */
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
transition: all 0.2s ease;
}
.bookmark-item:hover {
border-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
@@ -110,13 +130,13 @@
.bookmark-item h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
color: var(--text-primary, rgb(0 0 0)); /* black */
color: var(--color-text);
}
.bookmark-item p {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary, rgb(113 113 122)); /* zinc-500 */
color: var(--color-text-secondary);
line-height: 1.5;
}

View File

@@ -1,28 +1,47 @@
/* Add Bookmark Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
.modal-content { background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
.modal-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
@media (max-width: 768px) {
.modal-overlay { padding: 0; align-items: flex-end; }
.modal-content { max-width: 100%; max-height: 95vh; max-height: 95dvh; border-radius: 16px 16px 0 0; margin: 0; padding-bottom: var(--safe-area-bottom); }
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ }
.modal-header h2 { margin: 0; font-size: 1.5rem; color: rgb(255 255 255); /* white */ }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid var(--color-border); }
.modal-header h2 { margin: 0; font-size: 1.5rem; color: var(--color-text); }
.modal-form { padding: 1.5rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: rgb(212 212 216); /* zinc-300 */ font-size: 0.9rem; font-weight: 500; }
.fetching-indicator { font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(255 255 255); /* white */ font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: rgb(99 102 241); /* indigo-500 */ }
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: var(--color-text); font-size: 0.9rem; font-weight: 500; }
.fetching-indicator { font-size: 0.8rem; color: var(--color-text-secondary); font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--color-primary); }
.form-group input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
.form-group textarea { resize: vertical; min-height: 80px; }
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ line-height: 1.4; }
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); /* red-600 */ border-radius: 6px; color: rgb(220 38 38); /* red-600 */ font-size: 0.9rem; margin-bottom: 1rem; }
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: var(--color-text-secondary); line-height: 1.4; }
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); border-radius: 6px; color: rgb(220 38 38); font-size: 0.9rem; margin-bottom: 1rem; }
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
.btn-secondary { padding: 0.75rem 1.5rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-secondary:hover:not(:disabled) { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-secondary:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-primary); color: var(--color-text); }
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary { padding: 0.75rem 1.5rem; background: rgb(99 102 241); /* indigo-500 */ border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
.btn-primary:hover:not(:disabled) { background: rgb(79 70 229); /* indigo-600 */ }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--color-primary); border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
/* Confirm Dialog */
.confirm-dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
.confirm-dialog { background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 12px; max-width: 400px; width: 100%; padding: 1.5rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); }
.confirm-dialog-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; margin: 0 auto 1rem; font-size: 1.5rem; }
.confirm-dialog-icon.danger { background: rgba(220, 38, 38, 0.1); color: rgb(220 38 38); }
.confirm-dialog-icon.warning { background: rgba(251, 191, 36, 0.1); color: rgb(251 191 36); }
.confirm-dialog-icon.info { background: rgba(59, 130, 246, 0.1); color: rgb(59 130 246); }
.confirm-dialog-title { margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600; color: var(--color-text); text-align: center; }
.confirm-dialog-message { margin: 0 0 1.5rem 0; font-size: 0.9rem; color: var(--color-text-secondary); text-align: center; line-height: 1.5; }
.confirm-dialog-actions { display: flex; gap: 0.75rem; }
.confirm-dialog-btn { flex: 1; padding: 0.75rem 1rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.confirm-dialog-btn.cancel { background: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text); }
.confirm-dialog-btn.cancel:hover { background: var(--color-border); }
.confirm-dialog-btn.confirm.danger { background: rgb(220 38 38); color: white; }
.confirm-dialog-btn.confirm.danger:hover { background: rgb(185 28 28); }
.confirm-dialog-btn.confirm.warning { background: rgb(251 191 36); color: rgb(17 24 39); }
.confirm-dialog-btn.confirm.warning:hover { background: rgb(245 158 11); }
.confirm-dialog-btn.confirm.info { background: rgb(59 130 246); color: white; }
.confirm-dialog-btn.confirm.info:hover { background: rgb(37 99 235); }

View File

@@ -1,12 +1,14 @@
/* Profile UI fragments */
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
.author-card-clickable:hover { border-color: var(--color-primary); background: var(--color-bg-elevated); transform: translateY(-1px); }
.author-card-clickable:active { transform: translateY(0); }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); }
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
.author-card-avatar svg { font-size: 2.5rem; }
.author-card-content { flex: 1; min-width: 0; text-align: left; }
.author-card-name { font-size: 1rem; font-weight: 600; color: rgb(228 228 231); /* zinc-200 */ margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: rgb(161 161 170); /* zinc-400 */ line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
@media (max-width: 768px) {
.author-card-container {
@@ -26,4 +28,3 @@
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
}

Some files were not shown because too many files have changed in this diff Show More