Compare commits

..

67 Commits

Author SHA1 Message Date
Gigi
c0f34b684d chore: bump version to 0.5.6 2025-10-13 17:12:24 +02:00
Gigi
613956bbaf fix: use round checkmark icon (faCheckCircle) in Mark as Read button 2025-10-13 17:10:29 +02:00
Gigi
041ba5c05b style: remove horizontal divider above Mark as Read button 2025-10-13 17:09:26 +02:00
Gigi
05c21cfd6d style: remove horizontal divider below article menu button 2025-10-13 17:07:02 +02:00
Gigi
4898f99ae1 style: make article menu button more subtle by removing border 2025-10-13 17:05:49 +02:00
Gigi
be920e8c44 fix: remove extra horizontal divider above article menu 2025-10-13 17:05:15 +02:00
Gigi
0fa5ac536b feat: add three-dot menu to articles and enhance highlight menus
- Add three-dot menu button at end of articles (before Mark as Read)
- Right-aligned menu with two options:
  - Open on Nostr (using nostr gateway/portal)
  - Open with Native App (using nostr: URI scheme)
- Add 'Open with Native App' option to highlight card menus
- Menu only appears for nostr-native articles (kind:30023)
- Styled consistently with highlight card menus
- Click outside to close menu functionality
2025-10-13 17:03:00 +02:00
Gigi
cef359af29 fix: ensure code blocks use monospace fonts explicitly
- Add explicit monospace font-family to all pre elements
- Include Courier New as additional cross-platform fallback
- Apply to both reader-markdown and reader-html contexts
- Ensures code always renders in monospace even if Prism theme is overridden
2025-10-13 16:58:03 +02:00
Gigi
2de72b73c1 feat: add Prism.js syntax highlighting for code blocks
- Install prismjs and rehype-prism-plus packages
- Integrate rehype-prism plugin into ReactMarkdown
- Use prism-tomorrow dark theme for syntax highlighting
- Enhanced code block styling with better padding and borders
- Inline code now has distinct styling from code blocks
- Monospace font for all code (Monaco, Menlo, Consolas)
- Improved readability with proper line-height and spacing
2025-10-13 16:55:06 +02:00
Gigi
a794331c1a feat: add image placeholders to blog post cards in /explore
- Show newspaper icon placeholder when blog posts don't have images
- Always render image container with consistent height
- Match the same placeholder style as large bookmark preview
- Improves visual consistency across the app
2025-10-13 16:52:32 +02:00
Gigi
e09be543bc feat: add caching to /me page for faster loading
- Create meCache service to store highlights, bookmarks, and read articles
- Seed Me component from cache on load to avoid empty flash
- Show small spinner while refreshing if cached data is displayed
- Update cache when highlights are deleted
- Only show full loading screen if no cached data is available
- Improves perceived performance similar to /explore page
2025-10-13 16:50:43 +02:00
Gigi
88085c48d2 style: improve bookmarks sidebar visual design
- Replace green buttons with purple/blue primary color
- Add subtle borders to card and large preview views
- Enable image previews in card view for all bookmarks (not just articles)
- Fetch OG images for regular bookmarks in card view
- Improve hover states with lighter border colors
2025-10-13 16:47:41 +02:00
Gigi
e32010771b refactor: make /me Reading List use same components as bookmark sidebar
- Reuse BookmarkItem component for rendering individual bookmarks
- Apply same filtering logic (hasContentOrUrl) as BookmarkList
- Add view mode controls (compact/cards/large) matching sidebar
- Count shows individual bookmarks not bookmark lists
- Keeps code DRY by reusing existing components and logic
2025-10-13 16:41:01 +02:00
Gigi
03e7484e71 fix: preserve reading font settings in markdown images
- Remove inline styles from custom image component
- Let CSS inheritance handle font and styling properly
- Images now respect user's reading font and size settings
2025-10-13 16:35:11 +02:00
Gigi
d9fd4ec286 feat: enable inline image rendering in nostr-native blog posts
- Install rehype-raw plugin for HTML support in ReactMarkdown
- Configure ReactMarkdown to parse and render HTML img tags
- Add responsive image styling with max-width and auto height
- Images now render inline in nostr-native blog posts with proper styling
2025-10-13 16:34:08 +02:00
Gigi
8f14f0347c docs: update CHANGELOG for v0.5.5 2025-10-13 16:33:03 +02:00
Gigi
9b5bb8496f chore: bump version to 0.5.5 2025-10-13 16:32:25 +02:00
Gigi
9264a78c95 Merge pull request #4 from dergigi/me-page
feat(me): two-pane layout, tabs, refined highlight cards, and mark-as-read
2025-10-13 15:31:44 +01:00
Gigi
326d571871 fix(content): include currentArticle in useEffect deps to satisfy lint 2025-10-13 16:27:18 +02:00
Gigi
744e86b290 style: match /me profile card width to highlight cards
- Constrain AuthorCard width via header context
- Remove extra padding on .me-highlights-list so widths align
- Keeps both at 600px max with auto centering
2025-10-13 16:20:54 +02:00
Gigi
e46b68da7e style: improve Me page mobile tabs and avoid overlap with sidebar buttons
- Add top margin to header on mobile for floating buttons
- Tighten tab paddings and content spacing
- Reduce left/right padding for more room on small screens
2025-10-13 16:15:21 +02:00
Gigi
811a962632 style: reduce margins/paddings to make highlight cards more compact
- Decrease list gap and content padding
- Bring corner buttons closer to edges (header/footer 0.5rem, quote 0.25/0.5rem)
- Keep safe spacing to avoid text/button overlap
2025-10-13 16:13:11 +02:00
Gigi
eb82e8762a style: tighten vertical spacing on highlight cards
- Reduce header/footer vertical padding (0.5rem -> 0.375rem)
- Reduce content top/bottom padding (3rem -> 2.25rem)
- Slightly reduce gaps to compact layout
2025-10-13 16:09:42 +02:00
Gigi
d919da153f feat: make quote icon a CompactButton in top-left corner
- Replace static quote icon with CompactButton
- Position top-left with same corner margin as others
- Preserve level-based coloring via CSS (
  - mine: var(--highlight-color-mine)
  - friends: var(--highlight-color-friends)
  - nostrverse: var(--highlight-color-nostrverse)
)
2025-10-13 16:08:37 +02:00
Gigi
8389d5811a fix: make relay button match menu spacing within footer
- Force footer relay indicator to be in normal flow (position: static)
- Remove margins and rely on footer gap/padding
- Ensures same visual spacing as the three-dot CompactButton
2025-10-13 15:55:18 +02:00
Gigi
0aa0c44441 fix: group relay icon and author in footer-left for consistent alignment
- Add  container (relay + author)
- Footer now uses space-between with left group and right menu
- Consistent gap and truncation behavior for author
- Matches the visual rhythm of the three-dot button
2025-10-13 15:47:22 +02:00
Gigi
49ea7504a1 style: make relay indicator match CompactButton (same look as menu)
- Remove custom opacity for relay indicator
- Rely on  styles for hover/active/spacing
- Ensures relay button visually matches three-dot menu button
2025-10-13 15:37:16 +02:00
Gigi
6602fb9359 fix: align relay indicator within footer with symmetric spacing
- Place relay indicator as first element in footer (no absolute positioning)
- Remove extra author left padding; rely on footer gap/padding
- Ensure consistent 1rem outer padding and 0.75rem gap between footer items
- Matches spacing of timestamp and menu in their corners
2025-10-13 15:35:07 +02:00
Gigi
731eb6915a fix: align corner elements symmetrically with proper margins
- Change quote icon left margin from 0.5rem to 1rem
- Change relay indicator left margin from 0.5rem to 1rem
- All corner elements now have 1rem horizontal margin (matching header/footer padding)
- Adjust author padding-left to 2.5rem to accommodate relay icon
- Creates symmetrical appearance across all four corners
2025-10-13 15:28:55 +02:00
Gigi
3459179310 refactor: move quote icon to top-left corner and make it smaller
- Position quote icon absolutely in top-left corner (0.5rem, 0.5rem)
- Reduce font size from 1.2rem to 0.85rem
- Add opacity: 0.7 to make it more subtle
- Remove quote icon from document flow
- Update content padding to be uniform (3rem 1rem)
- Remove gap from highlight-item since content is only in-flow element
- Clean up mobile styles for quote icon
2025-10-13 15:25:10 +02:00
Gigi
b1f951daf5 fix: position relay indicator in bottom-left corner
- Move relay indicator to be absolutely positioned in bottom-left corner
- Similar to timestamp in top-right corner
- Add padding-left to author name to prevent overlap with relay icon
- Relay indicator sits outside the footer content flow
- z-index: 10 ensures it's above footer background
2025-10-13 15:23:29 +02:00
Gigi
caebcec0af fix: move relay indicator to footer to prevent overlap with author
- Move relay indicator from quote icon to footer as first element
- Remove absolute positioning from relay indicator
- Update footer layout: relay icon, author name, menu button (left to right)
- Author name now sits to the right of relay icon with no overlap
- Use margin-right: auto on author to push menu to the right
2025-10-13 15:20:40 +02:00
Gigi
5f50f4b8d6 fix: improve spacing and alignment of highlight card elements
- Remove padding from highlight-item, let header/footer/content handle it
- Fix header and footer to use left: 0 and right: 0 instead of negative offsets
- Use padding on quote-icon and highlight-content for proper spacing
- Adjust relay indicator positioning to work with new padding model
- Ensure all elements have proper vertical and horizontal spacing
2025-10-13 15:17:08 +02:00
Gigi
3039208ba0 refactor: make header and footer full-width with borders and corners
- Header and footer now span 100% width of the card
- Header has top border and top rounded corners
- Footer has bottom border and bottom rounded corners
- Main item has only left/right borders
- Properly adjust padding to accommodate absolute positioned header/footer
- Border colors transition correctly for hover, selected, and level states
2025-10-13 15:03:53 +02:00
Gigi
397c956e87 refactor: create highlight-header for timestamp positioning
- Add highlight-header container for timestamp
- Position header absolutely in top-right corner
- Remove padding-right workaround from highlight-content
- Use pointer-events to allow timestamp click while preventing header clicks
- Cleaner structure prevents any text overlap naturally
2025-10-13 14:45:21 +02:00
Gigi
cf47ceb74b refactor: create highlight-footer for perfect alignment
- Rename highlight-meta to highlight-footer for semantic clarity
- Use flexbox with space-between for proper element spacing
- Ensure author name and menu button are perfectly vertically aligned
- Add min-height to author to match button height
2025-10-13 14:42:23 +02:00
Gigi
da7aa2c115 fix: add padding to prevent quote text from overlapping timestamp 2025-10-13 14:40:30 +02:00
Gigi
c0046bc04c refactor: remove clock icon from timestamp button 2025-10-13 14:38:31 +02:00
Gigi
2f8f6a0652 feat: introduce CompactButton component for highlight cards
- Create reusable CompactButton component for small, borderless buttons
- Refactor relay indicator to use CompactButton
- Refactor menu toggle button to use CompactButton
- Make timestamp clickable with CompactButton (shows full date on hover)
- Simplify CSS by removing duplicate button styles
- Improve mobile touch targets for all compact buttons
2025-10-13 14:01:51 +02:00
Gigi
9a6f788b98 feat: move highlight timestamp to top-right corner of cards 2025-10-13 12:55:00 +02:00
Gigi
c1a628260c feat(icons): import books.svg as raw and register faBooks; add *.svg?raw types 2025-10-13 12:42:56 +02:00
Gigi
7b0bd7077c feat: use faBooks icon for Mark as Read button 2025-10-13 12:29:23 +02:00
Gigi
7d47f0a86e feat: add custom FontAwesome Pro books icon for Archive tab 2025-10-13 12:28:08 +02:00
Gigi
44fcd74cbe refactor: rename Library tab to Archive 2025-10-13 12:20:18 +02:00
Gigi
5ac0e7ed87 fix: deduplicate article events in library to prevent showing duplicates 2025-10-13 12:15:16 +02:00
Gigi
743968f7fb feat: use user's custom highlight color for Highlights tab 2025-10-13 12:14:25 +02:00
Gigi
e1a3ae4b4d feat: render library articles using BlogPostCard component for consistency 2025-10-13 12:13:12 +02:00
Gigi
acf13448ae style: improve tab border styling for dark theme 2025-10-13 12:10:19 +02:00
Gigi
a5daa8b56c fix: remove incorrect useSettings hook usage in Me component 2025-10-13 12:02:15 +02:00
Gigi
267169c5c1 fix: correct fetchBookmarks usage with callback pattern in Me component 2025-10-13 10:45:46 +02:00
Gigi
89272dd9a3 style: left-align text inside author card 2025-10-13 10:37:16 +02:00
Gigi
d059212238 style: constrain /me page content width to match author card (600px) 2025-10-13 10:36:36 +02:00
Gigi
0d8a3576a6 feat: add tabbed layout to /me page with Highlights, Reading List, and Library tabs 2025-10-13 10:35:14 +02:00
Gigi
8910c2750a feat: replace username with AuthorCard component on /me page 2025-10-13 10:30:58 +02:00
Gigi
12393d6df4 feat: implement two-pane layout for /me page with article sources and highlights 2025-10-13 10:27:18 +02:00
Gigi
6c0a2439ad feat: instant mark-as-read with checkmark animation and read status checking
- Add functions to check if article/URL was already marked as read via NIP-25 reactions
- Make mark-as-read action instant with fire-and-forget publishing
- Add checkmark icon animation when marking as read
- Display read status on load by querying kind:7 (nostr events) and kind:17 (websites) reactions
- Add green styling for already-read state
- Button shows checkmark and is disabled when article is already marked as read
2025-10-13 10:17:16 +02:00
Gigi
d83712127b docs: update CHANGELOG for v0.5.4 2025-10-13 10:08:32 +02:00
Gigi
55325cd7ad chore(release): bump version to 0.5.4 2025-10-13 10:05:53 +02:00
Gigi
82e508fca6 refactor(styles): extract base, layout, components, utils CSS and use aggregator imports in src/index.css 2025-10-13 09:47:09 +02:00
Gigi
8ff32e9363 chore(styles): scaffold styles directory and empty files 2025-10-13 09:40:23 +02:00
Gigi
477308632b fix: add safe area insets for symmetrical mobile button positioning
- Add safe-area-inset-top/bottom/left/right to all mobile floating buttons
- Ensures proper spacing on devices with notches and home indicators
- Makes relay status indicator perfectly aligned with bookmark/highlights buttons
2025-10-13 09:23:17 +02:00
Gigi
9ffd06f5e3 docs: update CHANGELOG for v0.5.3 2025-10-13 08:55:59 +02:00
Gigi
a89c87819a chore: bump version to 0.5.3 2025-10-13 08:55:12 +02:00
Gigi
b09ae3bae3 fix: increase profile icon size when logged out
- Change .profile-avatar svg font-size from 1rem to 1.25rem
- Matches size of other icon buttons in sidebar header
2025-10-13 08:54:18 +02:00
Gigi
6ea8c0d40e feat: make relay status indicator smaller and hide on scroll
- Reduce overall size of indicator on desktop (smaller padding, font sizes)
- On mobile, match size with sidebar toggle buttons (var(--min-touch-target))
- Auto-collapse on mobile (collapsed by default, tap to expand)
- Hide when scrolling down on mobile, show when scrolling up
- Match behavior of other mobile UI controls for consistency
2025-10-13 08:53:17 +02:00
Gigi
079501337c fix: filter out invalid bookmarks without IDs
- Skip bookmarks that don't have a valid ID instead of generating temporary IDs
- Use bookmark's original created_at timestamp for added_at instead of Date.now()
- Fixes issue where first 2 bookmarks always showed 'Now' timestamp with no content
2025-10-13 08:50:29 +02:00
Gigi
5bf0382227 docs(changelog): add v0.5.1 and v0.5.2 entries 2025-10-13 00:14:16 +02:00
35 changed files with 2764 additions and 516 deletions

