Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1e964d3a | ||
|
|
1500744a96 | ||
|
|
394311622d | ||
|
|
c7f3991ddd | ||
|
|
e05efaa4f6 | ||
|
|
c96347a331 | ||
|
|
d721e84e42 | ||
|
|
dcbe4bd23e | ||
|
|
e11184426e | ||
|
|
ebea872c72 | ||
|
|
8e57d3d491 | ||
|
|
ca339ac0b2 | ||
|
|
abb6819c40 | ||
|
|
de314894ff | ||
|
|
2939747ebf | ||
|
|
a4548306e7 | ||
|
|
f16c1720a6 | ||
|
|
5b2ee94062 | ||
|
|
3091ad7fd4 | ||
|
|
5b7488295c | ||
|
|
bea62ddc4b | ||
|
|
44d6b1fb2a | ||
|
|
02ec8dd936 | ||
|
|
765ce0ac5e | ||
|
|
a1f7c3e34a | ||
|
|
2e5eb08b54 | ||
|
|
46a6d4fe0c | ||
|
|
84ea0df550 | ||
|
|
0f58b166ce | ||
|
|
f65d39023c | ||
|
|
0b3c7efbc1 | ||
|
|
ecb462562f | ||
|
|
c5a3d00371 | ||
|
|
d3b7a8ddde | ||
|
|
0eee203a9b | ||
|
|
cd5a95dea3 | ||
|
|
f348ddaf73 | ||
|
|
9f09093c80 | ||
|
|
490c6c9bdc | ||
|
|
4eb0ede76b | ||
|
|
02c1b6b783 | ||
|
|
9eed448da6 | ||
|
|
f8d621bcdc | ||
|
|
5cbe2246d3 | ||
|
|
f29a180cbd | ||
|
|
0ca3771906 | ||
|
|
6dab126f88 | ||
|
|
6c74d04984 | ||
|
|
1e00ff5e35 | ||
|
|
71fa334f61 | ||
|
|
d3ee995221 | ||
|
|
6812584b8c | ||
|
|
47ddf8ebe1 | ||
|
|
36897e7f15 | ||
|
|
f18315be02 | ||
|
|
38d77b02f5 | ||
|
|
5b77a93bba | ||
|
|
e1c11a7450 | ||
|
|
d96ee50f5a | ||
|
|
d4a172ba7e | ||
|
|
52ddb8dd7d | ||
|
|
8c16614752 | ||
|
|
700d7cc5fa | ||
|
|
017703dab2 | ||
|
|
c59fdb14f1 | ||
|
|
0c104f95d9 | ||
|
|
acbefae501 | ||
|
|
2ce83ef88a | ||
|
|
dab3412ecd | ||
|
|
988b3164d2 | ||
|
|
4161053821 | ||
|
|
60054c4865 | ||
|
|
f4e8aa576c | ||
|
|
30a495bcd1 | ||
|
|
6dde0eb220 | ||
|
|
90d8ef3423 | ||
|
|
f626a8ec9b | ||
|
|
a7c7535236 | ||
|
|
5b0f2821d6 | ||
|
|
be045557b8 | ||
|
|
a0c92182f9 | ||
|
|
f33d33556b | ||
|
|
9aff889835 | ||
|
|
420df1fbdd | ||
|
|
2946ede5ac | ||
|
|
6ec28e6a9d | ||
|
|
820daa489e | ||
|
|
b162596013 | ||
|
|
e581237e16 | ||
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff | ||
|
|
d5076ff53e | ||
|
|
41e452be1e | ||
|
|
f267df8f60 | ||
|
|
7426c9d1fc | ||
|
|
93d0c1052b | ||
|
|
6537650757 | ||
|
|
a95f9b522b | ||
|
|
47d1335842 | ||
|
|
168095e133 | ||
|
|
5c7b413a8d | ||
|
|
bca6458e44 | ||
|
|
ebdfa3b5a3 | ||
|
|
17480dddbf | ||
|
|
2a422fbeb9 | ||
|
|
22961ee479 | ||
|
|
18db905974 | ||
|
|
689963c041 | ||
|
|
3f8869fd75 | ||
|
|
129aced1a2 | ||
|
|
69febf4356 | ||
|
|
65051c9c1f | ||
|
|
ba8229d464 | ||
|
|
9251b017d4 | ||
|
|
1ae76031f3 | ||
|
|
994d834a0b | ||
|
|
67a4e17055 | ||
|
|
1e82e3f240 | ||
|
|
f973c75ff5 | ||
|
|
28316a71c5 | ||
|
|
cfc12e2d78 |
@@ -5,4 +5,4 @@ alwaysApply: false
|
||||
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
|
||||
Never write "Loading" - always show a spinner, and just a spinner.
|
||||
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).
|
||||
|
||||
@@ -4,3 +4,5 @@ alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
|
||||
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||
|
||||
212
CHANGELOG.md
@@ -7,6 +7,209 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.9] - 2025-10-14
|
||||
|
||||
### Documentation
|
||||
|
||||
- Minor changelog formatting updates
|
||||
|
||||
## [0.6.8] - 2025-10-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated favicon and app icons to purple theme
|
||||
- Replaced all 8 icon files (apple-touch-icon, favicon variants, and Android Chrome icons)
|
||||
- New purple color scheme for better brand recognition
|
||||
|
||||
## [0.6.7] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Skeleton loading placeholders using `react-loading-skeleton` package
|
||||
- Replaced loading spinners with skeleton loaders across all major components
|
||||
- BookmarkList, Explore, Me, ContentPanel, and HighlightsPanel now use skeleton placeholders
|
||||
- Theme-aware skeleton animations matching app color scheme
|
||||
- Nostr identifier parsing and rendering in highlight comments
|
||||
- Support for `nostr:npub`, `nostr:nprofile`, `nostr:naddr`, `nostr:note`, `nostr:nevent`
|
||||
- Clickable links to profiles and articles from highlight comments
|
||||
- Shortened display format for better readability
|
||||
- Visibility filters for explore page content
|
||||
- Toggle filters for nostrverse, friends, and own content
|
||||
- Icon buttons with color coding matching highlight levels
|
||||
- Filter state persists across tab switches
|
||||
|
||||
### Changed
|
||||
|
||||
- Loading states now use skeleton placeholders instead of spinners for more polished UX
|
||||
- Removed incremental loading spinner from explore page refresh
|
||||
- Pull-to-refresh indicator provides refresh state feedback
|
||||
|
||||
### Fixed
|
||||
|
||||
- Type error in `HighlightItem.tsx` using `React.ReactElement` instead of `JSX.Element`
|
||||
- Me page skeleton loading now handles undefined `viewingPubkey` gracefully
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated FontAwesome rule to prefer skeleton placeholders over loading text or spinners
|
||||
|
||||
## [0.6.6] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Profile fetching and caching for explore page
|
||||
- Automatically fetches kind:0 metadata for all blog post authors
|
||||
- Stores profiles in event store for instant access across app
|
||||
- Rebroadcasts profiles to local/all relays per user settings
|
||||
- Fixes "Unknown" author names by proactively caching profiles
|
||||
- Rich content rendering in highlight comments
|
||||
- URLs automatically detected and rendered as clickable links
|
||||
- Image URLs (jpg, png, gif, webp, etc.) render as inline images
|
||||
- Images lazy-load with responsive sizing and rounded borders
|
||||
- Links open in new tab with security attributes
|
||||
|
||||
### Changed
|
||||
|
||||
- Hide citation in highlights sidebar when viewing article
|
||||
- Citation removed from sidebar since all highlights are from same source
|
||||
- Citation still shown in Explore and Me pages where context is needed
|
||||
- Reduces visual clutter and redundant information
|
||||
|
||||
### Fixed
|
||||
|
||||
- Blog posts with far-future publication dates no longer appear in explore
|
||||
- Filter excludes posts with dates more than 1 day in future
|
||||
- Allows 1 day tolerance for clock skew between systems
|
||||
- Prevents spam posts with unrealistic dates (e.g., "53585 years from now")
|
||||
- Layout breaks from long URLs in highlight comments
|
||||
- Added word-wrap, overflow-wrap, and word-break CSS properties
|
||||
- Set min-width: 0 to allow flex child to shrink properly
|
||||
- Long URLs now wrap correctly instead of causing horizontal overflow
|
||||
- Profile fetching implementation
|
||||
- Use eventStore.add() directly for immediate profile storage
|
||||
- Use tap() operator to process events as they arrive
|
||||
- Correct TypeScript types and dependency array
|
||||
|
||||
## [0.6.5] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Highlights tab on `/explore` page
|
||||
- View highlights from friends and followed users
|
||||
- Tab structure matching `/me` and profile pages
|
||||
- Grid layout for highlights with cards
|
||||
- Highlights shown first, writings second
|
||||
- Clicking highlight opens source article and scrolls to position
|
||||
- Opens highlights sidebar automatically when clicking from explore
|
||||
- Citation attribution on highlight items
|
||||
- Shows "— Author, Article Title" for Nostr-native content
|
||||
- Shows "— domain.com" for web URLs
|
||||
- Resolves author profiles and article titles automatically
|
||||
- Comment icon (fa-comments) for highlights with comments
|
||||
- Flipped horizontally for better visual alignment
|
||||
- Colored based on highlight level (mine/friends/nostrverse)
|
||||
- No background or extra indent for cleaner look
|
||||
- Click timestamp to open highlight in native Nostr app
|
||||
- Uses nostr:nevent links for native app integration
|
||||
|
||||
### Changed
|
||||
|
||||
- Highlight counter text color now matches article text (var(--color-text))
|
||||
- Better readability in both light and dark modes
|
||||
- Only forces white in overlay context (hero images)
|
||||
- Highlight level colors applied to explore page highlights
|
||||
- Yellow for own highlights
|
||||
- Orange for friends' highlights
|
||||
- Purple for nostrverse highlights
|
||||
- Explore page tab order: Highlights first, Writings second
|
||||
- Explore page tabs now extend full width to match content grid
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight counter readability in light mode
|
||||
- Theme-aware text color instead of hardcoded blue
|
||||
- Consistent with reading time indicator styling
|
||||
- Scroll-to-highlight reliability in article view
|
||||
- Added retry mechanism for asynchronous content loading
|
||||
- Attempts to find highlight element up to 20 times over 2 seconds
|
||||
- Author attribution in highlight citations
|
||||
- Correctly extracts author pubkey from highlight's p tag
|
||||
- No more "Unknown" author names
|
||||
- Explore page grid layout
|
||||
- Removed max-width constraint blocking full-width display
|
||||
- Tabs and content now properly aligned
|
||||
|
||||
### Style
|
||||
|
||||
- Replaced server icon with highlighter icon in highlight items
|
||||
- Switch from solid comment icon to outlined comments icon (fa-regular)
|
||||
- Removed background from highlight comments for cleaner appearance
|
||||
- Removed extra left margin from comments (icon provides sufficient indent)
|
||||
- Comment icon colored by highlight level with no opacity
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Added @fortawesome/free-regular-svg-icons package for outlined icons
|
||||
|
||||
## [0.6.4] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Color theme variants for light and dark modes
|
||||
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
|
||||
- Color swatches shown in theme selector instead of text labels
|
||||
- CSS variable tokens and theme classes for consistent theming
|
||||
- Playful empty state message for other users' profiles
|
||||
- Profile links now open within app instead of external portals
|
||||
|
||||
### Changed
|
||||
|
||||
- Default light theme changed to sepia for better readability
|
||||
- Theme setting labels renamed from 'Colors' to 'Theme'
|
||||
- Highlight text now aligns properly with footer icons
|
||||
- Increased spacing between highlight cards for better visual separation
|
||||
- Increased bottom padding in highlight cards
|
||||
- Simplified Me page tab labels for cleaner UI
|
||||
- Highlight marker style applied to active Highlights tab
|
||||
- All profile links open internally instead of via external Nostr portals
|
||||
- Match highlight comment color to highlight level color
|
||||
|
||||
### Fixed
|
||||
|
||||
- Consistent yellow-300 highlight color across all themes
|
||||
- Highlight contrast improved in light themes
|
||||
- Text contrast improved in dark color themes
|
||||
- Darker background for app body in dark themes
|
||||
- Reading progress indicator now uses theme colors
|
||||
- Highlights tab readability improved in light mode with proper background
|
||||
- Empty state text color changed from red to gray for better aesthetics
|
||||
- Replaced 'any' types with proper type definitions for better type safety
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrated entire codebase to semantic token system
|
||||
- Pull-to-refresh components updated to use semantic tokens
|
||||
- Cards, forms, and layout components migrated to semantic tokens
|
||||
- All remaining components converted to semantic token usage
|
||||
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
|
||||
- Theme colors applied to body element for consistent theming
|
||||
|
||||
## [0.6.3] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Ants link to empty writings state for other users
|
||||
|
||||
### Changed
|
||||
|
||||
- Empty state text color from red to gray
|
||||
|
||||
### Fixed
|
||||
|
||||
- Match highlight comment color to highlight level color
|
||||
- Open all profile links within app instead of external portals
|
||||
- Playful empty state message for other users' profiles
|
||||
|
||||
## [0.6.2] - 2025-01-27
|
||||
|
||||
### Added
|
||||
@@ -1096,7 +1299,14 @@ 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.6.2...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.9...HEAD
|
||||
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
|
||||
[0.6.7]: https://github.com/dergigi/boris/compare/v0.6.6...v0.6.7
|
||||
[0.6.6]: https://github.com/dergigi/boris/compare/v0.6.5...v0.6.6
|
||||
[0.6.5]: https://github.com/dergigi/boris/compare/v0.6.4...v0.6.5
|
||||
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
|
||||
|
||||
89
FEATURES.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Boris Features
|
||||
## Overview
|
||||
|
||||
- **Purpose**: A calm, fast, Nostr‑first reader that turns your bookmarks into a focused reading app.
|
||||
- **Layout**: Three‑pane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
|
||||
- **Content**: Renders both Nostr long‑form posts (kind:30023) and regular web URLs.
|
||||
- **Social layer**: Highlights shown by level — mine, friends, nostrverse — each with its own color and visibility toggle.
|
||||
|
||||
## Reader Experience
|
||||
|
||||
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||
- **Progress**: Reading progress indicator with completion state.
|
||||
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||
|
||||
## Highlights (NIP‑84)
|
||||
|
||||
- **Levels**: Mine, friends, nostrverse; toggle per level; colors configurable in settings.
|
||||
- **Interactions**: Click a highlight to scroll to its position; count indicator in the header.
|
||||
- **Creation**: Select text and use the floating highlighter button to publish a highlight.
|
||||
- **Attribution**: Automatically tags article authors for Nostr posts so they can see highlights.
|
||||
|
||||
## Zap Splits (NIP‑57)
|
||||
|
||||
- **Configurable splits**: Weight‑based sliders for highlighter, author(s), and Boris (defaults 50/50/2.1).
|
||||
- **Presets**: Quick buttons for common split configurations.
|
||||
- **Respect source**: If the source article has zap tags, author weights are proportionally preserved.
|
||||
|
||||
## Bookmarks & Reading List (NIP‑51 + Web)
|
||||
|
||||
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
|
||||
- **Web bookmarks**: Supports NIP‑B0 (kind:39701) for standalone URL bookmarks.
|
||||
- **Add Bookmark**: Modal with auto title/description extraction and keywords/tags suggestion (adds “boris” when helpful).
|
||||
- **Views**: Reading list in compact, cards, or large preview modes; quick toggles to switch.
|
||||
- **Archive**: “Read” items appear in your archive; can mark articles/web pages as read.
|
||||
|
||||
## Explore & Profiles
|
||||
|
||||
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||
|
||||
## Support
|
||||
|
||||
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
|
||||
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
|
||||
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
|
||||
- **Stats**: Total supporter count and zap count displayed at the bottom.
|
||||
|
||||
## Video
|
||||
|
||||
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.
|
||||
- **Metadata**: Fetches YouTube title/description/transcript when available.
|
||||
- **Deep links**: Open in native apps via platform‑specific URL schemes.
|
||||
|
||||
## Settings (NIP‑78 Application Data)
|
||||
|
||||
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paper‑white/sepia/ivory).
|
||||
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), per‑level colors.
|
||||
- **Layout & startup**: Default view modes, auto‑collapse preferences, show/hide highlights.
|
||||
- **Zap Splits**: Weight sliders and presets for NIP‑57 splits.
|
||||
- **Offline/Flight Mode**: Local image cache with size limit and clear controls; “use local relay as cache”; rebroadcast preferences.
|
||||
- **Relays**: Relay overview and status in Settings; educational links.
|
||||
- **PWA**: Install prompt when available.
|
||||
|
||||
## Offline, PWA, and Sync
|
||||
|
||||
- **PWA**: Installable; service worker registered; periodic update checks with in‑app toast.
|
||||
- **Flight Mode**: Operates with local relays only; highlights created offline are stored locally and synced later.
|
||||
- **Relay indicator**: Floating status indicator shows Connecting/Offline/Flight Mode and connected counts.
|
||||
|
||||
## Relays & Accounts
|
||||
|
||||
- **Applesauce stack**: Accounts, event store, relay pool, and blueprints power Nostr interactions.
|
||||
- **Multi‑relay**: Grouped connections with keep‑alive subscription; local+remote partitioning for fast queries.
|
||||
- **Persistence**: Accounts restored from local storage; settings saved to NIP‑78 and watched for updates.
|
||||
|
||||
## Privacy
|
||||
|
||||
- **Identity**: No email or new account; uses your existing Nostr signer/identity.
|
||||
- **Data**: Bookmarks and highlights live on Nostr; reading/rendering happens locally in your browser.
|
||||
|
||||
## Conveniences
|
||||
|
||||
- **Share/copy**: One‑click copy or share for articles and videos.
|
||||
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for long‑form articles.
|
||||
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.
|
||||
|
||||
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Gigi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
document.documentElement.className = 'theme-system';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
39
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
@@ -25,13 +26,15 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
@@ -2263,6 +2266,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
|
||||
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
|
||||
@@ -9807,6 +9822,15 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-loading-skeleton": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
|
||||
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -11672,6 +11696,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-pull-to-refresh": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz",
|
||||
"integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.10",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
@@ -28,13 +29,15 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"use-pull-to-refresh": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
1
public/thank-you.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
63
src/App.tsx
@@ -13,6 +13,7 @@ import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -61,6 +62,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore"
|
||||
element={
|
||||
@@ -70,6 +80,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
@@ -196,8 +215,7 @@ function App() {
|
||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||
|
||||
// Store subscription for cleanup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(pool as any)._keepAliveSubscription = keepAliveSub
|
||||
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
@@ -216,10 +234,9 @@ function App() {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((pool as any)._keepAliveSubscription) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pool as any)._keepAliveSubscription.unsubscribe()
|
||||
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||
if (poolWithSub._keepAliveSubscription) {
|
||||
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,22 +279,24 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
<SkeletonThemeProvider>
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
</SkeletonThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,8 +140,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
}, [url, title, description, tagsInput])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -11,17 +11,15 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||
import { CompactView } from './BookmarkViews/CompactView'
|
||||
import { LargeView } from './BookmarkViews/LargeView'
|
||||
import { CardView } from './BookmarkViews/CardView'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -115,8 +113,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -9,9 +9,9 @@ import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -28,7 +28,6 @@ interface BookmarkListProps {
|
||||
lastFetchTime?: number | null
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
@@ -47,20 +46,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
@@ -128,8 +127,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
@@ -141,13 +144,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
) : (
|
||||
<div
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
className="bookmarks-list"
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
<RefreshIndicator
|
||||
isRefreshing={isPulling || isRefreshing || false}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
@@ -157,7 +158,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
@@ -9,8 +10,7 @@ import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -25,7 +25,6 @@ interface CardViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -40,8 +39,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
@@ -58,7 +56,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
|
||||
// Fetch OG image if we don't have any other image
|
||||
React.useEffect(() => {
|
||||
@@ -190,16 +188,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<a
|
||||
href={getProfileUrl(authorNpub)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
title="Open author in search"
|
||||
title="Open author profile"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -15,7 +14,6 @@ interface CompactViewProps {
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -25,15 +23,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined, settings)
|
||||
const cachedImage = useImageCache(articleImage || undefined)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -21,7 +21,6 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -36,10 +35,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary,
|
||||
settings
|
||||
articleSummary
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
@@ -93,15 +91,13 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="large-author">
|
||||
<a
|
||||
href={getProfileUrl(authorNpub)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
className="author-link-minimal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{eventNevent && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import Support from './Support'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
@@ -31,14 +32,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
// Check for highlight navigation state
|
||||
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
const showSupport = location.pathname === '/support'
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
@@ -122,8 +130,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
|
||||
|
||||
// Handle highlight navigation from explore page
|
||||
useEffect(() => {
|
||||
if (navigationState?.highlightId && navigationState?.openHighlights) {
|
||||
// Open the highlights sidebar
|
||||
setIsHighlightsCollapsed(false)
|
||||
// Select the highlight (scroll happens automatically in useHighlightInteractions)
|
||||
setSelectedHighlightId(navigationState.highlightId)
|
||||
|
||||
// Clear the state after handling to avoid re-triggering
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
}
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
@@ -231,6 +251,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
showSupport={showSupport}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -286,7 +307,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
@@ -294,6 +315,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
|
||||
@@ -7,6 +7,7 @@ import rehypePrism from 'rehype-prism-plus'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ContentSkeleton } from './Skeletons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
@@ -406,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="reader loading">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
<div className="reader" aria-busy="true">
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,81 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
settings?: UserSettings
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
type TabType = 'writings' | 'highlights'
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exploreContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Visibility filters (defaults from settings, or friends only)
|
||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||
})
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// show spinner but keep existing posts
|
||||
// show spinner but keep existing data
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const cached = getCachedPosts(activeAccount.pubkey)
|
||||
if (cached && cached.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cached)
|
||||
// Use functional update to check current state without creating dependency
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0) {
|
||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -47,15 +83,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// When local contacts are available, kick off early posts fetch
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(partial),
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
// merge into UI and cache as we stream
|
||||
setBlogPosts((prev) => {
|
||||
const exists = prev.some(p => p.event.id === post.event.id)
|
||||
if (exists) return prev
|
||||
@@ -69,7 +109,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
// Ensure union of streamed + final is displayed
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of all) byId.set(post.event.id, post)
|
||||
@@ -82,52 +121,106 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return merged
|
||||
})
|
||||
})
|
||||
|
||||
// Fetch highlights
|
||||
fetchHighlightsFromAuthors(
|
||||
relayPool,
|
||||
partialArray,
|
||||
(highlight) => {
|
||||
setHighlights((prev) => {
|
||||
const exists = prev.some(h => h.id === highlight.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||
}
|
||||
).then((all) => {
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Always proceed to load nostrverse content even if no contacts
|
||||
// (removed blocking error for empty contacts)
|
||||
|
||||
// After full contacts, do a final pass for completeness
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// Fetch both friends content and nostrverse content in parallel
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
|
||||
const contactsArray = Array.from(contacts)
|
||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||
fetchNostrverseHighlights(relayPool, 100)
|
||||
])
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
// Merge and deduplicate all posts
|
||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||
const postsByKey = new Map<string, BlogPostPreview>()
|
||||
for (const post of allPosts) {
|
||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||
const existing = postsByKey.get(key)
|
||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||
postsByKey.set(key, post)
|
||||
}
|
||||
}
|
||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// Merge and deduplicate all highlights
|
||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||
const highlightsByKey = new Map<string, Highlight>()
|
||||
for (const highlight of allHighlights) {
|
||||
highlightsByKey.set(highlight.id, highlight)
|
||||
}
|
||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Fetch profiles for all blog post authors to cache them
|
||||
if (uniquePosts.length > 0) {
|
||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
||||
console.error('Failed to fetch author profiles:', err)
|
||||
})
|
||||
}
|
||||
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of posts) byId.set(post.event.id, post)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
// No blocking errors - let empty states handle messaging
|
||||
setBlogPosts(uniquePosts)
|
||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||
|
||||
setHighlights(uniqueHighlights)
|
||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
console.error('Failed to load data:', err)
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !activeAccount
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
@@ -144,56 +237,213 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
return classified.filter(h => {
|
||||
if (h.level === 'mine' && !visibility.mine) return false
|
||||
if (h.level === 'friends' && !visibility.friends) return false
|
||||
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
|
||||
return true
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||
|
||||
// Filter blog posts by future dates and visibility
|
||||
const filteredBlogPosts = useMemo(() => {
|
||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||
return blogPosts.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const isNostrverse = !isMine && !isFriend
|
||||
|
||||
if (isMine && !visibility.mine) return false
|
||||
if (isFriend && !visibility.friends) return false
|
||||
if (isNostrverse && !visibility.nostrverse) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'writings':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No blog posts yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredBlogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No highlights yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={exploreContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
<div className="explore-container">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
|
||||
{/* Visibility filters */}
|
||||
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<IconButton
|
||||
icon={faArrowsRotate}
|
||||
onClick={() => setRefreshTrigger(prev => prev + 1)}
|
||||
title="Refresh content"
|
||||
ariaLabel="Refresh content"
|
||||
variant="ghost"
|
||||
spin={loading || isRefreshing}
|
||||
disabled={loading || isRefreshing}
|
||||
/>
|
||||
))}
|
||||
{!loading && blogPosts.length === 0 && (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No blog posts found yet.</p>
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||
title="Toggle nostrverse content"
|
||||
ariaLabel="Toggle nostrverse content"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: visibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||
ariaLabel="Toggle friends content"
|
||||
variant="ghost"
|
||||
disabled={!activeAccount}
|
||||
style={{
|
||||
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: visibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||
ariaLabel="Toggle my content"
|
||||
variant="ghost"
|
||||
disabled={!activeAccount}
|
||||
style={{
|
||||
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: visibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/explore')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/explore/writings')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/HighlightCitation.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface HighlightCitationProps {
|
||||
highlight: Highlight
|
||||
relayPool?: RelayPool | null
|
||||
}
|
||||
|
||||
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||
highlight,
|
||||
relayPool
|
||||
}) => {
|
||||
const [articleTitle, setArticleTitle] = useState<string>()
|
||||
|
||||
// Extract author pubkey from p tag directly
|
||||
const authorPubkey = useMemo(() => {
|
||||
// First try the extracted author from highlight.author
|
||||
if (highlight.author) {
|
||||
return highlight.author
|
||||
}
|
||||
|
||||
// Fallback: extract directly from p tag
|
||||
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||
if (pTag && pTag[1]) {
|
||||
console.log('📝 Found author from p tag:', pTag[1])
|
||||
return pTag[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [highlight.author, highlight.tags])
|
||||
|
||||
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight.eventReference || !relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadTitle = async () => {
|
||||
try {
|
||||
if (!highlight.eventReference) return
|
||||
|
||||
// Convert eventReference to naddr if needed
|
||||
let naddr: string
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
} else {
|
||||
naddr = highlight.eventReference
|
||||
}
|
||||
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
if (title) {
|
||||
setArticleTitle(title)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load article title:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadTitle()
|
||||
}, [highlight.eventReference, relayPool])
|
||||
|
||||
const authorName = authorProfile?.name || authorProfile?.display_name
|
||||
|
||||
// For nostr-native content with article reference
|
||||
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For web URLs
|
||||
if (highlight.urlReference) {
|
||||
try {
|
||||
const url = new URL(highlight.urlReference)
|
||||
return (
|
||||
<div className="highlight-citation">
|
||||
— {url.hostname}
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
@@ -15,6 +16,159 @@ import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname.toLowerCase()
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to render a nostr identifier
|
||||
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
||||
try {
|
||||
// Remove nostr: prefix
|
||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||
const decoded = nip19.decode(identifier)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/p/${nip19.npubEncode(pubkey)}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{pubkey.slice(0, 8)}...
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'nprofile': {
|
||||
const { pubkey } = decoded.data
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/p/${npub}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{pubkey.slice(0, 8)}...
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data
|
||||
// Check if it's a blog post (kind:30023)
|
||||
if (kind === 30023) {
|
||||
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/a/${naddr}`}
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{identifier || 'Article'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
// For other kinds, show shortened identifier
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
nostr:{identifier.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'note': {
|
||||
const eventId = decoded.data
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
note:{eventId.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'nevent': {
|
||||
const { id } = decoded.data
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
event:{id.slice(0, 12)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
default:
|
||||
// Fallback for unrecognized types
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
{identifier.slice(0, 20)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, show shortened identifier
|
||||
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||
return (
|
||||
<span key={index} className="highlight-comment-nostr-id">
|
||||
{identifier.slice(0, 20)}...
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Component to render comment with links, inline images, and nostr identifiers
|
||||
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||
// Pattern to match both http(s) URLs and nostr: URIs
|
||||
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
|
||||
const parts = text.split(urlPattern)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// Handle nostr: URIs
|
||||
if (part.startsWith('nostr:')) {
|
||||
return renderNostrId(part, index)
|
||||
}
|
||||
|
||||
// Handle http(s) URLs
|
||||
if (part.match(/^https?:\/\//)) {
|
||||
if (isImageUrl(part)) {
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={part}
|
||||
alt="Comment attachment"
|
||||
className="highlight-comment-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="highlight-comment-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -29,6 +183,7 @@ interface HighlightItemProps {
|
||||
eventStore?: IEventStore | null
|
||||
onHighlightUpdate?: (highlight: Highlight) => void
|
||||
onHighlightDelete?: (highlightId: string) => void
|
||||
showCitation?: boolean
|
||||
}
|
||||
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
@@ -39,7 +194,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
onHighlightUpdate,
|
||||
onHighlightDelete
|
||||
onHighlightDelete,
|
||||
showCitation = true
|
||||
}) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -208,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
// Always show relay list, use plane icon for local-only
|
||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||
|
||||
// Show server icon with relay info if available
|
||||
// Show highlighter icon with relay info if available
|
||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||
const relayNames = highlight.publishedRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: isLocalOrOffline ? faPlane : faServer,
|
||||
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -225,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -236,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -318,7 +474,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
<CompactButton
|
||||
className="highlight-timestamp"
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</CompactButton>
|
||||
@@ -338,9 +497,19 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
{highlight.content}
|
||||
</blockquote>
|
||||
|
||||
{showCitation && (
|
||||
<HighlightCitation
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
/>
|
||||
)}
|
||||
|
||||
{highlight.comment && (
|
||||
<div className="highlight-comment">
|
||||
{highlight.comment}
|
||||
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||
<div className="highlight-comment-text">
|
||||
<CommentContent text={highlight.comment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightSkeleton } from './Skeletons'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -59,7 +60,6 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
const highlightsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
@@ -68,14 +68,15 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
@@ -127,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
/>
|
||||
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-loading">
|
||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||
<div className="highlights-list" aria-busy="true">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-empty">
|
||||
@@ -141,15 +144,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={highlightsListRef}
|
||||
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading}
|
||||
<div className="highlights-list">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
@@ -162,6 +160,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
eventStore={eventStore}
|
||||
onHighlightUpdate={handleHighlightUpdate}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
showCitation={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -21,8 +22,8 @@ import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -45,9 +46,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const meContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
@@ -60,14 +59,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
@@ -113,7 +110,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
// No blocking error - user can pull-to-refresh
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -123,11 +120,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
maximumPullLength: 240,
|
||||
refreshThreshold: 80,
|
||||
isDisabled: !viewingPubkey
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
@@ -192,36 +191,29 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
.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
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const showSkeletons = loading && !hasData
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<HighlightSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No highlights yet. Start highlighting content to see them here!</p>
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Pull to refresh!'
|
||||
: 'No highlights yet. Pull to refresh!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
@@ -237,9 +229,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No bookmarks yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
@@ -288,9 +291,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No read articles yet. Pull to refresh!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -305,9 +317,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
if (showSkeletons) {
|
||||
return (
|
||||
<div className="explore-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<BlogPostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return writings.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No articles written yet. Publish your first article to see it here!</p>
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No articles written yet. Pull to refresh!'
|
||||
: 'No articles written yet. Pull to refresh!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -327,15 +352,10 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={meContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
<div className="explore-container">
|
||||
<RefreshIndicator
|
||||
isRefreshing={isRefreshing}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
@@ -354,7 +374,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
<span className="tab-count">({highlights.length})</span>
|
||||
</button>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
@@ -364,8 +383,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Reading List</span>
|
||||
<span className="tab-count">({allIndividualBookmarks.length})</span>
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
@@ -374,7 +392,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
<span className="tab-count">({readArticles.length})</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -385,7 +402,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
<span className="tab-count">({writings.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PullToRefreshIndicatorProps {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
isRefreshing: boolean
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
|
||||
isPulling,
|
||||
pullDistance,
|
||||
canRefresh,
|
||||
threshold = 80
|
||||
}) => {
|
||||
// Only show when actively pulling, not when refreshing
|
||||
if (!isPulling) return null
|
||||
|
||||
const opacity = Math.min(pullDistance / threshold, 1)
|
||||
const rotation = (pullDistance / threshold) * 180
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pull-to-refresh-indicator"
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${-20 + pullDistance / 2}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pull-to-refresh-icon"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDown}
|
||||
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="pull-to-refresh-text">
|
||||
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PullToRefreshIndicator
|
||||
|
||||
@@ -33,12 +33,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const cachedImage = useImageCache(image)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
// Determine the dominant highlight color based on visibility and priority
|
||||
const highlightIndicatorStyles = useMemo(() => {
|
||||
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
|
||||
if (!highlights.length) return undefined
|
||||
|
||||
// Count highlights by level that are visible
|
||||
@@ -65,7 +65,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
return {
|
||||
backgroundColor: `rgba(${rgb}, 0.1)`,
|
||||
borderColor: `rgba(${rgb}, 0.3)`,
|
||||
color: '#fff'
|
||||
// Only force white color in overlay context, otherwise let CSS handle it
|
||||
...(isOverlay && { color: '#fff' })
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
@@ -93,7 +94,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
style={getHighlightIndicatorStyles(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
@@ -133,7 +134,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
style={getHighlightIndicatorStyles(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
|
||||
@@ -29,28 +29,39 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] bg-[rgba(26,26,26,0.85)] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
|
||||
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
|
||||
style={{
|
||||
'--left-offset': leftOffset,
|
||||
'--right-offset': rightOffset
|
||||
'--right-offset': rightOffset,
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
opacity: 0.95
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex-1 h-0.5 bg-white/10 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: 'bg-indigo-500'
|
||||
: ''
|
||||
}`}
|
||||
style={{ width: `${clampedProgress}%` }}
|
||||
style={{
|
||||
width: `${clampedProgress}%`,
|
||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<div className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : 'text-gray-500'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : ''
|
||||
}`}
|
||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
||||
>
|
||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
63
src/components/RefreshIndicator.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface RefreshIndicatorProps {
|
||||
isRefreshing: boolean
|
||||
pullPosition: number
|
||||
}
|
||||
|
||||
const THRESHOLD = 80
|
||||
|
||||
/**
|
||||
* Simple pull-to-refresh visual indicator
|
||||
*/
|
||||
const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
|
||||
isRefreshing,
|
||||
pullPosition
|
||||
}) => {
|
||||
const isVisible = isRefreshing || pullPosition > 0
|
||||
if (!isVisible) return null
|
||||
|
||||
const opacity = Math.min(pullPosition / THRESHOLD, 1)
|
||||
const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${translateY}px`,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 30,
|
||||
opacity,
|
||||
transition: isRefreshing ? 'opacity 0.2s' : 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--surface-secondary, #ffffff)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRotateRight}
|
||||
style={{
|
||||
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
|
||||
color: 'var(--accent-color, #3b82f6)'
|
||||
}}
|
||||
className={isRefreshing ? 'fa-spin' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RefreshIndicator
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, Helpers } from 'applesauce-core'
|
||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||
import { getProfileUrl } from '../config/nostrGateways'
|
||||
|
||||
const { getPubkeyFromDecodeResult } = Helpers
|
||||
|
||||
@@ -25,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
||||
|
||||
if (npub) {
|
||||
return (
|
||||
<a
|
||||
href={getProfileUrl(npub)}
|
||||
<Link
|
||||
to={`/p/${npub}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@{display}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
@@ -22,10 +23,10 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
readingFont: 'source-serif-4',
|
||||
fontSize: 21,
|
||||
highlightStyle: 'marker',
|
||||
highlightColor: '#ffff00',
|
||||
highlightColor: '#fde047',
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
highlightColorFriends: '#f97316',
|
||||
highlightColorMine: '#ffff00',
|
||||
highlightColorMine: '#fde047',
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
@@ -159,6 +160,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
|
||||
107
src/components/Settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
|
||||
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
|
||||
|
||||
interface ThemeSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const currentTheme = settings.theme ?? 'system'
|
||||
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
|
||||
const currentLightColor = settings.lightColorTheme ?? 'sepia'
|
||||
|
||||
// Determine which color picker to show based on current theme
|
||||
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
|
||||
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
|
||||
|
||||
// Color definitions for swatches
|
||||
const darkColors = {
|
||||
black: '#000000',
|
||||
midnight: '#18181b',
|
||||
charcoal: '#1c1c1e'
|
||||
}
|
||||
|
||||
const lightColors = {
|
||||
'paper-white': '#ffffff',
|
||||
sepia: '#f4f1ea',
|
||||
ivory: '#fffff0'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Theme</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Appearance</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faSun}
|
||||
onClick={() => onUpdate({ theme: 'light' })}
|
||||
title="Light theme"
|
||||
ariaLabel="Light theme"
|
||||
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faMoon}
|
||||
onClick={() => onUpdate({ theme: 'dark' })}
|
||||
title="Dark theme"
|
||||
ariaLabel="Dark theme"
|
||||
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faDesktop}
|
||||
onClick={() => onUpdate({ theme: 'system' })}
|
||||
title="Use system preference"
|
||||
ariaLabel="Use system preference"
|
||||
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDarkColors && (
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Dark Theme</label>
|
||||
<div className="color-picker">
|
||||
{Object.entries(darkColors).map(([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
|
||||
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLightColors && (
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Light Theme</label>
|
||||
<div className="color-picker">
|
||||
{Object.entries(lightColors).map(([key, color]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
|
||||
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSettings
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -117,6 +117,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faBolt}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
|
||||
42
src/components/Skeletons/BlogPostSkeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const BlogPostSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="blog-post-card"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="blog-post-card-image">
|
||||
<Skeleton height={200} style={{ display: 'block' }} />
|
||||
</div>
|
||||
<div className="blog-post-card-content">
|
||||
<Skeleton
|
||||
height={24}
|
||||
width="85%"
|
||||
style={{ marginBottom: '0.75rem' }}
|
||||
className="blog-post-card-title"
|
||||
/>
|
||||
<Skeleton
|
||||
count={2}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
className="blog-post-card-summary"
|
||||
/>
|
||||
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
|
||||
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton width={100} height={14} />
|
||||
</span>
|
||||
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton width={80} height={14} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/components/Skeletons/BookmarkSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
import { ViewMode } from '../Bookmarks'
|
||||
|
||||
interface BookmarkSkeletonProps {
|
||||
viewMode: ViewMode
|
||||
}
|
||||
|
||||
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
|
||||
if (viewMode === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className="bookmark-item-compact"
|
||||
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
|
||||
<Skeleton width={40} height={40} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width="60%" height={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'cards') {
|
||||
return (
|
||||
<div
|
||||
className="bookmark-card"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton height={160} style={{ display: 'block' }} />
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
<Skeleton width={80} height={14} />
|
||||
<Skeleton width={60} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// large view
|
||||
return (
|
||||
<div
|
||||
className="bookmark-large"
|
||||
style={{
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton height={240} style={{ display: 'block' }} />
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<Skeleton circle width={32} height={32} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width={100} height={12} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
src/components/Skeletons/ContentSkeleton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const ContentSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="reader-content"
|
||||
style={{
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
padding: '2rem 1rem'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Title */}
|
||||
<Skeleton
|
||||
height={48}
|
||||
width="90%"
|
||||
style={{ marginBottom: '1rem' }}
|
||||
/>
|
||||
|
||||
{/* Byline / Meta */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
|
||||
<Skeleton circle width={40} height={40} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width={200} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<Skeleton
|
||||
height={400}
|
||||
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||
/>
|
||||
|
||||
{/* Paragraphs */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="80%" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="65%" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="90%" />
|
||||
</div>
|
||||
|
||||
{/* Another image placeholder */}
|
||||
<Skeleton
|
||||
height={300}
|
||||
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||
<Skeleton width="75%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/components/Skeletons/HighlightSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import Skeleton from 'react-loading-skeleton'
|
||||
|
||||
export const HighlightSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="highlight-item"
|
||||
style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Author line with avatar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<Skeleton circle width={24} height={24} />
|
||||
<Skeleton width={120} height={14} />
|
||||
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
|
||||
{/* Highlight content */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
|
||||
<Skeleton width="70%" />
|
||||
</div>
|
||||
|
||||
{/* Citation/context */}
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<Skeleton width="90%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/components/Skeletons/SkeletonThemeProvider.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { SkeletonTheme } from 'react-loading-skeleton'
|
||||
|
||||
interface SkeletonThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
|
||||
const [colors, setColors] = useState({
|
||||
baseColor: '#27272a',
|
||||
highlightColor: '#52525b'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const updateColors = () => {
|
||||
const rootStyles = getComputedStyle(document.documentElement)
|
||||
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
|
||||
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
|
||||
|
||||
setColors({ baseColor, highlightColor })
|
||||
}
|
||||
|
||||
// Initial update
|
||||
updateColors()
|
||||
|
||||
// Watch for theme changes via MutationObserver
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
updateColors()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/components/Skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
|
||||
export { BookmarkSkeleton } from './BookmarkSkeleton'
|
||||
export { BlogPostSkeleton } from './BlogPostSkeleton'
|
||||
export { HighlightSkeleton } from './HighlightSkeleton'
|
||||
export { ContentSkeleton } from './ContentSkeleton'
|
||||
|
||||
235
src/components/Support.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface SupportProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
type SupporterProfile = ZapSender
|
||||
|
||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupporters = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const zappers = await fetchBorisZappers(relayPool)
|
||||
|
||||
if (zappers.length > 0) {
|
||||
const pubkeys = zappers.map(z => z.pubkey)
|
||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||
}
|
||||
|
||||
setSupporters(zappers)
|
||||
} catch (error) {
|
||||
console.error('Failed to load supporters:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSupporters()
|
||||
}, [relayPool, eventStore, settings])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||
<div className="text-center mb-16 md:mb-20">
|
||||
<div className="flex justify-center mb-8">
|
||||
<img
|
||||
src="/thank-you.svg"
|
||||
alt="Thank you"
|
||||
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
|
||||
Thank You!
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Your{' '}
|
||||
<a
|
||||
href="https://www.readwithboris.com/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
zaps
|
||||
</a>
|
||||
{' '}help keep this project alive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{supporters.length === 0 ? (
|
||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Whales Section */}
|
||||
{supporters.filter(s => s.isWhale).length > 0 && (
|
||||
<div className="mb-16 md:mb-20">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Legends
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||
{supporters.filter(s => s.isWhale).map(supporter => (
|
||||
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular Supporters Section */}
|
||||
{supporters.filter(s => !s.isWhale).length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Supporters
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||
{supporters.filter(s => !s.isWhale).map(supporter => (
|
||||
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Zap{' '}
|
||||
<a
|
||||
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
Boris
|
||||
</a>
|
||||
{' '}a{' '}
|
||||
<a
|
||||
href="https://www.readwithboris.com/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
meaningful amount of sats
|
||||
</a>
|
||||
{' '}and your avatar will show above.
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Total supporters: {supporters.length} •
|
||||
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SupporterCardProps {
|
||||
supporter: SupporterProfile
|
||||
isWhale: boolean
|
||||
}
|
||||
|
||||
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
|
||||
const navigate = useNavigate()
|
||||
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||
|
||||
const picture = profile?.picture
|
||||
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
||||
|
||||
const handleClick = () => {
|
||||
const npub = nip19.npubEncode(supporter.pubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
|
||||
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
title={`${name} • ${supporter.totalSats.toLocaleString()} sats`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faUserCircle}
|
||||
className={isWhale ? 'text-5xl' : 'text-3xl'}
|
||||
style={{ color: 'var(--color-border)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Whale Badge */}
|
||||
{isWhale && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||
style={{ borderColor: 'var(--color-bg)' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Total */}
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={isWhale ? 'text-xs' : 'text-[10px]'}
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{supporter.totalSats.toLocaleString()} sats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Support
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ThreePaneLayoutProps {
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
showProfile?: boolean
|
||||
showSupport?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -93,6 +94,9 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Profile content
|
||||
profile?: React.ReactNode
|
||||
|
||||
// Optional Support content
|
||||
support?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
@@ -225,8 +229,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
@@ -245,8 +249,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
@@ -299,7 +303,6 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
lastFetchTime={props.lastFetchTime}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
@@ -329,6 +332,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.profile}
|
||||
</>
|
||||
) : props.showSupport && props.support ? (
|
||||
// Render Support inside the main pane to keep side panels
|
||||
<>
|
||||
{props.support}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
|
||||
12
src/config/network.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Centralized network configuration for relay queries
|
||||
// Keep timeouts modest for local-first, longer for remote; tweak per use-case
|
||||
|
||||
export const LOCAL_TIMEOUT_MS = 1200
|
||||
export const REMOTE_TIMEOUT_MS = 6000
|
||||
|
||||
// Contacts often need a bit more time on mobile networks
|
||||
export const CONTACTS_REMOTE_TIMEOUT_MS = 9000
|
||||
|
||||
// Future knobs could live here (e.g., max limits per kind)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
@@ -9,10 +10,8 @@ import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
activeAccount: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
accountManager: any
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushSync } from 'react-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
@@ -10,8 +11,7 @@ import { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
activeAccount: any
|
||||
activeAccount: IAccount | undefined
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
currentArticle: NostrEvent | undefined
|
||||
|
||||
@@ -58,12 +58,15 @@ export const useHighlightInteractions = ({
|
||||
}
|
||||
}, [onHighlightClick, contentVersion])
|
||||
|
||||
// Scroll to selected highlight
|
||||
// Scroll to selected highlight with retry mechanism
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
// Use a small delay to ensure DOM is updated
|
||||
const timeoutId = setTimeout(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // Try for up to 2 seconds
|
||||
const retryDelay = 100
|
||||
|
||||
const tryScroll = () => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
@@ -76,10 +79,16 @@ export const useHighlightInteractions = ({
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++
|
||||
setTimeout(tryScroll, retryDelay)
|
||||
} else {
|
||||
console.warn('Could not find mark element for highlight:', selectedHighlightId)
|
||||
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Start trying after a small initial delay
|
||||
const timeoutId = setTimeout(tryScroll, 100)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [selectedHighlightId, contentVersion])
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
/**
|
||||
* Hook to return image URL for display
|
||||
* Service Worker handles all caching transparently
|
||||
@@ -9,9 +7,7 @@ import { UserSettings } from '../services/settingsService'
|
||||
* @returns The image URL (Service Worker handles caching)
|
||||
*/
|
||||
export function useImageCache(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
imageUrl: string | undefined
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
return imageUrl
|
||||
@@ -22,9 +18,7 @@ export function useImageCache(
|
||||
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||
*/
|
||||
export function useCacheImageOnLoad(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
imageUrl: string | undefined
|
||||
): void {
|
||||
// Service Worker will cache on first fetch
|
||||
// This hook is now a no-op, kept for API compatibility
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useEffect, useRef, useState, RefObject } from 'react'
|
||||
import { useIsCoarsePointer } from './useMediaQuery'
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing?: boolean
|
||||
disabled?: boolean
|
||||
threshold?: number // Distance in pixels to trigger refresh
|
||||
resistance?: number // Resistance factor (higher = harder to pull)
|
||||
}
|
||||
|
||||
interface PullToRefreshState {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable pull-to-refresh gesture on touch devices
|
||||
* @param containerRef - Ref to the scrollable container element
|
||||
* @param options - Configuration options
|
||||
* @returns State of the pull gesture
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UsePullToRefreshOptions
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
disabled = false,
|
||||
threshold = 80,
|
||||
resistance = 2.5
|
||||
} = options
|
||||
|
||||
const isTouch = useIsCoarsePointer()
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
|
||||
const touchStartY = useRef<number>(0)
|
||||
const startScrollTop = useRef<number>(0)
|
||||
const isDragging = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !isTouch || disabled || isRefreshing) return
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
const scrollTop = container.scrollTop
|
||||
if (scrollTop <= 0) {
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
startScrollTop.current = scrollTop
|
||||
isDragging.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const deltaY = currentY - touchStartY.current
|
||||
const scrollTop = container.scrollTop
|
||||
|
||||
// Only pull down when at top and pulling down
|
||||
if (scrollTop <= 0 && deltaY > 0) {
|
||||
// Prevent default scroll behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Apply resistance to make pulling feel natural
|
||||
const distance = Math.min(deltaY / resistance, threshold * 1.5)
|
||||
const canRefresh = distance >= threshold
|
||||
|
||||
setPullState({
|
||||
isPulling: true,
|
||||
pullDistance: distance,
|
||||
canRefresh
|
||||
})
|
||||
} else {
|
||||
// Reset if scrolled or pulling up
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
isDragging.current = false
|
||||
|
||||
if (pullState.canRefresh && !isRefreshing) {
|
||||
// Keep the indicator visible while refreshing
|
||||
setPullState(prev => ({
|
||||
...prev,
|
||||
isPulling: false
|
||||
}))
|
||||
|
||||
// Trigger refresh
|
||||
await onRefresh()
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners with passive: false to allow preventDefault
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
|
||||
|
||||
// Reset pull state when refresh completes
|
||||
useEffect(() => {
|
||||
if (!isRefreshing && pullState.isPulling) {
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}, [isRefreshing, pullState.isPulling])
|
||||
|
||||
return pullState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface UseSettingsParams {
|
||||
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
|
||||
|
||||
// Apply theme with color variants (defaults to 'system' if not set)
|
||||
applyTheme(
|
||||
settings.theme ?? 'system',
|
||||
settings.darkColorTheme ?? 'midnight',
|
||||
settings.lightColorTheme ?? 'sepia'
|
||||
)
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
@@ -77,7 +85,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fullAccount = accountManager.getActive()
|
||||
if (!fullAccount) throw new Error('No active account')
|
||||
const factory = new EventFactory({ signer: fullAccount })
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings)
|
||||
setSettings(newSettings)
|
||||
setToastType('success')
|
||||
setToastMessage('Settings saved')
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
@import './styles/components/settings.css';
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/components/pull-to-refresh.css';
|
||||
@import './styles/components/skeletons.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
@import './styles/utils/legacy.css';
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
@@ -16,7 +15,7 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
|
||||
|
||||
@@ -31,23 +30,14 @@ export const fetchBookmarks = async (
|
||||
if (!isAccountWithExtension(activeAccount)) {
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
// Try local-first quickly, then full set fallback
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
console.log('🔍 Fetching bookmark events')
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
||||
{}
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
@@ -111,14 +101,11 @@ export const fetchBookmarks = async (
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
|
||||
const localHydrate$ = localHydrate.length > 0
|
||||
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remoteHydrate$ = remoteHydrate.length > 0
|
||||
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: noteIds },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -15,24 +16,27 @@ export const fetchContacts = async (
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
|
||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||
|
||||
// Local-first quick attempt
|
||||
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
|
||||
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
|
||||
const partialFollowed = new Set<string>()
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
{
|
||||
relayUrls,
|
||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||
// Stream partials as we see any contact list
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
partialFollowed.add(tag[1])
|
||||
}
|
||||
}
|
||||
if (onPartial && partialFollowed.size > 0) {
|
||||
onPartial(new Set(partialFollowed))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const followed = new Set<string>()
|
||||
if (events.length > 0) {
|
||||
|
||||
70
src/services/dataFetch.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
export interface QueryOptions {
|
||||
relayUrls?: string[]
|
||||
localTimeoutMs?: number
|
||||
remoteTimeoutMs?: number
|
||||
onEvent?: (event: NostrEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified local-first query helper with optional streaming callback.
|
||||
* Returns all collected events (deduped by id) after both streams complete or time out.
|
||||
*/
|
||||
export async function queryEvents(
|
||||
relayPool: RelayPool,
|
||||
filter: Filter,
|
||||
options: QueryOptions = {}
|
||||
): Promise<NostrEvent[]> {
|
||||
const {
|
||||
relayUrls,
|
||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||
onEvent
|
||||
} = options
|
||||
|
||||
const urls = relayUrls && relayUrls.length > 0
|
||||
? relayUrls
|
||||
: Array.from(relayPool.relays.values()).map(r => r.url)
|
||||
|
||||
const ordered = prioritizeLocalRelays(urls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||
|
||||
const local$: Observable<NostrEvent> = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(localTimeoutMs))
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$: Observable<NostrEvent> = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(remoteTimeoutMs))
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
// Deduplicate by id (callers can perform higher-level replaceable grouping if needed)
|
||||
const byId = new Map<string, NostrEvent>()
|
||||
for (const ev of events) {
|
||||
if (!byId.has(ev.id)) byId.set(ev.id, ev)
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
export interface CachedBlogPostPreview {
|
||||
event: NostrEvent
|
||||
@@ -11,6 +12,7 @@ export interface CachedBlogPostPreview {
|
||||
|
||||
type CacheValue = {
|
||||
posts: CachedBlogPostPreview[]
|
||||
highlights: Highlight[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -22,8 +24,28 @@ export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
|
||||
return entry.posts
|
||||
}
|
||||
|
||||
export function getCachedHighlights(pubkey: string): Highlight[] | null {
|
||||
const entry = exploreCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry.highlights
|
||||
}
|
||||
|
||||
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
|
||||
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts,
|
||||
highlights: current?.highlights || [],
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const current = exploreCache.get(pubkey)
|
||||
exploreCache.set(pubkey, {
|
||||
posts: current?.posts || [],
|
||||
highlights,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
|
||||
@@ -39,4 +61,13 @@ export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): C
|
||||
return merged
|
||||
}
|
||||
|
||||
export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] {
|
||||
const current = exploreCache.get(pubkey)?.highlights || []
|
||||
const byId = new Map(current.map(h => [h.id, h]))
|
||||
byId.set(highlight.id, highlight)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||
setCachedHighlights(pubkey, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -35,49 +34,38 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
const processEvents = (incoming: NostrEvent[]) => {
|
||||
for (const event of incoming) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
// Emit as we incorporate
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
// Emit as we incorporate
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
processEvents(events)
|
||||
)
|
||||
|
||||
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { publishEvent } from './writeService'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
@@ -118,59 +118,26 @@ export async function createHighlight(
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(signedEvent)
|
||||
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
// Use unified write service to store and publish
|
||||
await publishEvent(relayPool, eventStore, signedEvent)
|
||||
|
||||
// Check current connection status for UI feedback
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url =>
|
||||
!url.includes('localhost') && !url.includes('127.0.0.1')
|
||||
)
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
|
||||
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Highlight relay status:', {
|
||||
targetRelays: targetRelays.length,
|
||||
expectedSuccessRelays,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: signedEvent.id
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
markEventAsOfflineCreated(signedEvent.id)
|
||||
}
|
||||
|
||||
|
||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
||||
const highlight = eventToHighlight(signedEvent)
|
||||
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
|
||||
highlight.publishedRelays = expectedSuccessRelays
|
||||
highlight.isLocalOnly = isLocalOnly
|
||||
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
|
||||
|
||||
// Publish to relays in the background (non-blocking)
|
||||
// This allows instant UI updates while publishing happens asynchronously
|
||||
relayPool.publish(targetRelays, signedEvent)
|
||||
.then(() => {
|
||||
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
|
||||
})
|
||||
|
||||
// Return the highlight immediately for instant UI updates
|
||||
highlight.isOfflineCreated = isLocalOnly
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
|
||||
export * from './highlights/fetchFromAuthors'
|
||||
|
||||
|
||||
52
src/services/highlights/fetchFromAuthors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { queryEvents } from '../dataFetch'
|
||||
|
||||
/**
|
||||
* Fetches highlights (kind:9802) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchHighlightsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch highlights from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], authors: pubkeys, limit: 200 },
|
||||
{
|
||||
onEvent: (event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights from authors:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -28,58 +27,11 @@ export async function fetchReadArticles(
|
||||
userPubkey: string
|
||||
): Promise<ReadArticle[]> {
|
||||
try {
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch kind:7 reactions (nostr-native articles)
|
||||
const kind7Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind7Local$, kind7Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Fetch kind:17 reactions (external URLs)
|
||||
const kind17Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind17Local$, kind17Remote$).pipe(toArray())
|
||||
)
|
||||
// Fetch kind:7 and kind:17 reactions in parallel
|
||||
const [kind7Events, kind17Events] = await Promise.all([
|
||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||
])
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
|
||||
@@ -157,34 +109,13 @@ export async function fetchReadArticlesWithData(
|
||||
return []
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the actual article events
|
||||
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const articleEvents: NostrEvent[] = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
const articleEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], ids: eventIds },
|
||||
{ relayUrls: RELAYS }
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
|
||||
102
src/services/nostrverseService.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { queryEvents } from './dataFetch'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
/**
|
||||
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @param limit - Maximum number of posts to fetch (default: 50)
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchNostrverseBlogPosts = async (
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
limit = 50
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [30023], limit },
|
||||
{
|
||||
relayUrls,
|
||||
onEvent: (event: NostrEvent) => {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
.map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse blog posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param limit - Maximum number of highlights to fetch (default: 100)
|
||||
* @returns Array of highlights
|
||||
*/
|
||||
export const fetchNostrverseHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
limit = 100
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
|
||||
|
||||
const rawEvents = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [9802], limit },
|
||||
{}
|
||||
)
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
|
||||
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
|
||||
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nostrverse highlights:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
81
src/services/profileService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray, tap } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { UserSettings } from './settingsService'
|
||||
|
||||
/**
|
||||
* Fetches profile metadata (kind:0) for a list of pubkeys
|
||||
* Stores profiles in the event store and optionally to local relays
|
||||
*/
|
||||
export const fetchProfiles = async (
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkeys: string[],
|
||||
settings?: UserSettings
|
||||
): Promise<NostrEvent[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys))
|
||||
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
|
||||
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Keep only the most recent profile for each pubkey
|
||||
const profilesByPubkey = new Map<string, NostrEvent>()
|
||||
|
||||
const processEvent = (event: NostrEvent) => {
|
||||
const existing = profilesByPubkey.get(event.pubkey)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
profilesByPubkey.set(event.pubkey, event)
|
||||
// Store in event store immediately
|
||||
eventStore.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [0], authors: uniquePubkeys })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => processEvent(event)),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [0], authors: uniquePubkeys })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => processEvent(event)),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const profiles = Array.from(profilesByPubkey.values())
|
||||
console.log('✅ Fetched', profiles.length, 'unique profiles')
|
||||
|
||||
// Rebroadcast profiles to local/all relays based on settings
|
||||
if (profiles.length > 0) {
|
||||
await rebroadcastEvents(profiles, relayPool, settings)
|
||||
}
|
||||
|
||||
return profiles
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { publishEvent } from './writeService'
|
||||
|
||||
const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||
@@ -47,6 +48,10 @@ export interface UserSettings {
|
||||
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||
// Mobile settings
|
||||
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
|
||||
// Theme preference
|
||||
theme?: 'dark' | 'light' | 'system' // default: system
|
||||
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
@@ -143,11 +148,10 @@ export async function saveSettings(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
factory: EventFactory,
|
||||
settings: UserSettings,
|
||||
relays: string[]
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
console.log('💾 Saving settings to nostr:', settings)
|
||||
|
||||
|
||||
// Create NIP-78 application data event manually
|
||||
// Note: AppDataBlueprint is not available in the npm package
|
||||
const draft = await factory.create(async () => ({
|
||||
@@ -156,14 +160,12 @@ export async function saveSettings(
|
||||
tags: [['d', SETTINGS_IDENTIFIER]],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
|
||||
|
||||
eventStore.add(signed)
|
||||
await relayPool.publish(relays, signed)
|
||||
|
||||
|
||||
// Use unified write service
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ Settings published successfully')
|
||||
}
|
||||
|
||||
|
||||
57
src/services/writeService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
|
||||
/**
|
||||
* Unified write helper: add event to EventStore, detect connectivity,
|
||||
* mark for offline sync if needed, and publish in background.
|
||||
*/
|
||||
export async function publishEvent(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
event: NostrEvent
|
||||
): Promise<void> {
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(event)
|
||||
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
|
||||
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url))
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(isLocalRelay)
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Event relay status:', {
|
||||
targetRelays: RELAYS.length,
|
||||
expectedSuccessRelays: expectedSuccessRelays.length,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: event.id.slice(0, 8)
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
markEventAsOfflineCreated(event.id)
|
||||
}
|
||||
|
||||
// Publish to all configured relays in the background (non-blocking)
|
||||
relayPool.publish(RELAYS, event)
|
||||
.then(() => {
|
||||
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
|
||||
})
|
||||
}
|
||||
|
||||
127
src/services/zapReceiptService.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { isValidZap, getZapSender, getZapAmount } from 'applesauce-core/helpers'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { BORIS_PUBKEY } from './highlightCreationService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
export interface ZapSender {
|
||||
pubkey: string
|
||||
totalSats: number
|
||||
zapCount: number
|
||||
isWhale: boolean // >= 69420 sats
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches zap receipts (kind:9735) for Boris and aggregates by sender
|
||||
* @param relayPool - The relay pool to query
|
||||
* @returns Array of senders who zapped >= 2100 sats, sorted by total desc
|
||||
*/
|
||||
export async function fetchBorisZappers(
|
||||
relayPool: RelayPool
|
||||
): Promise<ZapSender[]> {
|
||||
try {
|
||||
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
|
||||
|
||||
// Use all configured relays plus specific zap-heavy relays
|
||||
const zapRelays = [
|
||||
...RELAYS,
|
||||
'wss://nostr.mutinywallet.com', // Common zap relay
|
||||
'wss://relay.getalby.com/v1', // Alby zap relay
|
||||
]
|
||||
const prioritized = prioritizeLocalRelays(zapRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Fetch zap receipts with Boris as recipient
|
||||
const filter = {
|
||||
kinds: [9735],
|
||||
'#p': [BORIS_PUBKEY]
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const zapReceipts = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
|
||||
|
||||
// Dedupe by event ID and validate
|
||||
const uniqueReceipts = new Map<string, NostrEvent>()
|
||||
let invalidCount = 0
|
||||
|
||||
zapReceipts.forEach(receipt => {
|
||||
if (!uniqueReceipts.has(receipt.id)) {
|
||||
if (isValidZap(receipt)) {
|
||||
uniqueReceipts.set(receipt.id, receipt)
|
||||
} else {
|
||||
invalidCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ ${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
|
||||
|
||||
// Aggregate by sender using applesauce helpers
|
||||
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
|
||||
|
||||
for (const receipt of uniqueReceipts.values()) {
|
||||
const senderPubkey = getZapSender(receipt)
|
||||
const amountMsats = getZapAmount(receipt)
|
||||
|
||||
if (!senderPubkey || !amountMsats || amountMsats === 0) {
|
||||
console.warn('Invalid zap receipt - missing sender or amount:', receipt.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const amountSats = Math.floor(amountMsats / 1000)
|
||||
|
||||
const existing = senderTotals.get(senderPubkey) || { totalSats: 0, zapCount: 0 }
|
||||
senderTotals.set(senderPubkey, {
|
||||
totalSats: existing.totalSats + amountSats,
|
||||
zapCount: existing.zapCount + 1
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`👥 Found ${senderTotals.size} unique senders`)
|
||||
|
||||
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
|
||||
const zappers: ZapSender[] = Array.from(senderTotals.entries())
|
||||
.filter(([, data]) => data.totalSats >= 2100)
|
||||
.map(([pubkey, data]) => ({
|
||||
pubkey,
|
||||
totalSats: data.totalSats,
|
||||
zapCount: data.zapCount,
|
||||
isWhale: data.totalSats >= 69420
|
||||
}))
|
||||
.sort((a, b) => b.totalSats - a.totalSats)
|
||||
|
||||
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
|
||||
|
||||
return zappers
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch zap receipts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
/* Body - keep only app-specific overrides */
|
||||
body {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: hidden;
|
||||
@@ -22,7 +24,7 @@ body.mobile-sidebar-open {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgb(212 212 216); /* zinc-300 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -47,10 +43,246 @@
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
:root.theme-dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-bg: #18181b; /* zinc-900 */
|
||||
--color-bg-elevated: #27272a; /* zinc-800 */
|
||||
--color-bg-subtle: #1e1e1e; /* between zinc-800 and zinc-900 */
|
||||
--color-border: #3f3f46; /* zinc-700 */
|
||||
--color-border-subtle: #52525b; /* zinc-600 */
|
||||
--color-text: #e4e4e7; /* zinc-200 */
|
||||
--color-text-secondary: #a1a1aa; /* zinc-400 */
|
||||
--color-text-muted: #71717a; /* zinc-500 */
|
||||
--color-primary: #6366f1; /* indigo-500 */
|
||||
--color-primary-hover: #4f46e5; /* indigo-600 */
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:root.theme-light {
|
||||
color-scheme: light;
|
||||
|
||||
--color-bg: #ffffff; /* white */
|
||||
--color-bg-elevated: #f5f5f5; /* gray-100 */
|
||||
--color-bg-subtle: #fafafa; /* gray-50 */
|
||||
--color-border: #e5e7eb; /* gray-200 */
|
||||
--color-border-subtle: #d1d5db; /* gray-300 */
|
||||
--color-text: #111827; /* gray-900 */
|
||||
--color-text-secondary: #374151; /* gray-700 */
|
||||
--color-text-muted: #6b7280; /* gray-500 */
|
||||
--color-primary: #4f46e5; /* indigo-600 */
|
||||
--color-primary-hover: #4338ca; /* indigo-700 */
|
||||
|
||||
/* Highlight colors for light theme - use same Tailwind colors */
|
||||
--highlight-color-mine: #fde047; /* yellow-300 */
|
||||
--highlight-color-friends: #f97316; /* orange-500 */
|
||||
--highlight-color-nostrverse: #9333ea; /* purple-600 */
|
||||
}
|
||||
|
||||
/* System theme - follow OS preference */
|
||||
:root.theme-system {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.theme-system {
|
||||
--color-bg: #18181b;
|
||||
--color-bg-elevated: #27272a;
|
||||
--color-bg-subtle: #1e1e1e;
|
||||
--color-border: #3f3f46;
|
||||
--color-border-subtle: #52525b;
|
||||
--color-text: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
:root.theme-system {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-elevated: #f5f5f5;
|
||||
--color-bg-subtle: #fafafa;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-subtle: #d1d5db;
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
|
||||
/* Standard highlight colors */
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Color Theme Variants */
|
||||
/* Midnight (default) - current zinc palette */
|
||||
:root.dark-midnight {
|
||||
--color-bg: #18181b; /* zinc-900 */
|
||||
--color-bg-elevated: #27272a; /* zinc-800 */
|
||||
--color-bg-subtle: #0f0f11; /* darker than zinc-900 */
|
||||
--color-border: #3f3f46; /* zinc-700 */
|
||||
--color-border-subtle: #52525b; /* zinc-600 */
|
||||
--color-text: #e4e4e7; /* zinc-200 */
|
||||
--color-text-secondary: #a1a1aa; /* zinc-400 */
|
||||
--color-text-muted: #71717a; /* zinc-500 */
|
||||
}
|
||||
|
||||
/* Black - true black for OLED */
|
||||
:root.dark-black {
|
||||
--color-bg: #000000; /* true black */
|
||||
--color-bg-elevated: #0a0a0a; /* very dark gray */
|
||||
--color-bg-subtle: #000000; /* true black for body */
|
||||
--color-border: #1a1a1a;
|
||||
--color-border-subtle: #2a2a2a;
|
||||
--color-text: #e4e4e7; /* zinc-200 */
|
||||
--color-text-secondary: #a1a1aa; /* zinc-400 */
|
||||
--color-text-muted: #71717a; /* zinc-500 */
|
||||
}
|
||||
|
||||
/* Charcoal - warmer, softer dark */
|
||||
:root.dark-charcoal {
|
||||
--color-bg: #1c1c1e; /* warmer dark */
|
||||
--color-bg-elevated: #2c2c2e;
|
||||
--color-bg-subtle: #16161a; /* darker charcoal */
|
||||
--color-border: #3a3a3c;
|
||||
--color-border-subtle: #48484a;
|
||||
--color-text: #e4e4e7; /* zinc-200 */
|
||||
--color-text-secondary: #a1a1aa; /* zinc-400 */
|
||||
--color-text-muted: #71717a; /* zinc-500 */
|
||||
}
|
||||
|
||||
/* Light Color Theme Variants */
|
||||
/* Paper White (default) - pure white */
|
||||
:root.light-paper-white {
|
||||
--color-bg: #ffffff; /* white */
|
||||
--color-bg-elevated: #f5f5f5; /* gray-100 */
|
||||
--color-bg-subtle: #fafafa; /* gray-50 */
|
||||
--color-border: #e5e7eb; /* gray-200 */
|
||||
--color-border-subtle: #d1d5db; /* gray-300 */
|
||||
|
||||
/* Standard highlight colors */
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
|
||||
/* Sepia - warm, reading-friendly */
|
||||
:root.light-sepia {
|
||||
--color-bg: #f4f1ea; /* warm beige */
|
||||
--color-bg-elevated: #ebe6db; /* darker beige */
|
||||
--color-bg-subtle: #f9f6f0; /* lighter beige */
|
||||
--color-border: #d4cfc4; /* warm gray border */
|
||||
--color-border-subtle: #c4bfb4;
|
||||
--color-text: #2d2a24; /* warm dark brown */
|
||||
--color-text-secondary: #5d5a54;
|
||||
--color-text-muted: #8d8a84;
|
||||
|
||||
/* Standard highlight colors */
|
||||
--highlight-color-mine: #fde047; /* yellow-300 */
|
||||
--highlight-color-friends: #f97316; /* orange-500 */
|
||||
--highlight-color-nostrverse: #9333ea; /* purple-600 */
|
||||
}
|
||||
|
||||
/* Ivory - soft, creamy */
|
||||
:root.light-ivory {
|
||||
--color-bg: #fffff0; /* ivory */
|
||||
--color-bg-elevated: #faf8f0; /* cream */
|
||||
--color-bg-subtle: #fefef8;
|
||||
--color-border: #e8e6de;
|
||||
--color-border-subtle: #d8d6ce;
|
||||
--color-text: #1a1a18; /* near black with warm tint */
|
||||
--color-text-secondary: #4a4a48;
|
||||
--color-text-muted: #7a7a78;
|
||||
|
||||
/* Standard highlight colors */
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
|
||||
/* System theme color variants */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.theme-system.dark-midnight {
|
||||
--color-bg: #18181b;
|
||||
--color-bg-elevated: #27272a;
|
||||
--color-bg-subtle: #0f0f11;
|
||||
--color-border: #3f3f46;
|
||||
--color-border-subtle: #52525b;
|
||||
--color-text: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
}
|
||||
|
||||
:root.theme-system.dark-black {
|
||||
--color-bg: #000000;
|
||||
--color-bg-elevated: #0a0a0a;
|
||||
--color-bg-subtle: #000000;
|
||||
--color-border: #1a1a1a;
|
||||
--color-border-subtle: #2a2a2a;
|
||||
--color-text: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
}
|
||||
|
||||
:root.theme-system.dark-charcoal {
|
||||
--color-bg: #1c1c1e;
|
||||
--color-bg-elevated: #2c2c2e;
|
||||
--color-bg-subtle: #16161a;
|
||||
--color-border: #3a3a3c;
|
||||
--color-border-subtle: #48484a;
|
||||
--color-text: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root.theme-system.light-paper-white {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-elevated: #f5f5f5;
|
||||
--color-bg-subtle: #fafafa;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-subtle: #d1d5db;
|
||||
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
|
||||
:root.theme-system.light-sepia {
|
||||
--color-bg: #f4f1ea;
|
||||
--color-bg-elevated: #ebe6db;
|
||||
--color-bg-subtle: #f9f6f0;
|
||||
--color-border: #d4cfc4;
|
||||
--color-border-subtle: #c4bfb4;
|
||||
--color-text: #2d2a24;
|
||||
--color-text-secondary: #5d5a54;
|
||||
--color-text-muted: #8d8a84;
|
||||
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
|
||||
:root.theme-system.light-ivory {
|
||||
--color-bg: #fffff0;
|
||||
--color-bg-elevated: #faf8f0;
|
||||
--color-bg-subtle: #fefef8;
|
||||
--color-border: #e8e6de;
|
||||
--color-border-subtle: #d8d6ce;
|
||||
--color-text: #1a1a18;
|
||||
--color-text-secondary: #4a4a48;
|
||||
--color-text-muted: #7a7a78;
|
||||
|
||||
--highlight-color-mine: #fde047;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* Bookmark item and blog post cards */
|
||||
.bookmark-item { background: rgb(24 24 27); /* zinc-900 */ padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.bookmark-item { background: var(--color-bg); padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.bookmark-item:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); }
|
||||
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: rgb(255 255 255); /* white */ font-size: 1.2rem; }
|
||||
.bookmark-url { color: rgb(99 102 241); /* indigo-500 */ text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: var(--color-text); font-size: 1.2rem; }
|
||||
.bookmark-url { color: var(--color-primary); text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url:hover { text-decoration: underline; }
|
||||
.bookmark-content { color: rgb(212 212 216); /* zinc-300 */ margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: rgb(161 161 170); /* zinc-400 */ font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
|
||||
.individual-bookmarks { margin: 1rem 0; }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: rgb(255 255 255); /* white */ }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
|
||||
|
||||
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
|
||||
@@ -19,74 +19,76 @@
|
||||
.bookmarks-grid.bookmarks-large { gap: 1rem; }
|
||||
}
|
||||
|
||||
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid rgb(39 39 42); /* zinc-800 */ word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: rgb(63 63 70); /* zinc-700 */ background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid var(--color-bg-elevated); word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid rgb(39 39 42); /* zinc-800 */ border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: rgb(39 39 42); /* zinc-800 */ border-bottom-color: rgb(63 63 70); /* zinc-700 */ transform: none; box-shadow: none; }
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); 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-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; }
|
||||
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
|
||||
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.compact-row.clickable { cursor: pointer; }
|
||||
.compact-row.clickable:active { opacity: 0.8; }
|
||||
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: rgb(99 102 241); /* indigo-500 */ font-size: 0.85rem; flex-shrink: 0; }
|
||||
.compact-text { flex: 1; min-width: 0; color: rgb(212 212 216); /* zinc-300 */ font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookmark-date-compact { font-size: 0.7rem; color: rgb(113 113 122); /* zinc-500 */ flex-shrink: 0; white-space: nowrap; }
|
||||
.compact-read-btn { background: transparent; color: rgb(161 161 170); /* zinc-400 */ border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
|
||||
.compact-read-btn:hover { color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: var(--color-primary); font-size: 0.85rem; flex-shrink: 0; }
|
||||
.compact-text { flex: 1; min-width: 0; color: var(--color-text); font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookmark-date-compact { font-size: 0.7rem; color: var(--color-text-muted); flex-shrink: 0; white-space: nowrap; }
|
||||
.compact-read-btn { background: transparent; color: var(--color-text-secondary); border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
|
||||
.compact-read-btn:hover { color: var(--color-text); }
|
||||
.compact-read-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.bookmark-type { color: rgb(99 102 241); /* indigo-500 */ font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ background: rgb(24 24 27); /* zinc-900 */ padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ text-decoration: none; transition: color 0.2s ease; }
|
||||
.bookmark-date-link:hover { color: rgb(96 165 250); /* blue-400 */ text-decoration: underline; }
|
||||
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: rgb(212 212 216); /* zinc-300 */ line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: rgb(161 161 170); /* zinc-400 */ cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
|
||||
.expand-toggle:hover { color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
|
||||
.bookmark-date-link:hover { color: var(--color-primary); text-decoration: underline; }
|
||||
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: var(--color-text); line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: var(--color-text-secondary); cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
|
||||
.expand-toggle:hover { color: var(--color-text); }
|
||||
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
|
||||
.bookmark-meta-minimal { font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.author-link-minimal { color: rgb(161 161 170); /* zinc-400 */ text-decoration: none; transition: color 0.2s ease; }
|
||||
.author-link-minimal:hover { color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.read-now-button-minimal { background: rgb(99 102 241); /* indigo-500 */ color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
|
||||
.read-now-button-minimal:hover { background: rgb(79 70 229); /* indigo-600 */ }
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: rgb(99 102 241); /* indigo-500 */ cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: rgb(129 140 248); /* indigo-400 */ }
|
||||
.bookmark-meta-minimal { font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||
.author-link-minimal { color: var(--color-text-secondary); text-decoration: none; transition: color 0.2s ease; }
|
||||
.author-link-minimal:hover { color: var(--color-text); }
|
||||
.read-now-button-minimal { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
|
||||
.read-now-button-minimal:hover { background: var(--color-primary-hover); }
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgb(39 39 42); /* zinc-800 */ }
|
||||
.large-preview-image { width: 100%; height: 180px; background: rgb(24 24 27); /* zinc-900 */ background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ position: relative; }
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
.large-preview-image:hover { opacity: 0.9; }
|
||||
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
|
||||
.preview-placeholder { font-size: 3rem; color: rgb(82 82 91); /* zinc-600 */ }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: rgb(212 212 216); /* zinc-300 */ font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ padding-top: 0.75rem; border-top: 1px solid rgb(63 63 70); /* zinc-700 */ }
|
||||
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
|
||||
.large-author { flex: 1; }
|
||||
.large-read-button { background: rgb(99 102 241); /* indigo-500 */ color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.large-read-button:hover { background: rgb(79 70 229); /* indigo-600 */ }
|
||||
.large-read-button { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.large-read-button:hover { background: var(--color-primary-hover); }
|
||||
|
||||
/* Blog cards (Explore) */
|
||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: rgb(99 102 241); /* indigo-500 */ display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||
.explore-subtitle { font-size: 1.125rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
|
||||
.explore-loading, .explore-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: rgba(255, 255, 255, 0.7); }
|
||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
.explore-header .me-tabs { text-align: left; margin-top: 2rem; width: 100%; max-width: 100%; justify-content: flex-start; }
|
||||
.explore-loading, .explore-error, .explore-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: var(--color-text-secondary); }
|
||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||
.explore-error { color: rgb(239 68 68); /* red-500 */ }
|
||||
.explore-empty { color: var(--color-text-secondary); }
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||
.blog-post-card:hover { border-color: rgb(99 102 241); /* indigo-500 */ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: rgb(9 9 11); /* zinc-950 */ display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); 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: rgb(82 82 91); /* zinc-600 */ display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); 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 rgb(63 63 70); /* zinc-700 */ font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; }
|
||||
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
|
||||
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
||||
@media (max-width: 768px) {
|
||||
@@ -97,4 +99,3 @@
|
||||
.blog-post-card-content { padding: 1rem; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
.setting-label { text-align: left; flex: 1; }
|
||||
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
|
||||
.setting-group.setting-inline label { margin-bottom: 0; }
|
||||
.setting-group label { display: block; margin-bottom: 0.5rem; color: rgb(212 212 216); /* zinc-300 */ font-weight: 500; text-align: left; }
|
||||
.setting-group label { display: block; margin-bottom: 0.5rem; color: var(--color-text); font-weight: 500; text-align: left; }
|
||||
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-swatch { width: 33px; height: 33px; border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.color-swatch:hover { border-color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.color-swatch.active { border-color: rgb(99 102 241); /* indigo-500 */ box-shadow: 0 0 0 2px rgb(99 102 241); /* indigo-500 */ }
|
||||
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); /* black */ font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); /* white */ }
|
||||
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
|
||||
.font-size-btn:hover { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.font-size-btn.active { background: rgb(99 102 241); /* indigo-500 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
|
||||
.color-swatch { width: 33px; height: 33px; border: 1px solid var(--color-border-subtle); border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.color-swatch:hover { border-color: var(--color-text-secondary); }
|
||||
.color-swatch.active { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary); }
|
||||
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); }
|
||||
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
|
||||
.font-size-btn:hover { background: var(--color-border); border-color: var(--color-text-muted); }
|
||||
.font-size-btn.active { background: var(--color-primary); border-color: var(--color-primary); color: white; }
|
||||
.setting-preview {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-label { font-size: 0.875rem; color: rgb(161 161 170); /* zinc-400 */ margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.preview-label { font-size: 0.875rem; color: var(--color-text-secondary); margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.preview-content {
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
line-height: 1.7;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
@@ -35,20 +35,20 @@
|
||||
.preview-content h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5em;
|
||||
color: rgb(255 255 255); /* white */
|
||||
color: var(--color-text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.preview-content p {
|
||||
margin: 0.75rem 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 4px; color: rgb(255 255 255); /* white */ font-size: 1rem; }
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
.setting-select:focus { outline: none; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.setting-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
||||
.checkbox-label { display: flex !important; align-items: center; gap: 0.75rem; cursor: pointer; user-select: none; text-align: left; justify-content: flex-start; margin-bottom: 0 !important; font-weight: normal !important; }
|
||||
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.checkbox-label span { color: rgb(228 228 231); /* zinc-200 */ text-align: left; font-weight: 500; }
|
||||
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: var(--color-primary); }
|
||||
.checkbox-label span { color: var(--color-text); text-align: left; font-weight: 500; }
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
@@ -81,4 +81,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
@@ -14,16 +14,16 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-button:hover { background: rgb(63 63 70); /* zinc-700 */ }
|
||||
.icon-button:hover { background: var(--color-border); }
|
||||
.icon-button:active { transform: translateY(1px); }
|
||||
|
||||
.icon-button.primary { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.icon-button.primary { background: var(--color-primary); color: white; border-color: var(--color-primary); }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.icon-button.success { background: var(--color-primary); color: white; border-color: var(--color-primary); }
|
||||
.icon-button.success:hover { filter: brightness(1.1); }
|
||||
|
||||
.icon-button.ghost { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.icon-button.ghost { background: var(--color-bg-elevated); }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
@@ -42,9 +42,8 @@
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.icon-button.ghost:hover { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.icon-button:active { background: rgb(63 63 70); /* zinc-700 */ }
|
||||
.icon-button:hover { background: var(--color-bg-elevated); }
|
||||
.icon-button.ghost:hover { background: var(--color-bg-elevated); }
|
||||
.icon-button:active { background: var(--color-border); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-x: auto;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
@@ -18,7 +18,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary, rgb(161 161 170)); /* zinc-400 */
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -28,25 +28,32 @@
|
||||
}
|
||||
|
||||
.me-tab:hover {
|
||||
color: var(--text-primary, rgb(228 228 231)); /* zinc-200 */
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.me-tab.active {
|
||||
color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
border-bottom-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Highlights tab uses the user's custom "my highlights" color */
|
||||
/* Highlights tab uses the actual highlight style when active */
|
||||
.me-tab[data-tab="highlights"].active {
|
||||
color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
|
||||
color: var(--color-text);
|
||||
border-bottom-color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 20%, transparent);
|
||||
}
|
||||
|
||||
.me-tab[data-tab="highlights"].active:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Reading List tab uses blue color to match bookmarks icon */
|
||||
.me-tab[data-tab="reading-list"].active {
|
||||
color: rgb(99 102 241); /* indigo-500 */
|
||||
border-bottom-color: rgb(99 102 241); /* indigo-500 */
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
@@ -80,25 +87,25 @@
|
||||
|
||||
/* Enhanced border styling for reading list cards */
|
||||
.bookmarks-list .individual-bookmark {
|
||||
border: 1px solid rgb(82 82 91) !important; /* zinc-600 */
|
||||
background: rgb(24 24 27) !important; /* zinc-900 */
|
||||
border: 1px solid var(--color-border-subtle) !important;
|
||||
background: var(--color-bg) !important;
|
||||
}
|
||||
|
||||
.bookmarks-list .individual-bookmark:hover {
|
||||
border-color: rgb(113 113 122) !important; /* zinc-500 */
|
||||
background: rgb(39 39 42) !important; /* zinc-800 */
|
||||
border-color: var(--color-border) !important;
|
||||
background: var(--color-bg-elevated) !important;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, rgb(255 255 255)); /* white */
|
||||
border: 1px solid var(--border-color, rgb(228 228 231)); /* zinc-200 */
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -110,13 +117,13 @@
|
||||
.bookmark-item h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary, rgb(0 0 0)); /* black */
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bookmark-item p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, rgb(113 113 122)); /* zinc-500 */
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
/* Add Bookmark Modal */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
|
||||
.modal-content { background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
|
||||
.modal-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay { padding: 0; align-items: flex-end; }
|
||||
.modal-content { max-width: 100%; max-height: 95vh; max-height: 95dvh; border-radius: 16px 16px 0 0; margin: 0; padding-bottom: var(--safe-area-bottom); }
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ }
|
||||
.modal-header h2 { margin: 0; font-size: 1.5rem; color: rgb(255 255 255); /* white */ }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid var(--color-border); }
|
||||
.modal-header h2 { margin: 0; font-size: 1.5rem; color: var(--color-text); }
|
||||
.modal-form { padding: 1.5rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: rgb(212 212 216); /* zinc-300 */ font-size: 0.9rem; font-weight: 500; }
|
||||
.fetching-indicator { font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(255 255 255); /* white */ font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: var(--color-text); font-size: 0.9rem; font-weight: 500; }
|
||||
.fetching-indicator { font-size: 0.8rem; color: var(--color-text-secondary); font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||
.form-group input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ line-height: 1.4; }
|
||||
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); /* red-600 */ border-radius: 6px; color: rgb(220 38 38); /* red-600 */ font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: var(--color-text-secondary); line-height: 1.4; }
|
||||
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); border-radius: 6px; color: rgb(220 38 38); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.btn-secondary { padding: 0.75rem 1.5rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
|
||||
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-primary); color: var(--color-text); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: rgb(99 102 241); /* indigo-500 */ border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: rgb(79 70 229); /* indigo-600 */ }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: var(--color-primary); border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* Profile UI fragments */
|
||||
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
|
||||
.author-card-clickable:hover { border-color: rgb(99 102 241); /* indigo-500 */ background: rgb(30 30 33); /* slightly lighter */ transform: translateY(-1px); }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
|
||||
.author-card-clickable:hover { border-color: var(--color-primary); background: var(--color-bg-elevated); transform: translateY(-1px); }
|
||||
.author-card-clickable:active { transform: translateY(0); }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); }
|
||||
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.author-card-avatar svg { font-size: 2.5rem; }
|
||||
.author-card-content { flex: 1; min-width: 0; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: rgb(228 228 231); /* zinc-200 */ margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: rgb(161 161 170); /* zinc-400 */ line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-card-container {
|
||||
@@ -28,4 +28,3 @@
|
||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,21 +21,21 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pull-to-refresh-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--background-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Reader view */
|
||||
.reader {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
@@ -21,22 +21,22 @@
|
||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
||||
background: rgb(0 0 0); /* black */
|
||||
}
|
||||
.reader.empty { color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.reader.empty { color: var(--color-text-secondary); }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
|
||||
.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); font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-summary { color: rgb(212 212 216); /* zinc-300 */ font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; font-family: var(--reading-font); }
|
||||
.reader-summary { color: var(--color-text); font-size: 1.2rem; line-height: 1.6; 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(161, 161, 170, 0.7); /* zinc-400 */ opacity: 0.85; }
|
||||
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); 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: rgb(255 255 255); /* white */ 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(161, 161, 170, 0.1); /* zinc-400 */ border: 1px solid rgba(161, 161, 170, 0.3); /* zinc-400 */ border-radius: 6px; font-size: 0.875rem; color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); 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: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); /* indigo-500 */ border: 1px solid rgba(99, 102, 241, 0.3); /* indigo-500 */ border-radius: 6px; font-size: 0.875rem; color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: rgb(228 228 231); /* zinc-200 */ 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: rgb(228 228 231); /* zinc-200 */ line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-html { color: var(--color-text); 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: var(--color-text); 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; }
|
||||
@@ -49,7 +49,7 @@
|
||||
line-height: 1.2;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown h2, .reader-html h2 {
|
||||
font-size: 1.875rem; /* text-3xl */
|
||||
@@ -57,7 +57,7 @@
|
||||
line-height: 1.3;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.875rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown h3, .reader-html h3 {
|
||||
font-size: 1.5rem; /* text-2xl */
|
||||
@@ -65,7 +65,7 @@
|
||||
line-height: 1.4;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown h4, .reader-html h4 {
|
||||
font-size: 1.25rem; /* text-xl */
|
||||
@@ -73,7 +73,7 @@
|
||||
line-height: 1.4;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.625rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown h5, .reader-html h5 {
|
||||
font-size: 1.125rem; /* text-lg */
|
||||
@@ -81,7 +81,7 @@
|
||||
line-height: 1.4;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown h6, .reader-html h6 {
|
||||
font-size: 1rem; /* text-base */
|
||||
@@ -89,7 +89,7 @@
|
||||
line-height: 1.4;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.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; }
|
||||
@@ -107,7 +107,7 @@
|
||||
.reader-markdown li, .reader-html li {
|
||||
margin: 0.375rem 0;
|
||||
line-height: 1.6;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
}
|
||||
.reader-markdown ul ul, .reader-markdown ol ul, .reader-html ul ul, .reader-html ol ul {
|
||||
list-style-type: circle;
|
||||
@@ -128,16 +128,16 @@
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
|
||||
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
|
||||
.reader-markdown a { color: rgb(96 165 250); /* blue-400 */ text-decoration: none; }
|
||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-markdown pre { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ 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 { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); 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: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ }
|
||||
.reader-markdown pre[class*="language-"] { background: var(--color-bg-subtle); border: 1px solid var(--color-border); }
|
||||
.reader-markdown code[class*="language-"] { background: transparent; text-shadow: none; }
|
||||
.reader-html pre { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html code { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); 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; }
|
||||
/* Mobile: prevent code blocks from causing horizontal overflow */
|
||||
@media (max-width: 768px) {
|
||||
@@ -182,17 +182,17 @@
|
||||
/* 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(99 102 241); /* indigo-500 */ background: rgba(99, 102, 241, 0.1); }
|
||||
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(228 228 231); /* zinc-200 */ 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(99, 102, 241, 0.15); color: rgb(255 255 255); /* white */ }
|
||||
.article-menu-btn { background: none; border: none; color: var(--color-text-secondary); 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: var(--color-primary); background: rgba(99, 102, 241, 0.1); }
|
||||
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); 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: var(--color-text); 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(99, 102, 241, 0.15); color: var(--color-text); }
|
||||
.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: rgb(39 39 42); /* zinc-800 */ color: rgb(228 228 231); /* zinc-200 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ transform: translateY(-1px); }
|
||||
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: var(--color-bg-elevated); color: var(--color-text); border: 1px solid var(--color-border-subtle); 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: var(--color-border); border-color: var(--color-text-muted); 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; }
|
||||
@@ -228,7 +228,7 @@
|
||||
@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-summary-below-image .reader-summary { color: var(--color-text-secondary); 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; }
|
||||
@@ -237,4 +237,3 @@
|
||||
|
||||
/* Reading Progress Indicator - now using Tailwind utilities in component */
|
||||
|
||||
|
||||
|
||||
@@ -6,37 +6,37 @@
|
||||
.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: rgb(255 255 255); /* white */ margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.section-title { font-size: 1rem; font-weight: 600; color: var(--color-text); margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border); 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: rgb(99 102 241); /* indigo-500 */ 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: rgb(79 70 229); /* indigo-600 */ }
|
||||
.settings-footer .btn-primary { background: var(--color-primary); 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: var(--color-primary-hover); }
|
||||
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Setting groups */
|
||||
.setting-group { margin-bottom: 1.5rem; }
|
||||
.setting-label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; font-weight: 500; color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.setting-label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; font-weight: 500; color: var(--color-text); }
|
||||
|
||||
/* Zap splits preset buttons */
|
||||
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.zap-preset-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
color: rgb(212 212 216); /* zinc-300 */
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
.zap-preset-btn:hover {
|
||||
background: rgb(63 63 70); /* zinc-700 */
|
||||
border-color: rgb(99 102 241); /* indigo-500 */
|
||||
color: rgb(255 255 255); /* white */
|
||||
background: var(--color-border);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.zap-preset-btn.active {
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
border-color: rgb(99 102 241); /* indigo-500 */
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: rgb(255 255 255); /* white */
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgb(161 161 170); /* zinc-400 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.zap-split-label { font-weight: 500; }
|
||||
.zap-split-slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
background: var(--color-bg-elevated);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -64,36 +64,36 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.zap-split-slider::-webkit-slider-thumb:hover {
|
||||
background: rgb(79 70 229); /* indigo-600 */
|
||||
background: var(--color-primary-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb:hover {
|
||||
background: rgb(79 70 229); /* indigo-600 */
|
||||
background: var(--color-primary-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-description {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(161 161 170); /* zinc-400 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Relay items */
|
||||
@@ -146,4 +146,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
35
src/styles/components/skeletons.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* Skeleton loading animations - respects prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.react-loading-skeleton {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure skeletons have proper border radius to match design */
|
||||
.react-loading-skeleton {
|
||||
border-radius: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Image skeleton aspect ratio boxes to prevent CLS */
|
||||
.blog-post-card-image .react-loading-skeleton,
|
||||
.bookmark-card .react-loading-skeleton:first-child {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
/* Skeleton spacing adjustments */
|
||||
.highlights-list .react-loading-skeleton,
|
||||
.bookmarks-list .react-loading-skeleton {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Ensure skeletons inherit theme colors properly */
|
||||
.react-loading-skeleton::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-border-subtle, rgba(255, 255, 255, 0.05)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Toast Notification */
|
||||
.toast { position: fixed; top: 2rem; right: 2rem; background: rgb(24 24 27); /* zinc-900 */ color: rgb(255 255 255); /* white */ padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid rgb(63 63 70); /* zinc-700 */ 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; }
|
||||
.toast { position: fixed; top: 2rem; right: 2rem; background: var(--color-bg); color: var(--color-text); padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid var(--color-border); 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; } }
|
||||
@@ -10,4 +10,3 @@
|
||||
.toast-error svg { color: rgb(220 38 38); /* red-600 */ }
|
||||
@keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
max-width: 320px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
background: var(--color-bg);
|
||||
z-index: 1001; /* Above backdrop */
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Highlights panel layout and interactions */
|
||||
.highlights-container {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -25,8 +25,8 @@
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
@@ -39,7 +39,7 @@
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn:hover { background: rgb(63 63 70); /* zinc-700 */ color: rgb(255 255 255); /* white */ }
|
||||
.highlights-container.collapsed .toggle-highlights-btn:hover { background: var(--color-border); color: var(--color-text); }
|
||||
.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; }
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
@@ -65,32 +65,32 @@
|
||||
|
||||
.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: rgb(161 161 170); /* zinc-400 */ font-size: 0.875rem; }
|
||||
.highlights-title .count { color: var(--color-text-secondary); 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(255 255 255); /* white */ }
|
||||
.highlight-mode-toggle .mode-btn.active { background: rgb(99 102 241); /* indigo-500 */ color: rgb(255 255 255); /* white */ }
|
||||
.highlight-mode-toggle .mode-btn { background: none; border: none; color: var(--color-text-secondary); 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: var(--color-text); }
|
||||
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: rgb(161 161 170); /* zinc-400 */ text-align: center; gap: 0.5rem; }
|
||||
.highlights-empty svg { color: rgb(113 113 122); /* zinc-500 */ margin-bottom: 0.5rem; }
|
||||
.empty-hint { font-size: 0.875rem; color: rgb(113 113 122); /* zinc-500 */ margin-top: 0.5rem; }
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }
|
||||
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
|
||||
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
||||
|
||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.highlight-item { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||
.highlight-item:hover { border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.highlight-item.selected { border-color: rgb(99 102 241); /* indigo-500 */ background: rgb(39 39 42); /* zinc-800 */ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||
.highlight-item:hover { border-color: var(--color-primary); }
|
||||
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||
|
||||
/* Compact button for highlight cards */
|
||||
.compact-button { background: none; border: none; color: rgb(161 161 170); /* zinc-400 */ 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: rgb(212 212 216); /* zinc-300 */ background: rgba(255, 255, 255, 0.05); }
|
||||
.compact-button { background: none; border: none; color: var(--color-text-secondary); 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: var(--color-text); 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: rgb(161 161 170); /* zinc-400 */ transform: none; }
|
||||
.compact-button:disabled:hover { background: none; color: var(--color-text-secondary); 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-left-radius: 8px; border-top-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-header .compact-button { pointer-events: auto; }
|
||||
@@ -126,13 +126,25 @@
|
||||
.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: 2.25rem 0.75rem; }
|
||||
.highlight-text { margin: 0; padding: 0; font-style: italic; color: rgb(228 228 231); /* zinc-200 */ line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(99, 102, 241, 0.1); border-left: 3px solid rgb(99 102 241); /* indigo-500 */ border-radius: 4px; font-size: 0.875rem; color: rgb(228 228 231); /* zinc-200 */ line-height: 1.5; }
|
||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
|
||||
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
|
||||
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
||||
.highlight-comment-text { flex: 1; min-width: 0; }
|
||||
.highlight-comment-link { color: var(--color-primary); text-decoration: underline; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
.highlight-comment-link:hover { opacity: 0.8; }
|
||||
.highlight-comment-nostr-id { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 0.8em; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.05); padding: 0.125rem 0.375rem; border-radius: 3px; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
.highlight-comment-image { display: block; max-width: 100%; height: auto; margin-top: 0.5rem; border-radius: 6px; border: 1px solid var(--color-border); }
|
||||
|
||||
.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: rgb(161 161 170); /* zinc-400 */ border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
/* Level-colored comment icons */
|
||||
.highlight-item.level-mine .highlight-comment-icon { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-comment-icon { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-comment-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
|
||||
.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: var(--color-text-secondary); 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: rgb(212 212 216); /* zinc-300 */ font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
|
||||
.highlight-author { color: var(--color-text); 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 {
|
||||
@@ -143,11 +155,10 @@
|
||||
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: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(228 228 231); /* zinc-200 */ 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(99, 102, 241, 0.15); color: rgb(255 255 255); /* white */ }
|
||||
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); 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: var(--color-text); 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(99, 102, 241, 0.15); color: var(--color-text); }
|
||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: rgb(239 68 68); /* red-500 */ }
|
||||
.highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Bookmarks and sidebar layout */
|
||||
.bookmarks-container {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -20,7 +20,7 @@
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -41,15 +41,15 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mobile: add borders and rounded corners */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-header-bar {
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
flex-shrink: 0;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -108,8 +108,8 @@
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
@@ -123,7 +123,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn:hover { background: rgb(39 39 42); /* zinc-800 */ color: rgb(255 255 255); /* white */ }
|
||||
.sidebar-header-bar .toggle-sidebar-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
|
||||
.sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmarks-container.collapsed {
|
||||
@@ -136,8 +136,8 @@
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
@@ -151,22 +151,21 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: rgb(63 63 70); /* zinc-700 */ color: rgb(255 255 255); /* white */ }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: var(--color-border); color: var(--color-text); }
|
||||
.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: rgb(99 102 241); /* indigo-500 */ filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.6)); }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue { color: var(--color-primary); filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.6)); }
|
||||
|
||||
.user-info { margin: 0.5rem 0 0 0; color: rgb(161 161 170); /* zinc-400 */ font-size: 0.9rem; font-family: monospace; }
|
||||
.bookmark-count { color: rgb(113 113 122); /* zinc-500 */ font-size: 0.9rem; margin: 0.5rem 0; }
|
||||
.event-link { color: rgb(96 165 250); /* blue-400 */ text-decoration: none; font-weight: 500; }
|
||||
.user-info { margin: 0.5rem 0 0 0; color: var(--color-text-secondary); font-size: 0.9rem; font-family: monospace; }
|
||||
.bookmark-count { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
|
||||
.event-link { color: var(--color-primary); 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: rgb(59 130 246); /* blue-500 */ text-decoration: none; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url { display: block; margin: 0.25rem 0; color: var(--color-primary); 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: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||
|
||||
|
||||
|
||||
@@ -102,13 +102,13 @@ export const prioritizeLocalRelays = (relayUrls: string[]): string[] => {
|
||||
// Parallel request helper
|
||||
import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay'
|
||||
import { Observable, takeUntil, timer } from 'rxjs'
|
||||
import { Filter } from 'nostr-tools/filter'
|
||||
|
||||
export function createParallelReqStreams(
|
||||
relayPool: RelayPool,
|
||||
localRelays: string[],
|
||||
remoteRelays: string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
filter: any,
|
||||
filter: Filter,
|
||||
localTimeoutMs = 1200,
|
||||
remoteTimeoutMs = 6000
|
||||
): { local$: Observable<unknown>; remote$: Observable<unknown> } {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function extractNaddrUris(text: string): string[] {
|
||||
|
||||
/**
|
||||
* Decode a NIP-19 identifier and return a human-readable link
|
||||
* For articles (naddr), returns an internal app link
|
||||
* For articles (naddr) and profiles (npub/nprofile), returns internal app links
|
||||
* For other types, returns an external gateway link
|
||||
*/
|
||||
export function createNostrLink(encoded: string): string {
|
||||
@@ -51,7 +51,13 @@ export function createNostrLink(encoded: string): string {
|
||||
// For articles, link to our internal app route
|
||||
return `/a/${encoded}`
|
||||
case 'npub':
|
||||
case 'nprofile':
|
||||
// For profiles, link to our internal app route
|
||||
return `/p/${encoded}`
|
||||
case 'nprofile': {
|
||||
// For nprofile, convert to npub and link to our internal app route
|
||||
const npub = npubEncode(decoded.data.pubkey)
|
||||
return `/p/${npub}`
|
||||
}
|
||||
case 'note':
|
||||
case 'nevent':
|
||||
return getNostrUrl(encoded)
|
||||
|
||||
63
src/utils/theme.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type Theme = 'dark' | 'light' | 'system'
|
||||
export type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
|
||||
export type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
|
||||
|
||||
let mediaQueryListener: ((e: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
/**
|
||||
* Get the system's current theme preference
|
||||
*/
|
||||
export function getSystemTheme(): 'dark' | 'light' {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme and color variant to the document root element
|
||||
* Handles 'system' theme by listening to OS preference changes
|
||||
*/
|
||||
export function applyTheme(
|
||||
theme: Theme,
|
||||
darkColorTheme: DarkColorTheme = 'midnight',
|
||||
lightColorTheme: LightColorTheme = 'sepia'
|
||||
): void {
|
||||
const root = document.documentElement
|
||||
|
||||
// Remove existing theme classes
|
||||
root.classList.remove('theme-dark', 'theme-light', 'theme-system')
|
||||
// Remove existing color theme classes
|
||||
root.classList.remove('dark-black', 'dark-midnight', 'dark-charcoal')
|
||||
root.classList.remove('light-paper-white', 'light-sepia', 'light-ivory')
|
||||
|
||||
// Clean up previous media query listener if exists
|
||||
if (mediaQueryListener) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', mediaQueryListener)
|
||||
mediaQueryListener = null
|
||||
}
|
||||
|
||||
if (theme === 'system') {
|
||||
root.classList.add('theme-system')
|
||||
|
||||
// Apply color themes for system mode (CSS will handle media query)
|
||||
root.classList.add(`dark-${darkColorTheme}`)
|
||||
root.classList.add(`light-${lightColorTheme}`)
|
||||
|
||||
// Listen for system theme changes
|
||||
mediaQueryListener = (e: MediaQueryListEvent) => {
|
||||
console.log('🎨 System theme changed to:', e.matches ? 'dark' : 'light')
|
||||
// The CSS media query handles the color changes automatically
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', mediaQueryListener)
|
||||
} else {
|
||||
root.classList.add(`theme-${theme}`)
|
||||
// Apply appropriate color theme based on light/dark
|
||||
if (theme === 'dark') {
|
||||
root.classList.add(`dark-${darkColorTheme}`)
|
||||
} else {
|
||||
root.classList.add(`light-${lightColorTheme}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎨 Applied theme:', theme, 'with colors:', { dark: darkColorTheme, light: lightColorTheme })
|
||||
}
|
||||