mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7464a8b505 | ||
|
|
938d79663b | ||
|
|
cc0ad69275 | ||
|
|
810ff060f8 | ||
|
|
5e03ef70a6 | ||
|
|
f05fb29c7b | ||
|
|
e737b1f7f0 | ||
|
|
21a7be2f98 | ||
|
|
4c720aa049 | ||
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f |
152
CHANGELOG.md
152
CHANGELOG.md
@@ -7,6 +7,149 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.4] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Color theme variants for light and dark modes
|
||||
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
|
||||
- Color swatches shown in theme selector instead of text labels
|
||||
- CSS variable tokens and theme classes for consistent theming
|
||||
- Playful empty state message for other users' profiles
|
||||
- Profile links now open within app instead of external portals
|
||||
|
||||
### Changed
|
||||
|
||||
- Default light theme changed to sepia for better readability
|
||||
- Theme setting labels renamed from 'Colors' to 'Theme'
|
||||
- Highlight text now aligns properly with footer icons
|
||||
- Increased spacing between highlight cards for better visual separation
|
||||
- Increased bottom padding in highlight cards
|
||||
- Simplified Me page tab labels for cleaner UI
|
||||
- Highlight marker style applied to active Highlights tab
|
||||
- All profile links open internally instead of via external Nostr portals
|
||||
- Match highlight comment color to highlight level color
|
||||
|
||||
### Fixed
|
||||
|
||||
- Consistent yellow-300 highlight color across all themes
|
||||
- Highlight contrast improved in light themes
|
||||
- Text contrast improved in dark color themes
|
||||
- Darker background for app body in dark themes
|
||||
- Reading progress indicator now uses theme colors
|
||||
- Highlights tab readability improved in light mode with proper background
|
||||
- Empty state text color changed from red to gray for better aesthetics
|
||||
- Replaced 'any' types with proper type definitions for better type safety
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrated entire codebase to semantic token system
|
||||
- Pull-to-refresh components updated to use semantic tokens
|
||||
- Cards, forms, and layout components migrated to semantic tokens
|
||||
- All remaining components converted to semantic token usage
|
||||
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
|
||||
- Theme colors applied to body element for consistent theming
|
||||
|
||||
## [0.6.3] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Ants link to empty writings state for other users
|
||||
|
||||
### Changed
|
||||
|
||||
- Empty state text color from red to gray
|
||||
|
||||
### Fixed
|
||||
|
||||
- Match highlight comment color to highlight level color
|
||||
- Open all profile links within app instead of external portals
|
||||
- Playful empty state message for other users' profiles
|
||||
|
||||
## [0.6.2] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Pull-to-refresh gesture on mobile for all scrollable views
|
||||
- HighlightsPanel (right sidebar) - refresh highlights for current article
|
||||
- Explore page - refresh blog posts from friends
|
||||
- Me pages (all tabs) - refresh user data
|
||||
- BookmarkList (left sidebar) - refresh bookmarks
|
||||
- Touch-only activation using coarse pointer detection
|
||||
- Visual indicator with rotating arrow and contextual messages
|
||||
- Three-dot menu for external URLs in reader (`/r/` path)
|
||||
- Open in browser, Copy URL, Share URL actions
|
||||
- Consistent with article menu functionality
|
||||
|
||||
### Changed
|
||||
|
||||
- Bookmark refresh button moved to footer alongside view mode controls
|
||||
- Last update time now shown in button tooltip
|
||||
- Cleaner UI with all controls consolidated in footer
|
||||
- Unified button styles across left and right sidebars
|
||||
- All sidebar buttons now use IconButton component for consistency
|
||||
- Removed 73 lines of redundant CSS for old button classes
|
||||
- Highlights panel buttons match bookmark sidebar styling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reader content alignment on desktop
|
||||
- Title, summary, metadata, and body text now properly aligned
|
||||
- All reader elements now have consistent 2rem horizontal padding
|
||||
- Mobile layout retains compact padding
|
||||
- Highlight text matching with multiple improvements
|
||||
- Precise normalized-to-original character position mapping
|
||||
- Remove existing highlight marks before applying new ones
|
||||
- Robust validation and error handling for multi-node highlights
|
||||
- Prevent character spacing issues in highlighted text
|
||||
- Add text validation before applying highlights
|
||||
- Eliminate intra-word spaces in highlighted text
|
||||
|
||||
## [0.6.1] - 2025-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Writings tab on `/me` page to display user's published articles
|
||||
- Comprehensive headline styling (h1-h6) with Tailwind typography
|
||||
- List styling for ordered and unordered lists in articles
|
||||
- Blockquote styling with indentation and italics
|
||||
- Vertical padding to blockquotes for better readability
|
||||
- Horizontal padding for reader text content on desktop
|
||||
- Drop-shadows to sidebars for visual depth
|
||||
- MutationObserver for tracking highlight DOM changes
|
||||
|
||||
### Changed
|
||||
|
||||
- Article titles now larger and more prominent
|
||||
- Article summaries now display properly in reader header
|
||||
- Zap splits settings UI with preset buttons and full-width sliders
|
||||
- Sidebars now extend to 100vh height
|
||||
- Blockquote styling simplified to minimal indent and italic
|
||||
- Improved zap splits settings visual design
|
||||
|
||||
### Fixed
|
||||
|
||||
- Horizontal overflow from code blocks and wide content on mobile
|
||||
- Settings view now mobile-friendly with proper width constraints
|
||||
- Long relay URLs no longer cause horizontal overflow on mobile
|
||||
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
|
||||
- Video titles now show filename instead of 'Error Loading Content'
|
||||
- AddBookmarkModal z-index issue fixed using React Portal
|
||||
- Highlight matching for text spanning multiple DOM nodes/inline elements
|
||||
- Highlights now appear as single continuous element across DOM nodes
|
||||
- Highlights display immediately after creation with synchronous render
|
||||
- Scroll-to-highlight functionality restored after DOM updates
|
||||
- Padding gaps around sidebars removed
|
||||
- TypeScript errors in video-meta.ts resolved
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrated entire color system to Tailwind v4 color palette
|
||||
- Migrated all CSS files (sidebar, highlights, cards, forms, reader, etc.) to Tailwind colors
|
||||
- Updated default highlight colors to yellow-400 for markers and yellow-300 for other contexts
|
||||
- Added comprehensive color system documentation (COLOR_SYSTEM.md)
|
||||
- Cleaned up legacy.css removing unused debugging styles
|
||||
|
||||
## [0.6.0] - 2025-10-13
|
||||
|
||||
### Added
|
||||
@@ -1012,7 +1155,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.5.5...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.4...HEAD
|
||||
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
|
||||
[0.5.7]: https://github.com/dergigi/boris/compare/v0.5.6...v0.5.7
|
||||
[0.5.6]: https://github.com/dergigi/boris/compare/v0.5.5...v0.5.6
|
||||
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
|
||||
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# Boris Color System
|
||||
|
||||
All colors now use Tailwind CSS color palette for consistency and maintainability.
|
||||
|
||||
## Semantic Color Aliases (Tailwind Config)
|
||||
|
||||
```javascript
|
||||
'app-bg': '#18181b', // zinc-900 - Main backgrounds
|
||||
'app-bg-elevated': '#27272a', // zinc-800 - Elevated surfaces (cards, modals)
|
||||
'app-bg-subtle': '#1e1e1e', // Custom ~zinc-850 - Subtle backgrounds
|
||||
'app-border': '#3f3f46', // zinc-700 - Primary borders
|
||||
'app-border-subtle': '#52525b', // zinc-600 - Subtle borders
|
||||
'app-text': '#e4e4e7', // zinc-200 - Primary text
|
||||
'app-text-secondary': '#a1a1aa', // zinc-400 - Secondary text
|
||||
'app-text-muted': '#71717a', // zinc-500 - Muted text
|
||||
'primary': '#6366f1', // indigo-500 - Primary accent
|
||||
'primary-hover': '#4f46e5', // indigo-600 - Primary hover state
|
||||
'highlight-mine': '#fde047', // yellow-300 - User highlights
|
||||
'highlight-friends': '#f97316', // orange-500 - Friends highlights
|
||||
'highlight-nostrverse': '#9333ea', // purple-600 - Nostrverse highlights
|
||||
```
|
||||
|
||||
## Highlight Colors (User-Settable)
|
||||
|
||||
Default colors in the color picker:
|
||||
- **Yellow** (default): `#fde047` - yellow-300
|
||||
- **Orange**: `#f97316` - orange-500
|
||||
- **Pink**: `#ec4899` - pink-500
|
||||
- **Green**: `#22c55e` - green-500
|
||||
- **Blue**: `#3b82f6` - blue-500
|
||||
- **Purple**: `#9333ea` - purple-600
|
||||
|
||||
## Common Color Mappings
|
||||
|
||||
| Old Hex | Tailwind Color | Usage |
|
||||
|-----------|----------------|-------|
|
||||
| `#18181b` | zinc-900 | Main app background |
|
||||
| `#1a1a1a` | zinc-900 | Component backgrounds |
|
||||
| `#1e1e1e` | ~zinc-850 | Code blocks, subtle surfaces |
|
||||
| `#252525` | zinc-800 | Hover states |
|
||||
| `#27272a` | zinc-800 | Elevated surfaces |
|
||||
| `#2a2a2a` | zinc-800 | Buttons, inputs |
|
||||
| `#333` | zinc-700 | Primary borders |
|
||||
| `#3f3f46` | zinc-700 | Component borders |
|
||||
| `#444` | zinc-600 | Subtle borders |
|
||||
| `#52525b` | zinc-600 | Input borders |
|
||||
| `#555` | zinc-500 | Hover borders |
|
||||
| `#666` | zinc-500 | Muted text |
|
||||
| `#71717a` | zinc-500 | Secondary text |
|
||||
| `#888` | zinc-400 | Secondary text |
|
||||
| `#999` | zinc-400 | Muted labels |
|
||||
| `#a1a1aa` | zinc-400 | Placeholder text |
|
||||
| `#aaa` | zinc-300 | Light text |
|
||||
| `#ccc` | zinc-300 | Primary text on dark |
|
||||
| `#ddd` | zinc-200 | Bright text |
|
||||
| `#e4e4e7` | zinc-200 | Primary text |
|
||||
| `#646cff` | indigo-500 | Primary accent |
|
||||
| `#535bf2` | indigo-600 | Primary hover |
|
||||
| `#fde047` | yellow-300 | Default highlight (brighter) |
|
||||
| `#f97316` | orange-500 | Friends highlights |
|
||||
| `#9333ea` | purple-600 | Nostrverse highlights |
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### In CSS
|
||||
Use Tailwind utilities whenever possible:
|
||||
```css
|
||||
.example {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
}
|
||||
```
|
||||
|
||||
### In TSX
|
||||
Use Tailwind classes:
|
||||
```tsx
|
||||
<div className="bg-zinc-900 border border-zinc-700 text-zinc-200">
|
||||
```
|
||||
|
||||
Or semantic aliases:
|
||||
```tsx
|
||||
<div className="bg-app-bg border border-app-border text-app-text">
|
||||
```
|
||||
|
||||
### CSS Variables (User-Settable)
|
||||
For colors that users can customize:
|
||||
```css
|
||||
background: var(--highlight-color-mine, #fde047);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All hex colors are now Tailwind palette colors
|
||||
- CSS variables remain for user-customizable colors
|
||||
- Semantic aliases provide easier maintenance
|
||||
- RGB format in CSS allows for opacity control
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.5",
|
||||
"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",
|
||||
@@ -2263,6 +2264,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.5",
|
||||
"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",
|
||||
|
||||
27
src/App.tsx
27
src/App.tsx
@@ -70,6 +70,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
@@ -110,6 +119,24 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface AuthorCardProps {
|
||||
authorPubkey: string
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
|
||||
const navigate = useNavigate()
|
||||
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||
|
||||
const getAuthorName = () => {
|
||||
@@ -20,8 +24,19 @@ const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
|
||||
const authorImage = profile?.picture || profile?.image
|
||||
const authorBio = profile?.about
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
const npub = nip19.npubEncode(authorPubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="author-card">
|
||||
<div
|
||||
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
|
||||
onClick={handleClick}
|
||||
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<div className="author-card-avatar">
|
||||
{authorImage ? (
|
||||
<img src={authorImage} alt={getAuthorName()} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
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 { formatDistanceToNow } from 'date-fns'
|
||||
@@ -10,6 +10,8 @@ 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'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -48,6 +50,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
@@ -124,7 +139,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
@@ -137,37 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<div className="refresh-section" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
{lastFetchTime && (
|
||||
<span>
|
||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -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'
|
||||
@@ -10,7 +11,7 @@ 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
|
||||
@@ -190,16 +191,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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -6,7 +7,7 @@ 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
|
||||
@@ -93,15 +94,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 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||
@@ -25,18 +26,25 @@ interface BookmarksProps {
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
// Check for highlight navigation state
|
||||
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showExplore = location.pathname.startsWith('/explore')
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
|
||||
// Extract tab from explore routes
|
||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
@@ -45,12 +53,28 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
location.pathname === '/me/archive' ? 'archive' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Track previous location for going back from settings/me/explore
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings && !showMe && !showExplore) {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings, showMe, showExplore])
|
||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -107,6 +131,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
// 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,
|
||||
bookmarksLoading,
|
||||
@@ -212,6 +249,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -267,11 +305,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
relayPool ? <Explore relayPool={relayPool} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
|
||||
@@ -98,8 +98,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
@@ -145,15 +147,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu) {
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu])
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
@@ -280,6 +285,38 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
|
||||
const handleOpenExternalUrl = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
@@ -533,6 +570,47 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleExternalMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article menu for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && articleLinks && (
|
||||
<div className="article-menu-container">
|
||||
|
||||
@@ -1,26 +1,49 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
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 { Highlight } from '../types/highlights'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
type TabType = 'writings' | 'highlights'
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool, 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)
|
||||
|
||||
// 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)
|
||||
@@ -28,14 +51,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
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)
|
||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||
if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cachedPosts)
|
||||
}
|
||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
|
||||
setHighlights(cachedHighlights)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
@@ -43,15 +70,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// When local contacts are available, kick off early posts fetch
|
||||
// Store followed pubkeys for highlight classification
|
||||
setFollowedPubkeys(partial)
|
||||
// When local contacts are available, kick off early fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const partialArray = Array.from(partial)
|
||||
|
||||
// Fetch blog posts
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(partial),
|
||||
partialArray,
|
||||
relayUrls,
|
||||
(post) => {
|
||||
// merge into UI and cache as we stream
|
||||
setBlogPosts((prev) => {
|
||||
const exists = prev.some(p => p.event.id === post.event.id)
|
||||
if (exists) return prev
|
||||
@@ -65,7 +96,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
// Ensure union of streamed + final is displayed
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of all) byId.set(post.event.id, post)
|
||||
@@ -78,22 +108,52 @@ 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!')
|
||||
setError('You are not following anyone yet. Follow some people to see their content!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store final followed pubkeys
|
||||
setFollowedPubkeys(contacts)
|
||||
|
||||
// After full contacts, do a final pass for completeness
|
||||
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 [posts, userHighlights] = await Promise.all([
|
||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||
])
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
if (posts.length === 0 && userHighlights.length === 0) {
|
||||
setError('No content found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts((prev) => {
|
||||
@@ -107,16 +167,32 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
|
||||
setHighlights((prev) => {
|
||||
const byId = new Map(prev.map(h => [h.id, h]))
|
||||
for (const highlight of userHighlights) 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
|
||||
})
|
||||
} 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)
|
||||
setError('Failed to load content. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount, blogPosts.length])
|
||||
loadData()
|
||||
}, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
@@ -132,6 +208,96 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
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
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'writings':
|
||||
return 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>
|
||||
) : (
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'highlights':
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No highlights yet. Your friends should start highlighting content!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{classifiedHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
const hasData = highlights.length > 0 || blogPosts.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">
|
||||
@@ -144,35 +310,52 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<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-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
Discover highlights and blog posts from your friends and others
|
||||
</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)}
|
||||
/>
|
||||
))}
|
||||
{!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>
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => 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={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/HighlightCitation.tsx
Normal file
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,7 @@ import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -208,13 +210,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 +227,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -236,7 +238,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
icon: faHighlighter,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
@@ -318,7 +320,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,8 +343,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
{highlight.content}
|
||||
</blockquote>
|
||||
|
||||
<HighlightCitation
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
/>
|
||||
|
||||
{highlight.comment && (
|
||||
<div className="highlight-comment">
|
||||
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||
{highlight.comment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef } 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 HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -57,12 +59,24 @@ 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
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
React.useEffect(() => {
|
||||
@@ -127,7 +141,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list">
|
||||
<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}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
onClick={onToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
||||
</button>
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ interface IconButtonProps {
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = ''
|
||||
className = '',
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
style={{ width: size, height: size, ...style }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -21,18 +21,26 @@ 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 { getProfileUrl } from '../config/nostrGateways'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
@@ -40,6 +48,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -50,8 +60,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to view your data')
|
||||
if (!viewingPubkey) {
|
||||
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -60,39 +70,48 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash
|
||||
const cached = getCachedMeData(activeAccount.pubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights, read articles, and writings
|
||||
const [userHighlights, userReadArticles, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, activeAccount.pubkey),
|
||||
fetchReadArticlesWithData(relayPool, activeAccount.pubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [activeAccount.pubkey], RELAYS)
|
||||
// Fetch highlights and writings (public data)
|
||||
const [userHighlights, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, viewingPubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setReadArticles(userReadArticles)
|
||||
setWritings(userWritings)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
// Only fetch private data for own profile
|
||||
if (isOwnProfile && activeAccount) {
|
||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} else {
|
||||
setBookmarks([])
|
||||
setReadArticles([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
@@ -102,14 +121,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount])
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted
|
||||
if (activeAccount) {
|
||||
updateCachedHighlights(activeAccount.pubkey, updated)
|
||||
// Update cache when highlight is deleted (own profile only)
|
||||
if (isOwnProfile && viewingPubkey) {
|
||||
updateCachedHighlights(viewingPubkey, updated)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
@@ -194,8 +221,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No highlights yet. Start highlighting content to see them here!</p>
|
||||
<div className="explore-empty">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Start highlighting content to see them here!'
|
||||
: 'No highlights yet. You should shame them on nostr!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
@@ -212,7 +243,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
|
||||
case 'reading-list':
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<div className="explore-empty">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -263,7 +294,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
|
||||
case 'archive':
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<div className="explore-empty">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -280,8 +311,25 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
|
||||
case 'writings':
|
||||
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">
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No articles written yet. Publish your first article to see it here!'
|
||||
: (
|
||||
<>
|
||||
No articles written. You can find other stuff from this user using{' '}
|
||||
<a
|
||||
href={viewingPubkey ? getProfileUrl(nip19.npubEncode(viewingPubkey)) : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'rgb(99 102 241)', textDecoration: 'underline' }}
|
||||
>
|
||||
ants
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -301,9 +349,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<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-header">
|
||||
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
@@ -315,38 +372,38 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
<span className="tab-count">({highlights.length})</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Reading List</span>
|
||||
<span className="tab-count">({allIndividualBookmarks.length})</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
<span className="tab-count">({readArticles.length})</span>
|
||||
</button>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate('/me/writings')}
|
||||
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
<span className="tab-count">({writings.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
52
src/components/PullToRefreshIndicator.tsx
Normal file
52
src/components/PullToRefreshIndicator.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -70,8 +70,12 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, default to collapsed (icon only). On desktop, always show details.
|
||||
const showDetails = !isMobile || isExpanded
|
||||
|
||||
// On mobile when collapsed, make it circular like the highlight button
|
||||
const isCollapsedOnMobile = isMobile && !isExpanded
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||
@@ -85,25 +89,75 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
) : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: isMobile ? 'pointer' : 'default' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
left: '32px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: showDetails ? '0.5rem' : '0',
|
||||
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
|
||||
width: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
height: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
backgroundColor: 'rgba(39, 39, 42, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgb(82, 82, 91)',
|
||||
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
|
||||
color: 'rgb(228, 228, 231)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
cursor: isMobile ? 'pointer' : 'default',
|
||||
opacity: isMobile && !showOnMobile ? 0 : 1,
|
||||
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
|
||||
transition: 'all 0.3s ease',
|
||||
userSelect: 'none',
|
||||
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<div className="relay-status-icon">
|
||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||
</div>
|
||||
{showDetails && (
|
||||
<>
|
||||
<div className="relay-status-text">
|
||||
<div
|
||||
className="relay-status-text"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.125rem'
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span className="relay-status-subtitle">No relays connected</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
No relays connected
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
||||
title="Nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
||||
title="Friends highlights"
|
||||
aria-label="Toggle friends highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Toggle my highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
107
src/components/Settings/ThemeSettings.tsx
Normal file
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
|
||||
@@ -31,6 +31,7 @@ interface ThreePaneLayoutProps {
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
showProfile?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -89,6 +90,9 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Me content
|
||||
me?: React.ReactNode
|
||||
|
||||
// Optional Profile content
|
||||
profile?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
@@ -221,8 +225,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<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'
|
||||
@@ -241,8 +245,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && (
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<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'
|
||||
@@ -320,6 +324,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.me}
|
||||
</>
|
||||
) : props.showProfile && props.profile ? (
|
||||
// Render Profile inside the main pane to keep side panels
|
||||
<>
|
||||
{props.profile}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
|
||||
@@ -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])
|
||||
|
||||
153
src/hooks/usePullToRefresh.ts
Normal file
153
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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') {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@import './styles/components/reader.css';
|
||||
@import './styles/components/settings.css';
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/components/pull-to-refresh.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
@import './styles/utils/legacy.css';
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
|
||||
export * from './highlights/fetchFromAuthors'
|
||||
|
||||
|
||||
79
src/services/highlights/fetchFromAuthors.ts
Normal file
79
src/services/highlights/fetchFromAuthors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
|
||||
/**
|
||||
* 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 relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,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(
|
||||
|
||||
@@ -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,12 +1,14 @@
|
||||
/* Profile UI fragments */
|
||||
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
|
||||
.author-card-clickable:hover { border-color: var(--color-primary); background: var(--color-bg-elevated); transform: translateY(-1px); }
|
||||
.author-card-clickable:active { transform: translateY(0); }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); }
|
||||
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.author-card-avatar svg { font-size: 2.5rem; }
|
||||
.author-card-content { flex: 1; min-width: 0; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: rgb(228 228 231); /* zinc-200 */ margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: rgb(161 161 170); /* zinc-400 */ line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-card-container {
|
||||
@@ -26,4 +28,3 @@
|
||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
53
src/styles/components/pull-to-refresh.css
Normal file
53
src/styles/components/pull-to-refresh.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* Pull-to-refresh indicator styles */
|
||||
.pull-to-refresh-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pull-to-refresh-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pull-to-refresh-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg-elevated);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Container needs relative positioning for absolute indicator */
|
||||
.pull-to-refresh-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions during pull */
|
||||
.pull-to-refresh-container.is-pulling {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -169,7 +169,12 @@
|
||||
|
||||
/* Desktop: increase horizontal padding for text content */
|
||||
@media (min-width: 769px) {
|
||||
.reader-html, .reader-markdown {
|
||||
.reader-header,
|
||||
.reader-summary-below-image,
|
||||
.reader-html,
|
||||
.reader-markdown,
|
||||
.article-menu-container,
|
||||
.mark-as-read-container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
@@ -177,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; }
|
||||
@@ -223,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; }
|
||||
@@ -232,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 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,64 +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; }
|
||||
.highlight-level-toggles .level-toggle-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-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover { background: rgb(39 39 42); /* zinc-800 */ color: rgb(255 255 255); /* white */ }
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.refresh-highlights-btn:disabled:hover { background: transparent; color: rgb(228 228 231); /* zinc-200 */ }
|
||||
|
||||
.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; }
|
||||
@@ -158,13 +126,20 @@
|
||||
.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; }
|
||||
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
||||
|
||||
.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 {
|
||||
@@ -175,11 +150,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 */ }
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ export function tryMarkInTextNodes(
|
||||
const before = text.substring(0, actualIndex)
|
||||
const match = text.substring(actualIndex, actualIndex + searchText.length)
|
||||
const after = text.substring(actualIndex + searchText.length)
|
||||
|
||||
// Validate the match makes sense (not just whitespace or empty)
|
||||
if (!match || match.trim().length === 0) {
|
||||
console.warn('Invalid match (empty or whitespace only)')
|
||||
continue
|
||||
}
|
||||
|
||||
const mark = createMarkElement(highlight, match, highlightStyle)
|
||||
|
||||
replaceTextWithMark(textNode, before, after, mark)
|
||||
@@ -117,13 +124,81 @@ function tryMultiNodeMatch(
|
||||
|
||||
// Map normalized index back to original if needed
|
||||
let startIndex = matchIndex
|
||||
let endIndex = matchIndex + searchText.length
|
||||
let endIndex = matchIndex + searchFor.length
|
||||
|
||||
if (useNormalized) {
|
||||
// This is a simplified mapping - for normalized matches we approximate
|
||||
const ratio = combinedText.length / searchIn.length
|
||||
startIndex = Math.floor(matchIndex * ratio)
|
||||
endIndex = Math.min(combinedText.length, startIndex + searchText.length)
|
||||
// Build precise mapping by walking original text and advancing a normalized counter
|
||||
const endPos = matchIndex + searchFor.length // end position in normalized text (exclusive)
|
||||
let normPos = 0
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
let startNode: Text | null = null
|
||||
let startOffset = 0
|
||||
let endNode: Text | null = null
|
||||
let endOffset = 0
|
||||
|
||||
for (const nodeInfo of nodeMap) {
|
||||
const text = nodeInfo.originalText
|
||||
let prevWasWs = false
|
||||
for (let i = 0; i < text.length && (!foundStart || !foundEnd); i++) {
|
||||
const ch = text[i]
|
||||
const isWs = /\s/.test(ch)
|
||||
|
||||
if (isWs) {
|
||||
if (!prevWasWs) {
|
||||
// This whitespace sequence counts as one in normalized text
|
||||
if (!foundStart && normPos === matchIndex) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = i
|
||||
foundStart = true
|
||||
}
|
||||
if (!foundEnd && normPos === endPos) {
|
||||
endNode = nodeInfo.node
|
||||
endOffset = i
|
||||
foundEnd = true
|
||||
}
|
||||
normPos++
|
||||
}
|
||||
prevWasWs = true
|
||||
} else {
|
||||
if (!foundStart && normPos === matchIndex) {
|
||||
startNode = nodeInfo.node
|
||||
startOffset = i
|
||||
foundStart = true
|
||||
}
|
||||
normPos++
|
||||
if (!foundEnd && normPos === endPos) {
|
||||
endNode = nodeInfo.node
|
||||
endOffset = i + 1 // end after this character
|
||||
foundEnd = true
|
||||
}
|
||||
prevWasWs = false
|
||||
}
|
||||
}
|
||||
if (foundStart && foundEnd) break
|
||||
}
|
||||
|
||||
if (!foundStart || !foundEnd || !startNode || !endNode) {
|
||||
console.warn('Failed to map normalized positions to nodes', { matchIndex, endPos, normPos })
|
||||
return false
|
||||
}
|
||||
|
||||
// Set indices relative to combinedText by reconstructing start/end using nodeMap
|
||||
const startNodeInfo = nodeMap.find(n => n.node === startNode)!
|
||||
const endNodeInfo = nodeMap.find(n => n.node === endNode)!
|
||||
startIndex = startNodeInfo.start + startOffset
|
||||
endIndex = endNodeInfo.start + endOffset
|
||||
|
||||
if (startIndex < 0 || endIndex <= startIndex || endIndex > combinedText.length) {
|
||||
console.warn('Mapped indices invalid', { startIndex, endIndex, combinedTextLength: combinedText.length })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate indices
|
||||
if (startIndex < 0 || endIndex > combinedText.length || startIndex >= endIndex) {
|
||||
console.warn('Invalid highlight range:', { startIndex, endIndex, combinedTextLength: combinedText.length })
|
||||
return false
|
||||
}
|
||||
|
||||
// Find which nodes contain the match
|
||||
@@ -133,37 +208,82 @@ function tryMultiNodeMatch(
|
||||
if (startIndex < nodeInfo.end && endIndex > nodeInfo.start) {
|
||||
const nodeStart = Math.max(0, startIndex - nodeInfo.start)
|
||||
const nodeEnd = Math.min(nodeInfo.originalText.length, endIndex - nodeInfo.start)
|
||||
|
||||
// Validate node offsets
|
||||
if (nodeStart < 0 || nodeEnd > nodeInfo.originalText.length || nodeStart > nodeEnd) {
|
||||
console.warn('Invalid node offsets:', { nodeStart, nodeEnd, nodeLength: nodeInfo.originalText.length })
|
||||
continue
|
||||
}
|
||||
|
||||
affectedNodes.push({ node: nodeInfo.node, startOffset: nodeStart, endOffset: nodeEnd })
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedNodes.length === 0) return false
|
||||
if (affectedNodes.length === 0) {
|
||||
console.warn('No affected nodes found for highlight')
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a Range to wrap the entire selection in a single mark element
|
||||
const range = document.createRange()
|
||||
const firstNode = affectedNodes[0]
|
||||
const lastNode = affectedNodes[affectedNodes.length - 1]
|
||||
|
||||
range.setStart(firstNode.node, firstNode.startOffset)
|
||||
range.setEnd(lastNode.node, lastNode.endOffset)
|
||||
|
||||
// Extract the content from the range
|
||||
const extractedContent = range.extractContents()
|
||||
|
||||
// Create a single mark element
|
||||
const mark = document.createElement('mark')
|
||||
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
||||
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
||||
mark.setAttribute('data-highlight-id', highlight.id)
|
||||
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
|
||||
// Append the extracted content to the mark
|
||||
mark.appendChild(extractedContent)
|
||||
|
||||
// Insert the mark at the range position
|
||||
range.insertNode(mark)
|
||||
|
||||
return true
|
||||
try {
|
||||
// Create a Range to wrap the entire selection in a single mark element
|
||||
const range = document.createRange()
|
||||
const firstNode = affectedNodes[0]
|
||||
const lastNode = affectedNodes[affectedNodes.length - 1]
|
||||
|
||||
range.setStart(firstNode.node, firstNode.startOffset)
|
||||
range.setEnd(lastNode.node, lastNode.endOffset)
|
||||
|
||||
// Verify the range isn't collapsed or invalid
|
||||
if (range.collapsed) {
|
||||
console.warn('Range is collapsed, skipping highlight')
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the text content before extraction to verify it matches
|
||||
const rangeText = range.toString()
|
||||
const normalizedRangeText = normalizeWhitespace(rangeText)
|
||||
const normalizedSearchText = normalizeWhitespace(searchText)
|
||||
|
||||
// Validate that the extracted text matches what we're searching for
|
||||
if (!rangeText.includes(searchText) &&
|
||||
!normalizedRangeText.includes(normalizedSearchText) &&
|
||||
normalizedRangeText !== normalizedSearchText) {
|
||||
console.warn('Range text does not match search text:', {
|
||||
rangeText: rangeText.substring(0, 100),
|
||||
searchText: searchText.substring(0, 100),
|
||||
rangeLength: rangeText.length,
|
||||
searchLength: searchText.length
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the content from the range
|
||||
const extractedContent = range.extractContents()
|
||||
|
||||
// Verify we actually extracted something
|
||||
if (!extractedContent || extractedContent.childNodes.length === 0) {
|
||||
console.warn('No content extracted from range')
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a single mark element
|
||||
const mark = document.createElement('mark')
|
||||
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
||||
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
||||
mark.setAttribute('data-highlight-id', highlight.id)
|
||||
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
|
||||
// Append the extracted content to the mark
|
||||
mark.appendChild(extractedContent)
|
||||
|
||||
// Insert the mark at the range position
|
||||
range.insertNode(mark)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error applying multi-node highlight:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ export function applyHighlightsToHTML(
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = html
|
||||
|
||||
// CRITICAL: Remove any existing highlight marks to start with clean HTML
|
||||
// This prevents old broken highlights from corrupting the new rendering
|
||||
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
|
||||
existingMarks.forEach(mark => {
|
||||
// Replace the mark with its text content
|
||||
const textNode = document.createTextNode(mark.textContent || '')
|
||||
mark.parentNode?.replaceChild(textNode, mark)
|
||||
})
|
||||
|
||||
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
|
||||
|
||||
let appliedCount = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
|
||||
@@ -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
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user