View File

@@ -7,6 +7,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.5] - 2025-01-27
### Added
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
- Two-pane layout for `/me` page with article sources and highlights
- Custom FontAwesome Pro books icon for Archive tab
- CompactButton component for highlight cards
- Instant mark-as-read functionality with checkmark animation and read status checking
### Changed
- Rename Library tab to Archive
- Move highlight timestamp to top-right corner of cards
- Replace username with AuthorCard component on `/me` page
- Use user's custom highlight color for Highlights tab
- Render library articles using BlogPostCard component for consistency
- Use faBooks icon for Mark as Read button
- Make quote icon a CompactButton in top-left corner
### Fixed
- Include currentArticle in useEffect deps to satisfy lint
- Deduplicate article events in library to prevent showing duplicates
- Remove incorrect useSettings hook usage in Me component
- Correct fetchBookmarks usage with callback pattern in Me component
- Add padding to prevent quote text from overlapping timestamp
- Improve spacing and alignment of highlight card elements
- Align corner elements symmetrically with proper margins
- Group relay icon and author in footer-left for consistent alignment
- Position relay indicator in bottom-left corner to prevent overlap with author
### Style
- Match `/me` profile card width to highlight cards
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
- Reduce margins/paddings to make highlight cards more compact
- Tighten vertical spacing on highlight cards
- Left-align text inside author card
- Constrain `/me` page content width to match author card (600px)
- Improve tab border styling for dark theme
- Make relay indicator match CompactButton (same look as menu)
- Align relay indicator within footer with symmetric spacing
- Make header and footer full-width with borders and corners
## [0.5.4] - 2025-10-13
### Changed
- Refactor CSS into modular structure
- Split 3600+ line monolithic `index.css` into organized modules
- Created `src/styles/` directory with base, layout, components, and utils subdirectories
- Each file kept under 210 lines for maintainability
- Preserved cascade order and selector specificity via ordered `@import` statements
- No functional changes to styling
### Fixed
- Mobile button positioning now uses safe area insets for symmetrical layout on notched devices
## [0.5.3] - 2025-10-13
### Changed
- Relay status indicator is now more compact
- Smaller padding and font sizes on desktop
- Auto-collapsed on mobile (icon-only by default, tap to expand)
- Matches size of sidebar toggle buttons (44px touch target)
- Hides when scrolling down, shows when scrolling up (consistent with other mobile controls)
### Fixed
- Invalid bookmarks without IDs no longer appear in bookmark list
- Previously showed as "Now" timestamp with no content
- Bookmarks without valid IDs are now filtered out entirely
- Use bookmark's original timestamp instead of always generating new ones
- Profile icon size when logged out now matches other icon buttons in sidebar header
## [0.5.2] - 2025-10-12
### Added
- Three-dot menu to highlight cards for more compact UI
- Combines "Open on Nostr" and "Delete" actions into dropdown menu
- Uses horizontal ellipsis icon (⋯)
- Click-outside functionality to close menu
### Changed
- Switch Nostr gateway from njump.me/search.dergigi.com to ants.sh
- Centralized gateway URLs in config file
- All profile and event links now use ants.sh
- Automatic detection of identifier type (profile vs event) for proper routing
- Remove loading text from Explore and Me pages (spinner only)
- "Open on Nostr" now links to the highlight event itself instead of the article
### Fixed
- Gateway URL routing for ants.sh requirements (/p/ for profiles, /e/ for events)
- Linting errors in HighlightItem component
## [0.5.1] - 2025-10-12
### Added
- Highlight color customization to UI elements
- Apply user's "my highlights" color to highlight creation buttons
- Apply highlight group colors to highlight count indicators
- Apply "my highlights" color to collapsed highlights panel button
### Fixed
- Highlight count indicator styling to match reading-time element
- Brightness and border styling for highlight count indicator
- User highlight color now applies to both marker and arrow icons
- Highlight group color properly applied to count indicator background
### Removed
- MOBILE_IMPLEMENTATION.md documentation file
## [0.5.0] - 2025-10-12
### Added
@@ -692,7 +799,10 @@ 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.0...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
[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
[0.5.0]: https://github.com/dergigi/boris/compare/v0.4.3...v0.5.0
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8

347
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.4.3",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.4.3",
"version": "0.5.5",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -20,11 +20,14 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.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"
},
"devDependencies": {
@@ -2964,6 +2967,12 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5283,6 +5292,82 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5310,6 +5395,48 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
"integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^6.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5/node_modules/property-information": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
"integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -5323,6 +5450,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5333,6 +5477,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -7429,12 +7583,42 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse-numeric-range": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz",
"integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==",
"license": "ISC"
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7580,6 +7764,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -7763,6 +7956,77 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/refractor": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz",
"integrity": "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^7.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/refractor/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/refractor/node_modules/hast-util-parse-selector": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz",
"integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/hastscript": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz",
"integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^3.0.0",
"property-information": "^6.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/property-information": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
"integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -7842,6 +8106,50 @@
"regjsparser": "bin/parser"
}
},
"node_modules/rehype-parse": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
"integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-html": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-prism-plus": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/rehype-prism-plus/-/rehype-prism-plus-2.0.1.tgz",
"integrity": "sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q==",
"license": "MIT",
"dependencies": {
"hast-util-to-string": "^3.0.0",
"parse-numeric-range": "^1.3.0",
"refractor": "^4.8.0",
"rehype-parse": "^9.0.0",
"unist-util-filter": "^5.0.0",
"unist-util-visit": "^5.0.0"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
@@ -9024,6 +9332,17 @@
"node": ">=8"
}
},
"node_modules/unist-util-filter": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-5.0.1.tgz",
"integrity": "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
}
},
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -9168,6 +9487,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -9273,6 +9606,16 @@
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.5.2",
"version": "0.5.6",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -23,11 +23,14 @@
"applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0",
"nostr-tools": "^2.4.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.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"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
@@ -28,15 +28,19 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
className="blog-post-card"
style={{ textDecoration: 'none', color: 'inherit' }}
>
{post.image && (
<div className="blog-post-card-image">
<div className="blog-post-card-image">
{post.image ? (
<img
src={post.image}
alt={post.title}
loading="lazy"
/>
</div>
)}
) : (
<div className="blog-post-image-placeholder">
<FontAwesomeIcon icon={faNewspaper} />
</div>
)}
</div>
<div className="blog-post-card-content">
<h3 className="blog-post-card-title">{post.title}</h3>
{post.summary && (

View File

@@ -8,6 +8,7 @@ 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'
@@ -44,17 +45,33 @@ export const CardView: React.FC<CardViewProps> = ({
articleSummary,
settings
}) => {
const cachedImage = useImageCache(articleImage, settings)
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
const [ogImage, setOgImage] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
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)
// Fetch OG image if we don't have any other image
React.useEffect(() => {
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [firstUrl, articleImage, instantPreview, ogImage])
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && cachedImage && (
{cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${cachedImage})` }}

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
interface CompactButtonProps {
icon?: IconDefinition
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
title?: string
ariaLabel?: string
disabled?: boolean
spin?: boolean
className?: string
children?: React.ReactNode
}
const CompactButton: React.FC<CompactButtonProps> = ({
icon,
onClick,
title,
ariaLabel,
disabled = false,
spin = false,
className = '',
children
}) => {
return (
<button
className={`compact-button ${className}`.trim()}
onClick={onClick}
title={title}
aria-label={ariaLabel || title}
disabled={disabled}
>
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
{children}
</button>
)
}
export default CompactButton

View File

@@ -1,8 +1,14 @@
import React, { useMemo, useState } from 'react'
import React, { useMemo, useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -15,8 +21,14 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
import {
createEventReaction,
createWebsiteReaction,
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
interface ContentPanelProps {
loading: boolean
@@ -70,7 +82,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -93,6 +109,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection
})
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (articleMenuRef.current && !articleMenuRef.current.contains(event.target as Node)) {
setShowArticleMenu(false)
}
}
if (showArticleMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
@@ -104,40 +136,125 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
// Get article links for menu
const getArticleLinks = () => {
if (!currentArticle) return null
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3)
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: currentArticle.pubkey,
identifier: dTag,
relays: relayHints
})
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
}
}
const articleLinks = getArticleLinks()
const handleMenuToggle = () => {
setShowArticleMenu(!showArticleMenu)
}
const handleOpenPortal = () => {
if (articleLinks) {
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
const handleOpenNative = () => {
if (articleLinks) {
window.location.href = articleLinks.native
}
setShowArticleMenu(false)
}
const handleMarkAsRead = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot mark as read: no account or relay pool')
// Check if article is already marked as read when URL/article changes
useEffect(() => {
const checkReadStatus = async () => {
if (!activeAccount || !relayPool || !selectedUrl) {
setIsMarkedAsRead(false)
return
}
setIsCheckingReadStatus(true)
try {
let hasRead = false
if (isNostrArticle && currentArticle) {
hasRead = await hasMarkedEventAsRead(
currentArticle.id,
activeAccount.pubkey,
relayPool
)
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
}
setIsMarkedAsRead(hasRead)
} catch (error) {
console.error('Failed to check read status:', error)
} finally {
setIsCheckingReadStatus(false)
}
}
checkReadStatus()
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
setIsMarkingAsRead(true)
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
try {
if (isNostrArticle && currentArticle) {
// Kind 7 reaction for nostr-native articles
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
// Kind 17 reaction for external websites
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
// Reset animation after it completes
setTimeout(() => {
setShowCheckAnimation(false)
}, 600)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
} catch (error) {
console.error('Failed to mark as read:', error)
} finally {
setIsMarkingAsRead(false)
}
})()
}
if (!selectedUrl) {
@@ -165,7 +282,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
components={{
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt}
{...props}
/>
)
}}
>
{processedMarkdown || markdown}
</ReactMarkdown>
</div>
@@ -211,17 +340,56 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={articleMenuRef}>
<button
className="article-menu-btn"
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showArticleMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Mark as Read button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className="mark-as-read-btn"
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkingAsRead}
title="Mark as Read"
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
>
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
</span>
</button>
</div>
)}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
@@ -14,6 +14,7 @@ import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -122,7 +123,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}
const getHighlightLink = () => {
const getHighlightLinks = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
@@ -135,10 +136,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
author: highlight.pubkey,
kind: 9802
})
return getNostrUrl(nevent)
return {
portal: getNostrUrl(nevent),
native: `nostr:${nevent}`
}
}
const highlightLink = getHighlightLink()
const highlightLinks = getHighlightLinks()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
@@ -282,9 +287,15 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowMenu(!showMenu)
}
const handleOpenExternal = (e: React.MouseEvent) => {
const handleOpenPortal = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(highlightLink, '_blank', 'noopener,noreferrer')
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
setShowMenu(false)
}
const handleOpenNative = (e: React.MouseEvent) => {
e.stopPropagation()
window.location.href = highlightLinks.native
setShowMenu(false)
}
@@ -303,20 +314,25 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onClick={handleItemClick}
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
>
<div className="highlight-quote-icon">
<FontAwesomeIcon icon={faQuoteLeft} />
{relayIndicator && (
<div
className="highlight-relay-indicator"
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
>
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
<div className="highlight-header">
<CompactButton
className="highlight-timestamp"
title={new Date(highlight.created_at * 1000).toLocaleString()}
onClick={(e) => e.stopPropagation()}
>
{formatDateCompact(highlight.created_at)}
</CompactButton>
</div>
<CompactButton
className="highlight-quote-button"
icon={faQuoteLeft}
title="Quote"
onClick={(e) => e.stopPropagation()}
/>
{/* relay indicator lives in footer for consistent padding/alignment */}
<div className="highlight-content">
<blockquote className="highlight-text">
{highlight.content}
@@ -329,33 +345,47 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
)}
<div className="highlight-meta">
<span className="highlight-author">
{getUserDisplayName()}
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDateCompact(highlight.created_at)}
</span>
<div className="highlight-footer">
<div className="highlight-footer-left">
{relayIndicator && (
<CompactButton
className="highlight-relay-indicator"
icon={relayIndicator.icon}
spin={relayIndicator.spin}
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
disabled={!relayPool || !eventStore}
/>
)}
<span className="highlight-author">
{getUserDisplayName()}
</span>
</div>
<div className="highlight-menu-wrapper" ref={menuRef}>
<button
className="highlight-menu-btn"
<CompactButton
icon={faEllipsisH}
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
/>
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleOpenExternal}
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
<button
className="highlight-menu-item"
onClick={handleOpenNative}
>
<FontAwesomeIcon icon={faMobileAlt} />
<span>Open with Native App</span>
</button>
{canDelete && (
<button
className="highlight-menu-item highlight-menu-item-danger"

View File

@@ -1,38 +1,45 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
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'
interface MeProps {
relayPool: RelayPool
}
type TabType = 'highlights' | 'reading-list' | 'archive'
const Me: React.FC<MeProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<TabType>('highlights')
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
const [viewMode, setViewMode] = useState<ViewMode>('cards')
useEffect(() => {
const loadHighlights = async () => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to view your highlights')
setError('Please log in to view your data')
setLoading(false)
return
}
@@ -41,34 +48,95 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
setLoading(true)
setError(null)
// Fetch highlights created by the user
const userHighlights = await fetchHighlights(
relayPool,
activeAccount.pubkey
)
if (userHighlights.length === 0) {
setError('No highlights yet. Start highlighting content to see them here!')
// 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)
}
// Fetch highlights and read articles
const [userHighlights, userReadArticles] = await Promise.all([
fetchHighlights(relayPool, activeAccount.pubkey),
fetchReadArticlesWithData(relayPool, activeAccount.pubkey)
])
setHighlights(userHighlights)
setReadArticles(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(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
} catch (err) {
console.error('Failed to load highlights:', err)
setError('Failed to load highlights. Please try again.')
console.error('Failed to load data:', err)
setError('Failed to load data. Please try again.')
} finally {
setLoading(false)
}
}
loadHighlights()
loadData()
}, [relayPool, activeAccount])
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setHighlights(prev => prev.filter(h => h.id !== highlightId))
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted
if (activeAccount) {
updateCachedHighlights(activeAccount.pubkey, updated)
}
return updated
})
}
if (loading) {
const getPostUrl = (post: BlogPostPreview) => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
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
}
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
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
if (loading && !hasData) {
return (
<div className="explore-container">
<div className="explore-loading">
@@ -89,26 +157,139 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
)
}
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
return highlights.length === 0 ? (
<div className="explore-error">
<p>No highlights yet. Start highlighting content to see them here!</p>
</div>
) : (
<div className="highlights-list me-highlights-list">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
)
case 'reading-list':
return allIndividualBookmarks.length === 0 ? (
<div className="explore-error">
<p>No bookmarks yet. Bookmark articles to see them here!</p>
</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}
/>
))}
</div>
<div className="view-mode-controls" style={{
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={faList}
onClick={() => setViewMode('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => setViewMode('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => setViewMode('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
)
case 'archive':
return readArticles.length === 0 ? (
<div className="explore-error">
<p>No read articles yet. Mark articles as read to see them here!</p>
</div>
) : (
<div className="explore-grid">
{readArticles.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faUser} />
{getUserDisplayName()}
</h1>
<p className="explore-subtitle">
<FontAwesomeIcon icon={faHighlighter} /> {highlights.length} highlight{highlights.length !== 1 ? 's' : ''}
</p>
{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>
)}
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => setActiveTab('highlights')}
>
<FontAwesomeIcon icon={faHighlighter} />
Highlights ({highlights.length})
</button>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => setActiveTab('reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
Reading List ({allIndividualBookmarks.length})
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => setActiveTab('archive')}
>
<FontAwesomeIcon icon={faBooks} />
Archive ({readArticles.length})
</button>
</div>
</div>
<div className="highlights-list me-highlights-list">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
<div className="me-tab-content">
{renderTabContent()}
</div>
</div>
)

View File

@@ -8,9 +8,13 @@ import { useIsMobile } from '../hooks/useMediaQuery'
interface RelayStatusIndicatorProps {
relayPool: RelayPool | null
showOnMobile?: boolean // Control visibility based on scroll
}
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
relayPool,
showOnMobile = true
}) => {
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
@@ -70,7 +74,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
return (
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
title={
!isMobile ? (
isConnecting

View File

@@ -366,7 +366,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightColor={props.settings.highlightColorMine || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />
<RelayStatusIndicator
relayPool={props.relayPool}
showOnMobile={showMobileButtons}
/>
{props.toastMessage && (
<Toast
message={props.toastMessage}

1
src/icons/books.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

52
src/icons/customIcons.ts Normal file
View File

@@ -0,0 +1,52 @@
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
import booksSvg from './books.svg?raw'
/**
* Custom icon definitions for FontAwesome Pro icons
* or any custom SVG icons that aren't in the free tier
*/
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
const { prefix, iconName, unicode = 'e002' } = options
// Extract viewBox first; fallback to width/height
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
let width = 512
let height = 512
if (viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/)
if (parts.length === 4) {
const w = Number(parts[2])
const h = Number(parts[3])
if (!Number.isNaN(w)) width = Math.round(w)
if (!Number.isNaN(h)) height = Math.round(h)
}
} else {
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
if (widthMatch) width = Math.round(Number(widthMatch[1]))
if (heightMatch) height = Math.round(Number(heightMatch[1]))
}
// Collect all path d attributes
const pathDs: string[] = []
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
let m: RegExpExecArray | null
while ((m = pathRegex.exec(svg)) !== null) {
pathDs.push(m[1])
}
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
return {
prefix,
iconName,
icon: [width, height, [], unicode, pathData]
}
}
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
prefix: 'far',
iconName: 'books'
})

View File

@@ -1,299 +1,20 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
@import './styles/base/variables.css';
@import './styles/base/global.css';
@import './styles/layout/app.css';
@import './styles/layout/sidebar.css';
@import './styles/layout/highlights.css';
@import './styles/components/icon-button.css';
@import './styles/components/profile.css';
@import './styles/components/cards.css';
@import './styles/components/modals.css';
@import './styles/components/toast.css';
@import './styles/components/forms.css';
@import './styles/components/reader.css';
@import './styles/components/settings.css';
@import './styles/components/me.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--reading-font: 'Source Serif 4', serif;
--reading-font-size: 18px;
/* Layout variables */
--sidebar-width: 320px;
--sidebar-collapsed-width: 64px;
--highlights-width: 360px;
--highlights-collapsed-width: 56px;
--main-max-width: 900px;
--main-horizontal-padding: 1rem;
/* Mobile breakpoints */
--mobile-breakpoint: 768px;
--tablet-breakpoint: 1024px;
/* Mobile touch target minimum */
--min-touch-target: 44px;
/* Safe area insets for notched devices */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Use dynamic viewport height if supported */
@supports (height: 100dvh) {
body {
min-height: 100dvh;
}
}
body.mobile-sidebar-open {
overflow: hidden;
position: fixed;
width: 100%;
}
#root {
max-width: none;
margin: 0;
padding: 1rem;
}
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app {
text-align: center;
position: relative;
}
.app header {
margin-bottom: 2rem;
}
.app header h1 {
font-size: 2.5rem;
margin: 0;
color: #646cff;
}
.app header p {
margin: 0.5rem 0 0 0;
color: #888;
}
/* Bookmarks Styles */
.bookmarks-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
text-align: left;
padding: 0;
}
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 1rem;
border-top: 1px solid #333;
background: transparent;
border-radius: 0;
}
.bookmarks-container .bookmarks-list {
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sidebar-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px 12px 0 0;
margin-bottom: 0;
}
.sidebar-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.mobile-hamburger-btn {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-hamburger-btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-hamburger-btn.visible {
opacity: 1;
visibility: visible;
}
.mobile-hamburger-btn:active {
transform: scale(0.95);
}
.mobile-close-btn {
display: none;
}
@media (max-width: 768px) {
.mobile-hamburger-btn {
display: flex;
}
.sidebar-header-bar .toggle-sidebar-btn {
display: none;
}
.mobile-close-btn {
display: flex;
}
}
.view-mode-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.profile-avatar {
width: 33px;
height: 33px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #2a2a2a;
border: 1px solid #444;
flex-shrink: 0;
color: #ddd;
box-sizing: border-box;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar svg {
font-size: 1rem;
}
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 33px;
height: 33px;
flex-shrink: 0;
box-sizing: border-box;
}
.sidebar-header-bar .toggle-sidebar-btn:hover {
background: #2a2a2a;
color: #fff;
}
.sidebar-header-bar .toggle-sidebar-btn:active {
transform: translateY(1px);
}
.bookmarks-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0;
background: transparent;
border: none;
}
.bookmarks-container.collapsed .toggle-sidebar-btn {
background: #2a2a2a;
color: #ddd;
border: none;
padding: 0;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 36px;
flex-shrink: 0;
}
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
background: #333;
color: #fff;
}
.bookmarks-container.collapsed .toggle-sidebar-btn:active {
transform: translateY(1px);
}
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon {
width: auto;
padding: 0 0.5rem;
gap: 0.5rem;
}
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue {
color: #646cff;
filter: drop-shadow(0 0 4px rgba(100, 108, 255, 0.6));
}
.user-info {
margin: 0.5rem 0 0 0;
@@ -360,55 +81,6 @@ body.mobile-sidebar-open {
background: #218838;
}
/* Generic IconButton styling */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #444;
border-radius: 6px;
background: #2a2a2a;
color: #ddd;
cursor: pointer;
min-width: 33px;
min-height: 33px;
padding: 0;
box-sizing: border-box;
}
.icon-button:hover { background: #333; }
.icon-button:active { transform: translateY(1px); }
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
.icon-button.primary:hover { filter: brightness(1.05); }
.icon-button.success { background: #28a745; color: white; border-color: #28a745; }
.icon-button.success:hover { filter: brightness(1.05); }
.icon-button.ghost { background: #2a2a2a; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.icon-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
}
}
/* Disable hover effects on touch devices */
@media (pointer: coarse) {
.icon-button:hover {
background: #2a2a2a;
}
.icon-button.ghost:hover {
background: #2a2a2a;
}
.icon-button:active {
background: #333;
}
}
.bookmark-events {
margin: 1rem 0;
@@ -732,8 +404,8 @@ body.mobile-sidebar-open {
.mobile-highlights-btn {
display: none;
position: fixed;
top: 1rem;
right: 1rem;
top: calc(1rem + env(safe-area-inset-top));
right: calc(1rem + env(safe-area-inset-right));
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
@@ -945,20 +617,69 @@ body.mobile-sidebar-open {
.reader-markdown a:hover { text-decoration: underline; }
.reader-markdown pre,
.reader-markdown code {
background: #111;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.9em;
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
.reader-markdown pre {
padding: 0.75rem;
overflow: auto;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
line-height: 1.5;
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
.reader-markdown code {
padding: 0.1rem 0.3rem;
.reader-markdown pre code {
background: transparent;
border: none;
padding: 0;
font-size: 0.9em;
display: block;
}
/* Prism.js enhancements */
.reader-markdown pre[class*="language-"] {
background: #1e1e1e;
border: 1px solid #333;
}
.reader-markdown code[class*="language-"] {
background: transparent;
text-shadow: none;
}
.reader-html pre {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
.reader-html code {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.9em;
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
.reader-html pre code {
background: transparent;
border: none;
padding: 0;
display: block;
}
/* Mark as Read button */
@@ -968,7 +689,6 @@ body.mobile-sidebar-open {
align-items: center;
padding: 2rem 1rem;
margin-top: 2rem;
border-top: 1px solid #333;
}
.mark-as-read-btn {
@@ -1005,6 +725,39 @@ body.mobile-sidebar-open {
.mark-as-read-btn svg {
font-size: 1.1rem;
transition: transform 0.3s ease;
}
/* Marked as read state */
.mark-as-read-btn.marked {
background: #1a4d1a;
border-color: #2d662d;
color: #90ee90;
}
.mark-as-read-btn.marked:disabled {
opacity: 1;
cursor: default;
}
/* Checkmark animation */
.mark-as-read-btn.animating svg {
animation: checkmarkPop 0.6s ease;
}
@keyframes checkmarkPop {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.3);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 768px) {
@@ -3182,13 +2935,13 @@ body.mobile-sidebar-open {
/* Relay Status Indicator */
.relay-status-indicator {
position: fixed;
bottom: 1.5rem;
left: 1.5rem;
bottom: 1rem;
left: 1rem;
z-index: 999;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(245, 158, 11, 0.95);
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
@@ -3196,25 +2949,27 @@ body.mobile-sidebar-open {
backdrop-filter: blur(10px);
transition: all 0.3s ease;
cursor: default;
font-size: 0.875rem;
}
/* Mobile compact mode - just show icon */
/* Mobile compact mode - just show icon, match sidebar button size */
@media (max-width: 768px) {
.relay-status-indicator.mobile {
padding: 0.5rem;
padding: 0;
width: var(--min-touch-target);
height: var(--min-touch-target);
display: flex;
align-items: center;
justify-content: center;
bottom: 1rem;
left: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
left: calc(1rem + env(safe-area-inset-left));
gap: 0;
}
.relay-status-indicator.mobile.expanded {
width: auto;
padding: 0.75rem 1rem;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.relay-status-indicator.mobile .relay-status-icon {
@@ -3224,6 +2979,18 @@ body.mobile-sidebar-open {
.relay-status-indicator.mobile:active {
transform: scale(0.95);
}
/* Hide/show on scroll */
.relay-status-indicator.mobile.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.relay-status-indicator.mobile.visible {
opacity: 1;
visibility: visible;
}
}
.relay-status-indicator.connecting {
@@ -3250,7 +3017,7 @@ body.mobile-sidebar-open {
}
.relay-status-icon {
font-size: 1.25rem;
font-size: 1rem;
color: #1a1a1a;
display: flex;
align-items: center;
@@ -3260,18 +3027,18 @@ body.mobile-sidebar-open {
.relay-status-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
gap: 0.1rem;
}
.relay-status-title {
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.relay-status-subtitle {
font-size: 0.75rem;
font-size: 0.6875rem;
color: rgba(26, 26, 26, 0.8);
line-height: 1.2;
}

View File

@@ -60,8 +60,27 @@ export const processApplesauceBookmarks = (
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
return allItems.map((bookmark: BookmarkData) => ({
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
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]
return bookmarkArray
.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,
@@ -70,23 +89,8 @@ export const processApplesauceBookmarks = (
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
}))
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray.map((bookmark: BookmarkData) => ({
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
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: Math.floor(Date.now() / 1000)
}))
}
// Types and guards around signer/decryption APIs

View File

@@ -0,0 +1,222 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
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'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadArticle {
id: string
url?: string
eventId?: string
eventAuthor?: string
eventKind?: number
markedAt: number
reactionId: string
}
/**
* Fetches all articles that the user has marked as read
* Returns both nostr-native articles (kind:7) and external URLs (kind:17)
*/
export async function fetchReadArticles(
relayPool: RelayPool,
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())
)
const readArticles: ReadArticle[] = []
// Process kind:7 reactions (nostr-native articles)
for (const event of kind7Events) {
if (event.content === MARK_AS_READ_EMOJI) {
const eTag = event.tags.find((t) => t[0] === 'e')
const pTag = event.tags.find((t) => t[0] === 'p')
const kTag = event.tags.find((t) => t[0] === 'k')
if (eTag && eTag[1]) {
readArticles.push({
id: eTag[1],
eventId: eTag[1],
eventAuthor: pTag?.[1],
eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined,
markedAt: event.created_at,
reactionId: event.id
})
}
}
}
// Process kind:17 reactions (external URLs)
for (const event of kind17Events) {
if (event.content === MARK_AS_READ_EMOJI) {
const rTag = event.tags.find((t) => t[0] === 'r')
if (rTag && rTag[1]) {
readArticles.push({
id: rTag[1],
url: rTag[1],
markedAt: event.created_at,
reactionId: event.id
})
}
}
}
// Sort by markedAt (most recent first) and dedupe
const deduped = new Map<string, ReadArticle>()
readArticles
.sort((a, b) => b.markedAt - a.markedAt)
.forEach((article) => {
if (!deduped.has(article.id)) {
deduped.set(article.id, article)
}
})
return Array.from(deduped.values())
} catch (error) {
console.error('Failed to fetch read articles:', error)
return []
}
}
/**
* Fetches full article data for read nostr-native articles
* and converts them to BlogPostPreview format for rendering
*/
export async function fetchReadArticlesWithData(
relayPool: RelayPool,
userPubkey: string
): Promise<BlogPostPreview[]> {
try {
// First get all read articles
const readArticles = await fetchReadArticles(relayPool, userPubkey)
// Filter to only nostr-native articles (kind 30023)
const nostrArticles = readArticles.filter(
article => article.eventKind === 30023 && article.eventId
)
if (nostrArticles.length === 0) {
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())
)
// Deduplicate article events by ID
const uniqueArticleEvents = new Map<string, NostrEvent>()
articleEvents.forEach(event => {
if (!uniqueArticleEvents.has(event.id)) {
uniqueArticleEvents.set(event.id, event)
}
})
// Convert to BlogPostPreview format
const blogPosts: BlogPostPreview[] = Array.from(uniqueArticleEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled Article',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
// Sort by when they were marked as read (most recent first)
const articlesMap = new Map(nostrArticles.map(a => [a.eventId, a]))
blogPosts.sort((a, b) => {
const markedAtA = articlesMap.get(a.event.id)?.markedAt || 0
const markedAtB = articlesMap.get(b.event.id)?.markedAt || 0
return markedAtB - markedAtA
})
return blogPosts
} catch (error) {
console.error('Failed to fetch read articles with data:', error)
return []
}
}

54
src/services/meCache.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
readArticles: BlogPostPreview[]
timestamp: number
}
const meCache = new Map<string, MeCache>() // key: pubkey
export function getCachedMeData(pubkey: string): MeCache | null {
const entry = meCache.get(pubkey)
if (!entry) return null
return entry
}
export function setCachedMeData(
pubkey: string,
highlights: Highlight[],
bookmarks: Bookmark[],
readArticles: BlogPostPreview[]
): void {
meCache.set(pubkey, {
highlights,
bookmarks,
readArticles,
timestamp: Date.now()
})
}
export function updateCachedHighlights(pubkey: string, highlights: Highlight[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, highlights, timestamp: Date.now() })
}
}
export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, bookmarks, timestamp: Date.now() })
}
}
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
const existing = meCache.get(pubkey)
if (existing) {
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
}
}

View File

@@ -1,11 +1,14 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
const MARK_AS_READ_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
* @param eventId The ID of the event being reacted to
@@ -101,3 +104,96 @@ export async function createWebsiteReaction(
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedEventAsRead(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
try {
const filter = {
kinds: [7],
authors: [userPubkey],
'#e': [eventId]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}
/**
* Checks if the user has already marked a website as read
* @param url The URL to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedWebsiteAsRead(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
try {
// Normalize URL the same way as when creating reactions
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const filter = {
kinds: [17],
authors: [userPubkey],
'#r': [normalizedUrl]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}

View File

@@ -0,0 +1,68 @@
/* Global element styles and app container */
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Use dynamic viewport height if supported */
@supports (height: 100dvh) {
body {
min-height: 100dvh;
}
}
body.mobile-sidebar-open {
overflow: hidden;
position: fixed;
width: 100%;
}
#root {
max-width: none;
margin: 0;
padding: 1rem;
}
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app {
text-align: center;
position: relative;
}
.app header {
margin-bottom: 2rem;
}
.app header h1 {
font-size: 2.5rem;
margin: 0;
color: #646cff;
}
.app header p {
margin: 0.5rem 0 0 0;
color: #888;
}
.loading {
text-align: center;
padding: 2rem;
color: #ccc;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,48 @@
/* CSS variables and color-scheme */
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
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;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--reading-font: 'Source Serif 4', serif;
--reading-font-size: 18px;
/* Layout variables */
--sidebar-width: 320px;
--sidebar-collapsed-width: 64px;
--highlights-width: 360px;
--highlights-collapsed-width: 56px;
--main-max-width: 900px;
--main-horizontal-padding: 1rem;
/* Mobile breakpoints */
--mobile-breakpoint: 768px;
--tablet-breakpoint: 1024px;
/* Mobile touch target minimum */
--min-touch-target: 44px;
/* Safe area insets for notched devices */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}

View File

@@ -0,0 +1,98 @@
/* Bookmark item and blog post cards */
.bookmark-item { background: #1a1a1a; 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: #fff; font-size: 1.2rem; }
.bookmark-url { color: #646cff; 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: #ccc; margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: #888; font-size: 0.9rem; margin-top: 0.5rem; }
.individual-bookmarks { margin: 1rem 0; }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: #fff; }
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
@media (max-width: 768px) {
.bookmarks-grid { gap: 0.75rem; }
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
.bookmarks-grid.bookmarks-large { gap: 1rem; }
}
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid #2a2a2a; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
.individual-bookmark:hover { border-color: #3a3a3a; background: #252525; }
/* Compact view */
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid #2a2a2a; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: #252525; border-bottom-color: #333; transform: none; box-shadow: none; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
.compact-row.clickable { cursor: pointer; }
.compact-row.clickable:active { opacity: 0.8; }
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: #646cff; font-size: 0.85rem; flex-shrink: 0; }
.compact-text { flex: 1; min-width: 0; color: #ccc; font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-date-compact { font-size: 0.7rem; color: #666; flex-shrink: 0; white-space: nowrap; }
.compact-read-btn { background: transparent; color: #888; 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: #ccc; }
.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: #646cff; font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: #888; background: #1a1a1a; padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: #666; }
.bookmark-date-link { font-size: 0.8rem; color: #666; text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: #8ab4f8; text-decoration: underline; }
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: #ccc; line-height: 1.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: #888; cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
.expand-toggle:hover { color: #bbb; }
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
.bookmark-meta-minimal { font-size: 0.8rem; color: #888; }
.author-link-minimal { color: #888; text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: #aaa; }
.read-now-button-minimal { background: #646cff; 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: #535bf2; }
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: #646cff; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: #8088ff; }
/* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid #2a2a2a; }
.large-preview-image { width: 100%; height: 180px; background: #1a1a1a; background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid #333; position: relative; }
.large-preview-image:hover { opacity: 0.9; }
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
.preview-placeholder { font-size: 3rem; color: #444; }
.large-content { padding: 1.25rem; }
.large-text { color: #ccc; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: #888; padding-top: 0.75rem; border-top: 1px solid #333; }
.large-author { flex: 1; }
.large-read-button { background: #646cff; 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: #535bf2; }
/* 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: #646cff; 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-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: #ff6b6b; }
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
.blog-post-card { background: #1a1a1a; border: 1px solid #333; 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: #646cff; transform: translateY(-4px); box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: #0f0f0f; display: flex; align-items: center; justify-content: center; }
.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: #444; display: flex; align-items: center; justify-content: center; }
.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 #333; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); 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) {
.explore-container { padding: 1rem; }
.explore-header h1 { font-size: 2rem; }
.explore-grid { grid-template-columns: 1fr; gap: 1.5rem; }
.blog-post-card-summary { -webkit-line-clamp: 2; font-size: 0.8rem; }
.blog-post-card-content { padding: 1rem; }
}

View File

@@ -0,0 +1,30 @@
/* Forms and controls for settings */
.setting-group { margin-bottom: 1.5rem; text-align: left; }
.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: #ccc; 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 #444; border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
.color-swatch:hover { border-color: #888; }
.color-swatch.active { border-color: #646cff; box-shadow: 0 0 0 2px #646cff; }
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #000; font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px #fff; }
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid #444; border-radius: 6px; color: #ccc; cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.font-size-btn:hover { background: #333; border-color: #666; }
.font-size-btn.active { background: #646cff; border-color: #646cff; color: white; }
.setting-preview { margin: 1.5rem 0; padding: 1rem; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; }
.preview-label { font-size: 0.875rem; color: #999; margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-content { color: #ddd; line-height: 1.7; }
.preview-content h3 { margin: 0 0 1rem 0; font-size: 1.5em; color: #fff; }
.preview-content p { margin: 0.75rem 0; }
.setting-select { width: 100%; padding: 0.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: #646cff; }
.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: #646cff; }
.checkbox-label span { color: #ddd; text-align: left; font-weight: 500; }

View File

@@ -0,0 +1,43 @@
/* Generic IconButton styling */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #444;
border-radius: 6px;
background: #2a2a2a;
color: #ddd;
cursor: pointer;
min-width: 33px;
min-height: 33px;
padding: 0;
box-sizing: border-box;
}
.icon-button:hover { background: #333; }
.icon-button:active { transform: translateY(1px); }
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
.icon-button.primary:hover { filter: brightness(1.05); }
.icon-button.success { background: #646cff; color: white; border-color: #646cff; }
.icon-button.success:hover { filter: brightness(1.1); }
.icon-button.ghost { background: #2a2a2a; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.icon-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
}
}
/* Disable hover effects on touch devices */
@media (pointer: coarse) {
.icon-button:hover { background: #2a2a2a; }
.icon-button.ghost:hover { background: #2a2a2a; }
.icon-button:active { background: #333; }
}

View File

@@ -0,0 +1,126 @@
/* Me page tabs */
.me-tabs {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
overflow-x: auto;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.me-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, #999);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
margin-bottom: -1px;
}
.me-tab:hover {
color: var(--text-primary, #ddd);
background: rgba(255, 255, 255, 0.05);
}
.me-tab.active {
color: var(--primary-color, #8b5cf6);
border-bottom-color: var(--primary-color, #8b5cf6);
}
/* Highlights tab uses the user's custom "my highlights" color */
.me-tab[data-tab="highlights"].active {
color: var(--highlight-color-mine, #ffff00);
border-bottom-color: var(--highlight-color-mine, #ffff00);
}
.me-tab svg {
font-size: 1rem;
}
.me-tab-content {
padding: 1.5rem 0;
max-width: 600px;
margin: 0 auto;
width: 100%;
}
/* 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%; }
/* Bookmarks list */
.bookmarks-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bookmark-item {
padding: 1rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
transition: all 0.2s ease;
}
.bookmark-item:hover {
border-color: var(--primary-color, #8b5cf6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.bookmark-item a {
text-decoration: none;
color: inherit;
}
.bookmark-item h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
color: var(--text-primary, #000);
}
.bookmark-item p {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary, #666);
line-height: 1.5;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
/* Add top breathing room so floating sidebar buttons don't overlap header */
.explore-container .explore-header {
margin-top: 2.25rem;
}
.me-tabs {
gap: 0.25rem;
padding: 0 0.5rem;
margin-top: 0.5rem;
}
.me-tab {
padding: 0.5rem 0.7rem;
font-size: 0.82rem;
}
.me-tab svg {
font-size: 0.9rem;
margin-right: 0.25rem;
}
.me-tab-content {
padding: 1.25rem 0.75rem;
}
}

View File

@@ -0,0 +1,28 @@
/* 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: #1a1a1a; border: 1px solid #333; 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 #333; }
.modal-header h2 { margin: 0; font-size: 1.5rem; color: #fff; }
.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: #ccc; font-size: 0.9rem; font-weight: 500; }
.fetching-indicator { font-size: 0.8rem; color: #999; font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #fff; 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: #646cff; }
.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: #999; line-height: 1.4; }
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid #dc3545; border-radius: 6px; color: #dc3545; 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: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #ccc; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-secondary:hover:not(:disabled) { background: #333; border-color: #646cff; color: white; }
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary { padding: 0.75rem 1.5rem; background: #646cff; border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
.btn-primary:hover:not(:disabled) { background: #535bf2; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }

View File

@@ -0,0 +1,20 @@
/* Profile UI fragments */
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 600px; width: 100%; }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; color: #666; }
.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: #ddd; margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: #999; 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 { padding: 1.5rem 1rem; }
.author-card { padding: 1rem; }
.author-card-avatar { width: 48px; height: 48px; }
.author-card-avatar svg { font-size: 2rem; }
.author-card-name { font-size: 0.95rem; }
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
}

View File

@@ -0,0 +1,85 @@
/* Reader view */
.reader { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 0.75rem; text-align: left; overflow: hidden; contain: layout style; }
.reader.empty { color: #888; }
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: #888; }
.loading-spinner svg { font-size: 1.2rem; }
.reader-header { margin-bottom: 2rem; position: relative; }
.reader-title { margin: 0 0 0.75rem 0; font-family: var(--reading-font); }
.reader-summary { color: #aaa; font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; font-family: var(--reading-font); }
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: rgba(136, 136, 136, 0.7); opacity: 0.85; }
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: #fff; padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(136, 136, 136, 0.1); border: 1px solid rgba(136, 136, 136, 0.3); border-radius: 6px; font-size: 0.875rem; color: #888; }
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(100, 108, 255, 0.1); border: 1px solid rgba(100, 108, 255, 0.3); border-radius: 6px; font-size: 0.875rem; color: #646cff; }
.highlight-indicator svg { font-size: 0.875rem; }
.reader-html { color: #ddd; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: #ddd; line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure content is left-aligned even if source markup uses center */
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
.reader center, .reader [align="center"] { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
.reader-markdown h1, .reader-markdown h2, .reader-markdown h3, .reader-markdown h4 { margin-top: 1.2rem; }
.reader-markdown p { margin: 0.5rem 0; }
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
.reader-markdown a { color: #8ab4f8; text-decoration: none; }
.reader-markdown a:hover { text-decoration: underline; }
.reader-markdown code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre code { background: transparent; border: none; padding: 0; font-size: 0.9em; display: block; }
/* Prism.js enhancements */
.reader-markdown pre[class*="language-"] { background: #1e1e1e; border: 1px solid #333; }
.reader-markdown code[class*="language-"] { background: transparent; text-shadow: none; }
.reader-html pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
/* Article menu */
.article-menu-container { display: flex; justify-content: flex-end; padding: 1.5rem 0 0.5rem; margin-top: 2rem; }
.article-menu-wrapper { position: relative; }
.article-menu-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: #646cff; background: rgba(100, 108, 255, 0.1); }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
/* Mark as Read button */
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 1rem; }
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: #2a2a2a; color: #ddd; border: 1px solid #444; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
.mark-as-read-btn:hover:not(:disabled) { background: #333; border-color: #555; transform: translateY(-1px); }
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mark-as-read-btn svg { font-size: 1.1rem; }
@media (max-width: 768px) {
.mark-as-read-container { padding: 1.5rem 1rem; }
.mark-as-read-btn { width: 100%; max-width: 300px; }
}
/* Hero image in reader/card views */
.article-hero-image { width: 100%; height: 200px; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; transition: all 0.2s ease; border-radius: 8px 8px 0 0; position: relative; }
.article-hero-image:hover { opacity: 0.9; }
.article-hero-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%); pointer-events: none; border-radius: 8px 8px 0 0; }
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; }
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
.reader-header-overlay .reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.reader-header-overlay .publish-date { color: rgba(255, 255, 255, 0.65); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); opacity: 1; }
.reader-header-overlay .publish-date svg { opacity: 0.7; }
.reader-header-overlay .reading-time, .reader-header-overlay .highlight-indicator { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.25); color: #fff; }
.reader-header-overlay .highlight-indicator { background: rgba(100, 108, 255, 0.25); border: 1px solid rgba(100, 108, 255, 0.4); }
.reader-summary-below-image { display: none; }
@media (max-width: 768px) {
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
.reader-summary-below-image .reader-summary { color: #aaa; font-size: 1rem; line-height: 1.6; margin: 0; }
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
}

View File

@@ -0,0 +1,15 @@
/* Settings view containers */
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; }
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding: 0; }
.settings-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; text-align: left; }
.settings-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
.settings-section { margin-bottom: 2.5rem; }
.settings-section:last-child { margin-bottom: 0; }
.section-title { font-size: 1rem; font-weight: 600; color: #fff; margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid #333; text-transform: uppercase; letter-spacing: 0.05em; }
.settings-footer { display: flex; justify-content: flex-start; padding: 1rem 0 0.5rem 0; flex-shrink: 0; }
.settings-footer .btn-primary { background: #646cff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; gap: 0.5rem; }
.settings-footer .btn-primary:hover:not(:disabled) { background: #535bf2; }
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }

View File

@@ -0,0 +1,13 @@
/* Toast Notification */
.toast { position: fixed; top: 2rem; right: 2rem; background: #1a1a1a; color: #fff; padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid #333; display: flex; align-items: center; gap: 0.75rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); animation: toast-slide-in 0.3s ease-out; z-index: 9999; font-size: 0.95rem; }
@media (max-width: 768px) {
.toast { top: auto; bottom: calc(1rem + var(--safe-area-bottom)); right: 1rem; left: 1rem; max-width: calc(100% - 2rem); }
@keyframes toast-slide-in { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
}
.toast-success { border-color: #28a745; }
.toast-success svg { color: #28a745; }
.toast-error { border-color: #dc3545; }
.toast-error svg { color: #dc3545; }
@keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }

145
src/styles/layout/app.css Normal file
View File

@@ -0,0 +1,145 @@
/* App-level layout and panes */
.bookmarks-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 600px;
margin: 0 auto;
}
/* Two-pane layout (legacy support) */
.two-pane {
display: grid;
grid-template-columns: 360px 1fr;
column-gap: 0;
height: calc(100vh - 4rem);
transition: grid-template-columns 0.3s ease;
}
.two-pane.sidebar-collapsed { grid-template-columns: 60px 1fr; }
/* Three-pane layout */
.three-pane {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
column-gap: 0;
height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease;
position: relative;
}
@supports (height: 100dvh) {
.three-pane { height: calc(100dvh - 2rem); }
}
.three-pane.sidebar-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width); }
.three-pane.highlights-collapsed { grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width); }
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); }
/* Mobile three-pane layout */
@media (max-width: 768px) {
.three-pane {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100vh;
height: 100dvh;
}
.three-pane.sidebar-collapsed,
.three-pane.highlights-collapsed,
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: 1fr; }
}
.pane.sidebar { overflow-y: auto; height: 100%; }
.pane.main {
overflow-y: auto;
height: 100%;
max-width: var(--main-max-width);
margin: 0 auto;
padding: 0 var(--main-horizontal-padding);
overflow-x: hidden;
contain: layout style;
}
/* Remove padding when sidebar is collapsed for zero gap */
.three-pane.sidebar-collapsed .pane.main { padding-left: 0; }
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main { padding-left: 0; }
.pane.highlights { overflow-y: auto; height: 100%; }
/* Ensure panes are stacked in the correct order on desktop */
@media (min-width: 769px) {
/* Desktop stacking to keep highlights above main without overlap */
.three-pane .pane.sidebar { z-index: 1; }
.three-pane .pane.main { z-index: 1; }
.three-pane .pane.highlights { z-index: 2; }
}
/* Mobile pane styles */
@media (max-width: 768px) {
/* Both sidepanes slide in as overlays */
.pane.sidebar,
.pane.highlights {
position: fixed;
top: 0;
width: 85%;
max-width: 320px;
height: 100vh;
height: 100dvh;
background: #1a1a1a;
z-index: 1001; /* Above backdrop */
transition: transform 0.3s ease;
box-shadow: none;
display: flex;
flex-direction: column;
}
/* Ensure content fills the mobile sidepanes */
.pane.sidebar > *,
.pane.highlights > * { width: 100%; height: 100%; }
/* Remove borders from containers in mobile overlays */
.pane.sidebar .bookmarks-container,
.pane.highlights .highlights-container { border: none; border-radius: 0; flex: 1; min-height: 0; }
/* Bookmarks sidebar from left */
.pane.sidebar { left: 0; transform: translateX(-100%); }
.pane.sidebar.mobile-open { transform: translateX(0); box-shadow: 4px 0 12px rgba(0, 0, 0, 0.5); }
/* Highlights sidebar from right */
.pane.highlights { right: 0; transform: translateX(100%); }
.pane.highlights.mobile-open { transform: translateX(0); box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5); }
.pane.main { grid-column: 1; grid-row: 1; padding: 0.5rem; max-width: 100%; transition: opacity 0.2s ease; }
/* Hide main content when sidepanes are open on mobile */
.three-pane .pane.main.mobile-hidden { opacity: 0; pointer-events: none; }
.mobile-sidebar-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999; /* Below sidepanes */
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-sidebar-backdrop.visible { display: block; opacity: 1; }
.mobile-highlights-btn {
display: none;
position: fixed;
top: calc(1rem + env(safe-area-inset-top));
right: calc(1rem + env(safe-area-inset-right));
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-highlights-btn.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.mobile-highlights-btn.visible { opacity: 1; visibility: visible; }
@media (max-width: 768px) { .mobile-highlights-btn { display: flex; } }
}

View File

@@ -0,0 +1,175 @@
/* Highlights panel layout and interactions */
.highlights-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.highlights-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0;
background: transparent;
border: none;
}
.highlights-container.collapsed .toggle-highlights-btn {
background: #2a2a2a;
color: #ddd;
border: none;
padding: 0;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.highlights-container.collapsed .toggle-highlights-btn:hover { background: #333; color: #fff; }
.highlights-container.collapsed .toggle-highlights-btn:active { transform: translateY(1px); }
.highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
.highlights-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #333;
background: #1a1a1a;
border-radius: 12px 12px 0 0;
}
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.highlights-title .count { color: #888; font-size: 0.875rem; }
.highlight-mode-toggle { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-mode-toggle .mode-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.highlight-mode-toggle .mode-btn.active { background: #646cff; color: #fff; }
/* Three-level highlight toggles */
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
.refresh-highlights-btn,
.toggle-highlight-display-btn,
.toggle-highlights-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.refresh-highlights-btn:hover,
.toggle-highlight-display-btn:hover,
.toggle-highlights-btn:hover { background: #2a2a2a; color: #fff; }
.refresh-highlights-btn:active,
.toggle-highlight-display-btn:active,
.toggle-highlights-btn:active { transform: translateY(1px); }
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-highlights-btn:disabled:hover { background: transparent; color: #ddd; }
.highlights-loading,
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: #888; text-align: center; gap: 0.5rem; }
.highlights-empty svg { color: #555; margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: #666; margin-top: 0.5rem; }
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlight-item { background: #1e1e1e; border-left: 1px solid #333; border-right: 1px solid #333; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
.highlight-item:hover { border-color: #646cff; }
.highlight-item:hover .highlight-header,
.highlight-item:hover .highlight-footer { border-color: #646cff; }
.highlight-item.selected { border-color: #646cff; background: #252525; box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3); }
.highlight-item.selected .highlight-header,
.highlight-item.selected .highlight-footer { border-color: #646cff; }
/* Compact button for highlight cards */
.compact-button { background: none; border: none; color: #888; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; gap: 0.25rem; transition: all 0.2s ease; border-radius: 4px; min-width: 20px; min-height: 20px; }
.compact-button:hover { color: #aaa; background: rgba(255, 255, 255, 0.05); }
.compact-button:active { transform: scale(0.95); }
.compact-button:disabled { opacity: 0.5; cursor: not-allowed; }
.compact-button:disabled:hover { background: none; color: #888; transform: none; }
.highlight-header { position: absolute; top: 0; left: 0; right: 0; padding: 0.25rem 0.5rem; display: flex; align-items: center; justify-content: flex-end; pointer-events: none; border-top: 1px solid #333; border-top-left-radius: 8px; border-top-right-radius: 8px; transition: border-color 0.2s ease; }
.highlight-header .compact-button { pointer-events: auto; }
.highlight-timestamp { font-size: 0.75rem; font-weight: 500; white-space: nowrap; }
/* Level colors in sidebar items */
.highlight-item.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
.highlight-item.level-mine .highlight-header,
.highlight-item.level-mine .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); }
.highlight-item.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); }
.highlight-item.level-friends .highlight-header,
.highlight-item.level-friends .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); }
.highlight-item.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); }
.highlight-item.level-nostrverse .highlight-header,
.highlight-item.level-nostrverse .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); }
.highlight-quote-button { position: absolute; top: 0.25rem; left: 0.5rem; z-index: 10; }
.highlight-item.level-mine .highlight-quote-button { color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-quote-button { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-quote-button { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-relay-indicator { flex-shrink: 0; }
.highlight-relay-indicator:hover { opacity: 1; }
/* Mobile: Larger touch targets and better spacing */
@media (max-width: 768px) {
.highlight-relay-indicator { padding: 8px; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
.compact-button { padding: 0.5rem; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon { color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1.75rem 0.75rem; }
.highlight-text { margin: 0; padding: 0; font-style: italic; color: #ddd; line-height: 1.6; border-left: none; font-size: 0.95rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(100, 108, 255, 0.1); border-left: 3px solid #646cff; border-radius: 4px; font-size: 0.875rem; color: #ddd; line-height: 1.5; }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: #888; border-bottom: 1px solid #333; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.highlight-author { color: #aaa; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
/* Ensure relay indicator in footer uses normal flow and matches CompactButton spacing */
.highlight-item .highlight-footer .highlight-relay-indicator {
position: static; /* override any absolute rules from global styles */
bottom: auto;
left: auto;
margin: 0; /* rely on footer gap */
padding: 0.25rem; /* CompactButton base */
}
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
.highlight-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.highlight-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: #ff4444; }
.highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }

View File

@@ -0,0 +1,183 @@
/* Bookmarks and sidebar layout */
.bookmarks-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
text-align: left;
padding: 0;
}
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 1rem;
border-top: 1px solid #333;
background: transparent;
border-radius: 0;
}
.bookmarks-container .bookmarks-list {
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sidebar-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px 12px 0 0;
margin-bottom: 0;
}
.sidebar-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.mobile-hamburger-btn {
display: none;
position: fixed;
top: calc(1rem + env(safe-area-inset-top));
left: calc(1rem + env(safe-area-inset-left));
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-hamburger-btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-hamburger-btn.visible {
opacity: 1;
visibility: visible;
}
.mobile-hamburger-btn:active {
transform: scale(0.95);
}
.mobile-close-btn {
display: none;
}
@media (max-width: 768px) {
.mobile-hamburger-btn { display: flex; }
.sidebar-header-bar .toggle-sidebar-btn { display: none; }
.mobile-close-btn { display: flex; }
}
.view-mode-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.profile-avatar {
width: 33px;
height: 33px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #2a2a2a;
border: 1px solid #444;
flex-shrink: 0;
color: #ddd;
box-sizing: border-box;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar svg { font-size: 1.25rem; }
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 33px;
height: 33px;
flex-shrink: 0;
box-sizing: border-box;
}
.sidebar-header-bar .toggle-sidebar-btn:hover { background: #2a2a2a; color: #fff; }
.sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
.bookmarks-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0;
background: transparent;
border: none;
}
.bookmarks-container.collapsed .toggle-sidebar-btn {
background: #2a2a2a;
color: #ddd;
border: none;
padding: 0;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 36px;
flex-shrink: 0;
}
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: #333; color: #fff; }
.bookmarks-container.collapsed .toggle-sidebar-btn:active { transform: translateY(1px); }
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue { color: #646cff; filter: drop-shadow(0 0 4px rgba(100, 108, 255, 0.6)); }
.user-info { margin: 0.5rem 0 0 0; color: #888; font-size: 0.9rem; font-family: monospace; }
.bookmark-count { color: #666; font-size: 0.9rem; margin: 0.5rem 0; }
.event-link { color: #8ab4f8; text-decoration: none; font-weight: 500; }
.event-link:hover { text-decoration: underline; }
.bookmark-urls { margin: 0.75rem 0; }
.bookmark-url { display: block; margin: 0.25rem 0; color: #007bff; text-decoration: none; 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; }
.url-row { display: flex; align-items: center; gap: 0.5rem; }
.read-inline-btn { background: #28a745; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
.read-inline-btn:hover { background: #218838; }

View File

@@ -0,0 +1,24 @@
/* Reusable keyframes */
@keyframes pulse-glow {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes toast-slide-in {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes highlight-pulse-animation {
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
50% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
75% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
}

View File

@@ -0,0 +1,42 @@
/* Inline content highlights - utilities */
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35); padding: 0.125rem 0.25rem; cursor: pointer; transition: all 0.2s ease; position: relative; border-radius: 2px; box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); contain: layout style; }
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5); box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3); }
.content-highlight-underline { background: transparent; padding: 0; cursor: pointer; transition: all 0.2s ease; position: relative; text-decoration: underline; text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8); text-decoration-thickness: 2px; text-underline-offset: 2px; contain: layout style; }
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); text-decoration-thickness: 3px; }
.content-highlight.highlight-pulse, .content-highlight-marker.highlight-pulse, .content-highlight-underline.highlight-pulse { animation: highlight-pulse-animation 1.5s ease-in-out; }
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker, .reader-html .content-highlight-underline, .reader-markdown .content-highlight-underline { color: inherit; }
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker { text-decoration: none; }
/* Three-level highlight colors */
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent); }
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent); }
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent); }
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent); }
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent); }
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent); }
/* Underline styles for three levels */
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent); }
.content-highlight-underline.level-mine:hover { text-decoration-color: var(--highlight-color-mine, #ffff00); }
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent); }
.content-highlight-underline.level-friends:hover { text-decoration-color: var(--highlight-color-friends, #f97316); }
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent); }
.content-highlight-underline.level-nostrverse:hover { text-decoration-color: var(--highlight-color-nostrverse, #9333ea); }
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4); box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15); }
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55); box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25); }
.content-highlight-underline { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9); }
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); }
/* Three-level overrides for light mode */
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent); }
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent); }
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent); }
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent); }
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent); }
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent); }
.highlight-indicator { background: rgba(100, 108, 255, 0.15); border-color: rgba(100, 108, 255, 0.4); }
}

5
src/vite-env.d.ts vendored
View File

@@ -3,3 +3,8 @@
interface ImportMetaEnv {
readonly VITE_DEFAULT_ARTICLE_NADDR: string
}
declare module '*.svg?raw' {
const content: string
export default content
}