Compare commits

...

94 Commits

Author SHA1 Message Date
Gigi
0c104f95d9 chore: bump version to 0.6.7 2025-10-14 15:44:25 +02:00
Gigi
acbefae501 Merge pull request #7 from dergigi/loading-placeholders
Remove loading spinners in favor of skeleton placeholders
2025-10-14 15:43:55 +02:00
Gigi
2ce83ef88a fix: use React.ReactElement instead of JSX.Element type
Change return type from JSX.Element to React.ReactElement to fix ESLint no-undef error
2025-10-14 15:42:54 +02:00
Gigi
dab3412ecd refactor: remove loading spinner from explore page
Remove incremental loading spinner as pull-to-refresh indicator already provides visual feedback for refresh state. Initial loading continues to use skeleton placeholders.
2025-10-14 15:41:34 +02:00
Gigi
988b3164d2 docs: add loading placeholder guideline to fontawesome rule 2025-10-14 15:40:32 +02:00
Gigi
4161053821 fix: Me - handle undefined viewingPubkey in skeleton loading state 2025-10-14 15:37:03 +02:00
Gigi
60054c4865 feat: ContentPanel - replace spinner with skeleton loaders 2025-10-14 15:36:57 +02:00
Gigi
f4e8aa576c feat: HighlightsPanel - replace spinner with skeleton loaders 2025-10-14 15:35:28 +02:00
Gigi
30a495bcd1 feat: Me - replace spinner with skeleton loaders 2025-10-14 15:35:23 +02:00
Gigi
6dde0eb220 feat: Explore - replace spinner with skeleton loaders 2025-10-14 15:35:17 +02:00
Gigi
90d8ef3423 feat: BookmarkList - replace spinner with skeleton loaders 2025-10-14 15:35:10 +02:00
Gigi
f626a8ec9b feat: add skeleton components and theme provider 2025-10-14 15:35:03 +02:00
Gigi
a7c7535236 feat: add react-loading-skeleton package 2025-10-14 14:53:40 +02:00
Gigi
5b0f2821d6 feat: parse and render nostr identifiers in highlight comments
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments
- Render profiles as clickable links with shortened pubkeys (@abc12345...)
- Render blog posts (kind:30023) as clickable article links
- Shorten other event identifiers to prevent layout breaks
- Add monospace styling for shortened nostr IDs
- Maintains DRY principles by extending existing CommentContent component
2025-10-14 12:58:01 +02:00
Gigi
be045557b8 feat: add nostrverse content and visibility filters to explore page
- Add visibility filter state and UI (mine/friends/nostrverse toggles)
- Create nostrverseService to fetch public content from the entire network
- Fetch both friends content and nostrverse content in parallel
- Apply visibility filters to both highlights and blog posts
- Filter buttons match highlight sidebar styling
- Users can now discover content beyond their friend network
- Maintains performance with sensible limits (50 posts, 100 highlights)
2025-10-14 12:09:12 +02:00
Gigi
a0c92182f9 docs: update CHANGELOG.md for v0.6.6 release 2025-10-14 12:03:53 +02:00
Gigi
f33d33556b chore: bump version to 0.6.6 2025-10-14 12:02:20 +02:00
Gigi
9aff889835 fix: correct profile fetching implementation and dependencies
- Use eventStore.add() directly instead of mapEventsToStore
- Use tap() operator to process and store events as they arrive
- Add eventStore and settings to useEffect dependencies
- Fixes TypeScript and ESLint errors
2025-10-14 12:00:52 +02:00
Gigi
420df1fbdd feat: fetch and cache author profiles in explore page
- Create profileService to fetch and cache kind:0 metadata
- Fetch profiles for all blog post authors on explore page
- Store profiles in event store for immediate access
- Rebroadcast profiles to local/all relays per user settings
- Fixes 'Unknown' author names by ensuring profiles are cached
- Uses mapEventsToStore to automatically populate event store
2025-10-14 11:59:28 +02:00
Gigi
2946ede5ac fix: filter out blog posts with far-future publication dates
- Add filteredBlogPosts useMemo to exclude posts with unreasonable dates
- Allow 1 day into future for clock skew tolerance
- Prevents spam/error posts with dates like '53585 years from now'
- Uses published_at tag or event.created_at as fallback
2025-10-14 11:57:04 +02:00
Gigi
6ec28e6a9d feat: render links and images in highlight comments
- Parse URLs in comment text and render as clickable links
- Detect image URLs and render inline images
- Add CommentContent component for smart URL rendering
- Style links with primary color and underline
- Style images with border and rounded corners
- Images lazy-load and respect max-width
- Links open in new tab with noopener/noreferrer
2025-10-14 11:54:41 +02:00
Gigi
820daa489e feat: hide citation in highlights sidebar for current article
- Add showCitation prop to HighlightItem (defaults to true)
- Set showCitation={false} in HighlightsPanel
- Reduces redundancy since all sidebar highlights are from same article
- Citation still shown in Explore and Me pages where context is needed
2025-10-14 11:52:29 +02:00
Gigi
b162596013 fix: prevent layout breaks from long URLs in highlight comments
- Add word-wrap, overflow-wrap, and word-break to comments
- Set min-width: 0 to allow flex child to shrink
- Prevents horizontal overflow from long URLs or text
- Maintains readable layout with line wrapping
2025-10-14 11:51:16 +02:00
Gigi
e581237e16 docs: update CHANGELOG.md for v0.6.5 release 2025-10-14 11:49:43 +02:00
Gigi
fcc329cc7c chore: bump version to 0.6.5 2025-10-14 11:48:25 +02:00
Gigi
c9544e0fd2 feat: open highlight in native app when clicking timestamp
- Click timestamp to open highlight event in user's native Nostr app
- Reuses existing native link logic (nostr:nevent)
- Simple and DRY implementation
2025-10-14 11:40:30 +02:00
Gigi
d7906cfb95 fix: use article text color for highlight counter
- Change highlight indicator to var(--color-text)
- Matches main article text color for better readability
- More prominent and consistent with content
2025-10-14 11:38:59 +02:00
Gigi
13cd6aeb11 fix: use consistent text color for highlight counter
- Change highlight indicator color to var(--color-text-secondary)
- Matches reading time color for visual consistency
- Better readability in both light and dark modes
2025-10-14 11:38:19 +02:00
Gigi
d4821d18fb fix: improve highlight counter readability in light mode
- Make highlight indicator color theme-aware
- Only force white text color in overlay context (with hero image)
- Let CSS handle text color in regular header for better light mode support
- Fixes hard-to-read white text on light backgrounds
2025-10-14 11:37:13 +02:00
Gigi
b86bf48382 deps: add @fortawesome/free-regular-svg-icons
- Install FontAwesome regular icons package
- Required for faComments icon in HighlightItem component
2025-10-14 11:34:52 +02:00
Gigi
c595f94567 style: switch to regular comments icon
- Use faComments from @fortawesome/free-regular-svg-icons
- Replace solid faComment with regular faComments
- Provides lighter, outlined icon style per FontAwesome regular variant
2025-10-14 11:33:02 +02:00
Gigi
82058c0ef4 style: remove extra indent from highlight comments
- Remove margin-left from comment container
- Icon alone provides sufficient visual indentation
- Cleaner alignment with highlight content
2025-10-14 11:32:16 +02:00
Gigi
a1f3424b38 style: remove background from highlight comments
- Remove background color from comment boxes
- Keep only colored icon for visual distinction
- Cleaner, simpler appearance
2025-10-14 11:31:32 +02:00
Gigi
14ab749ef1 style: color comment icon by highlight level and remove border
- Remove border-left from highlight comments
- Color comment icon based on highlight level (mine/friends/nostrverse)
- Remove opacity from icon for clearer color representation
- Yellow for mine, orange for friends, purple for nostrverse
2025-10-14 11:30:07 +02:00
Gigi
61dd4b2089 style: flip comment icon horizontally
- Add flip='horizontal' prop to comment icon
- Better visual alignment with comment text
2025-10-14 11:29:26 +02:00
Gigi
fb2fe1cc63 feat(highlights): add comment icon to highlight comments
- Import and use faComment icon
- Display comment icon next to comment text
- Style with flexbox layout and slight opacity
- Icon aligns to top with comment text
- Visual indicator that distinguishes comments from highlights
2025-10-14 11:28:51 +02:00
Gigi
720f12ce1c feat(explore): color highlights by author level (mine/friends/nostrverse)
- Import and use classifyHighlights utility
- Track followed pubkeys from contact fetching
- Classify highlights using same logic as highlights sidebar
- Pass classified highlights with level to HighlightItem
- Highlights now show colored borders based on author:
  - Yellow for own highlights (mine)
  - Orange for friends' highlights
  - Purple for nostrverse highlights
- Keep code DRY by reusing existing classification logic
2025-10-14 11:26:19 +02:00
Gigi
423ebb403f fix: add retry mechanism for scroll-to-highlight in article content
- Replace single 100ms delay with retry mechanism
- Try up to 20 times (2 seconds total) to find highlight mark element
- Fixes timing issue when content is still loading from explore page
- Mark elements need time to be rendered after article loads
- Retry every 100ms until element is found or max attempts reached
- Improves reliability of highlight scrolling from external navigation
2025-10-14 11:24:35 +02:00
Gigi
c90fb66bb8 feat(explore): scroll to highlight and open sidebar on click
- Pass highlight ID and openHighlights flag via navigation state
- Add useEffect in Bookmarks to handle navigation state
- Open highlights sidebar when clicking highlight from explore
- Auto-scroll to selected highlight (handled by useHighlightInteractions)
- Clear state after handling to prevent re-triggering
- Enhanced UX for discovering and reading highlighted content
2025-10-14 11:22:34 +02:00
Gigi
188de7ab1d feat(explore): clicking highlight opens source article in reader
- Add handleHighlightClick handler in explore page
- For nostr-native articles: convert eventReference to naddr and navigate to /a/{naddr}
- For web URLs: navigate to /r/{encoded-url}
- Pass onHighlightClick to HighlightItem component
- Users can now click highlights to read the full source content
2025-10-14 11:20:17 +02:00
Gigi
0b1cf267a7 refactor: reorder explore tabs - highlights first, writings second
- Change default tab to highlights on /explore
- Reorder tab buttons in UI (highlights, then writings)
- Update route: /explore shows highlights, /explore/writings shows writings
- Update route detection logic in Bookmarks component
- Highlights are now the primary content on explore page
2025-10-14 11:08:42 +02:00
Gigi
19f68612a5 style: use highlighter icon instead of server icon in highlight items
- Replace faServer with faHighlighter in bottom left indicator
- Update import statement
- Keep plane icon for offline/local-only highlights
- More semantically appropriate icon for highlight items
2025-10-14 11:08:03 +02:00
Gigi
1b1600d6f2 fix: make explore tabs actually span full width
- Set width: 100% and max-width: 100% on tabs
- Add justify-content: flex-start for left alignment
- Tabs now properly extend to match grid width
2025-10-14 11:07:00 +02:00
Gigi
ce67c19ece style: make explore tabs extend to grid width
- Add specific styling for tabs in explore-header
- Tabs now span full width to match the grid below
- Maintain left alignment for consistency with grid layout
2025-10-14 11:04:59 +02:00
Gigi
f754ce3cfe fix: extract author pubkey directly from p tag in highlights
- Pass full highlight object to HighlightCitation component
- Extract author pubkey from p tag as fallback if highlight.author not set
- Add debug logging to track author resolution
- Fix TypeScript type errors with proper guards
- Ensure author profile resolution works correctly
2025-10-14 11:04:24 +02:00
Gigi
19a86525cb debug: add logging to track author pubkey and profile resolution 2025-10-14 11:03:30 +02:00
Gigi
29213ceb1c feat(highlights): add citation attribution to highlight items
- Create HighlightCitation component to show source attribution
- For nostr-native content: display as '— Author, Article Title'
- For web URLs: display hostname as '— domain.com'
- Automatically resolves article titles from event references
- Resolves author names from profile data
- Add styling for citation line below highlight text
- Keep code DRY by reusing existing articleTitleResolver service
2025-10-14 11:01:02 +02:00
Gigi
d25a9b1735 refactor: use existing HighlightItem component for consistency
- Remove custom HighlightCard component
- Use the same HighlightItem component used throughout the app
- Remove custom highlight card styles
- Keep code DRY and UI consistent
2025-10-14 10:57:00 +02:00
Gigi
0f03706166 style: add proper styling for highlight cards in explore grid
- Add gradient header with quote icon for visual distinction
- Style highlight text and comments for card view
- Ensure cards work well in grid layout
- Add mobile responsive styling for highlight cards
2025-10-14 10:56:18 +02:00
Gigi
b1f79e3844 fix: resolve type errors and remove unused code
- Remove unused handleHighlightDelete function
- Fix all TypeScript type errors by using correct Highlight properties
- Use created_at instead of timestamp
- Use content instead of text
- Use urlReference instead of url
- All lint checks and type checks now pass
2025-10-14 10:54:51 +02:00
Gigi
243d9b17ef chore(explore): update subtitle text
- Change subtitle to mention both highlights and blog posts
- Include 'friends and others' to reflect broader content scope
2025-10-14 10:50:51 +02:00
Gigi
50a6cf6499 fix(explore): remove max-width constraint for grid layout
- Remove me-tab-content wrapper that was limiting width to 600px
- Allow explore-grid to use full width for proper multi-column layout
- Blog posts now display in proper grid format
2025-10-14 10:49:36 +02:00
Gigi
8f7991e971 refactor(explore): use grid layout for highlights tab
- Change highlights from list view to grid/card view
- Match the visual style of the writings tab
- Keep tab structure at the top
- Explore page now shows more content at once
2025-10-14 10:46:15 +02:00
Gigi
0aba54bd23 feat(explore): add highlights tab to explore page
- Create fetchHighlightsFromAuthors function for fetching highlights from multiple contacts
- Add tab structure to Explore page (Writings and Highlights tabs)
- Update explore cache to handle both blog posts and highlights
- Add /explore/highlights route
- Keep UI consistent with /me page tab structure
- Implement pull-to-refresh for both tabs
- Add proper caching and streaming for highlights
2025-10-14 10:45:23 +02:00
Gigi
23833b2cff docs: update CHANGELOG.md for v0.6.4 release 2025-10-14 10:40:40 +02:00
Gigi
d5076ff53e chore: bump version to 0.6.4 2025-10-14 10:39:44 +02:00
Gigi
41e452be1e Merge pull request #6 from dergigi/themes
Add comprehensive theme support with light/dark modes
2025-10-14 10:38:54 +02:00
Gigi
f267df8f60 feat(ui): increase bottom padding in highlight cards
- Increase bottom padding from 0.75rem to 2.5rem
- Reduces gap between cards from 1rem to 0.75rem (user edit)
- Provides more breathing room between text and footer
- Improves readability and visual balance
2025-10-14 10:35:34 +02:00
Gigi
7426c9d1fc feat(ui): increase spacing between highlight cards
- Increase gap from 0.75rem to 1rem in highlights list
- Provides better visual breathing room between cards
- Improves overall readability and card separation
2025-10-14 10:34:48 +02:00
Gigi
93d0c1052b feat(ui): align highlight text with footer icons
- Add 1.25rem left padding to highlight text content
- Add 1.25rem left margin to highlight comments
- Text now starts roughly where the fa-server icon ends
- Improves visual alignment and readability of highlight cards
2025-10-14 10:34:07 +02:00
Gigi
6537650757 feat(ui): apply highlight marker style to active Highlights tab
- Use actual highlight visual treatment (marker style) on tab
- Text remains in semantic color (--color-text) for readability
- Background uses 35% highlight color blend with glow effect
- Hover state intensifies to 50% for better interaction feedback
- Creates consistent visual language between tabs and content highlights
2025-10-14 10:32:31 +02:00
Gigi
a95f9b522b refactor(ui): simplify Me page tab labels
- Remove count numbers from all tabs (cleaner UI)
- Rename "Reading List" to "Bookmarks" (clearer naming)
- Keep tab names: Highlights, Bookmarks, Archive, Writings
- Reduces visual clutter and improves readability
2025-10-14 10:31:35 +02:00
Gigi
47d1335842 fix(ui): add background to Highlights tab for better contrast
- Add --color-bg-elevated background to active Highlights tab
- Improves contrast of yellow highlight color in light mode
- Creates visual separation while maintaining highlight color identity
- Keeps yellow text and border for consistent highlight theming
2025-10-14 10:30:27 +02:00
Gigi
168095e133 fix(ui): improve Highlights tab readability in light mode
- Use semantic text color (--color-text) for tab label in active state
- Keep highlight color for icon and bottom border as visual accent
- Ensures text is always readable regardless of theme
- Fixes contrast issues on /me page Highlights tab
2025-10-14 10:29:11 +02:00
Gigi
5c7b413a8d fix(theme): use consistent yellow-300 highlight color across all themes
- Revert to yellow-300 (#fde047) for all light and dark themes
- Use consistent Tailwind palette: yellow-300, orange-500, purple-600
- Previous darker colors were causing inconsistency with design system
- Ensures highlights use the same color values across all theme variants
2025-10-14 10:23:55 +02:00
Gigi
bca6458e44 fix(theme): improve highlight contrast in light themes
- Use darker yellow (yellow-400 instead of yellow-300) for better visibility
- Use darker orange (orange-600 instead of orange-500)
- Sepia theme uses even darker highlights (yellow-500, red-600)
- Ensures text and icons remain visible on highlighted text
- Applies to all light theme variants and system mode
2025-10-14 10:12:22 +02:00
Gigi
ebdfa3b5a3 fix(lint): replace 'any' types with proper type definitions
- Add DarkColorTheme and LightColorTheme type definitions
- Replace 'as any' with proper type assertions
- All eslint and TypeScript checks now pass
2025-10-14 10:10:57 +02:00
Gigi
17480dddbf fix(theme): improve text contrast in dark color themes
- Add text color definitions to all dark theme variants
- Ensure bright text (zinc-200) for readability on dark backgrounds
- Update --color-bg-subtle to be darker for better hierarchy
- Fixes low contrast issue where text was barely readable
2025-10-14 10:06:43 +02:00
Gigi
2a422fbeb9 fix(theme): use darker background for app body
- Change body background from --color-bg to --color-bg-subtle
- Creates visual depth and hierarchy between app bg and content
- Content panels now stand out more against the background
2025-10-14 10:05:10 +02:00
Gigi
22961ee479 fix(theme): update reading progress indicator to use theme colors
- Replace hard-coded dark background with --color-bg-elevated
- Use --color-border for progress track
- Use --color-primary for progress bar
- Use --color-text-muted for percentage text
- Indicator now adapts to light/dark themes
2025-10-14 10:02:43 +02:00
Gigi
18db905974 refactor(theme): rename labels from 'Colors' to 'Theme'
- Change 'Dark Colors' to 'Dark Theme'
- Change 'Light Colors' to 'Light Theme'
- More consistent and clearer labeling
2025-10-14 10:00:48 +02:00
Gigi
689963c041 refactor(theme): change default light theme to sepia
- Update default from paper-white to sepia for warmer reading
- Midnight remains default for dark mode
- Sepia provides warm, eye-friendly tones for light mode
2025-10-14 10:00:21 +02:00
Gigi
3f8869fd75 refactor(theme): show color swatches instead of text labels
- Replace text buttons with color swatches for theme selection
- Use actual background colors to preview each theme
- Add border for white swatch to make it visible
- Tooltips show theme names on hover
2025-10-14 09:58:47 +02:00
Gigi
129aced1a2 feat(theme): add color theme variants for light and dark modes
- Add darkColorTheme: black, midnight (default), charcoal
- Add lightColorTheme: paper-white (default), sepia, ivory
- Extend UserSettings with color theme fields
- Update ThemeSettings UI to show color options
- Add CSS variables for all color theme variants
- Sepia and Ivory have warm, reading-friendly palettes
- Black offers true black for OLED screens
- All color themes sync via Nostr (NIP-78)
2025-10-14 09:39:13 +02:00
Gigi
69febf4356 refactor(theme): remove localStorage, use only Nostr for persistence
- Remove localStorage.setItem/getItem from theme.ts
- Simplify early boot script to just default to system theme
- Theme now loads purely from NIP-78 settings
- Prevents race conditions between localStorage and Nostr settings
2025-10-14 09:34:54 +02:00
Gigi
65051c9c1f fix(theme): apply theme colors to body element
- Add background and text color to body
- Ensures page background changes with theme
2025-10-14 09:25:13 +02:00
Gigi
ba8229d464 refactor(css): update pull-to-refresh to use semantic tokens 2025-10-14 09:24:49 +02:00
Gigi
9251b017d4 refactor(css): migrate remaining components to semantic tokens
- Update icon-button.css, profile.css, me.css to use tokens
- Migrate reader.css to semantic colors for light theme
- Update toast.css with theme-aware colors
- All major UI components now support theme switching
2025-10-14 09:24:31 +02:00
Gigi
1ae76031f3 refactor(css): migrate cards/forms/layout to semantic tokens
- Replace hard-coded colors with CSS variables in cards.css
- Update forms.css, settings.css, modals.css with tokens
- Migrate sidebar.css and highlights.css to use semantic colors
- Update layout/app.css and base/global.css
- Enables proper light/dark theme switching
2025-10-14 09:13:42 +02:00
Gigi
994d834a0b feat(theme): add CSS variable tokens and theme classes
- Define semantic color tokens (--color-bg, --color-text, etc.)
- Add .theme-dark, .theme-light, .theme-system CSS classes
- Create theme.ts utility for theme application
- Add early boot theme script to prevent FOUC
- Support system preference with live updates
2025-10-14 09:11:38 +02:00
Gigi
67a4e17055 feat: add ants link to empty writings state for other users
- Update empty writings message for other users' profiles
- Show 'No articles written. You can find other stuff from this user using ants.'
- Link 'ants' to the ants.sh profile page for that user
- Keep original message for own profile
2025-10-14 01:42:29 +02:00
Gigi
1e82e3f240 fix: change empty state text color from red to gray
- Create new .explore-empty class with muted gray color (zinc-400)
- Keep .explore-error red for actual errors
- Update all empty state divs in Me.tsx to use .explore-empty
- Empty states (no highlights, no bookmarks, etc.) no longer appear as errors
2025-10-14 01:38:35 +02:00
Gigi
f973c75ff5 feat: match highlight comment color to highlight level color
- Remove hardcoded blue color from highlight comments
- Apply level-specific colors (mine/friends/nostrverse) to comment borders
- Use color-mix for subtle background tint matching highlight color
- Comment styling now respects user's highlight color settings
2025-10-14 01:36:59 +02:00
Gigi
28316a71c5 feat: open all profile links within app instead of external portals
- Update nostrUriResolver to return internal /p/:npub links for npub/nprofile
- Replace external profile links with React Router Link components
- Update ResolvedMention, LargeView, and CardView components
- Convert nprofile to npub before routing
- Keep note/nevent links as external (no internal viewer yet)
2025-10-14 01:32:28 +02:00
Gigi
cfc12e2d78 feat: add playful empty state message for other users' profiles
- Show 'You should shame them on nostr!' when viewing profiles with no highlights
- Keep original helpful message for own profile
- Conditional based on isOwnProfile flag
2025-10-14 01:29:54 +02:00
Gigi
7464a8b505 chore: bump version to 0.6.3 2025-10-14 01:27:22 +02:00
Gigi
938d79663b fix: remove unused isRefreshing parameter from PullToRefreshIndicator
- Keep prop in interface for backward compatibility
- Don't destructure unused parameter to satisfy linter
- All lint checks and type checks now pass
2025-10-14 01:13:02 +02:00
Gigi
cc0ad69275 chore: remove COLOR_SYSTEM.md documentation file 2025-10-14 01:11:32 +02:00
Gigi
810ff060f8 feat: make relay status indicator circular FAB on mobile
- Default to collapsed (icon only) on mobile
- Expand to show details when tapped on mobile
- Circular 56px FAB when collapsed, matching highlight button style
- Desktop always shows expanded with details
- Hide on scroll via showOnMobile prop (matches sidepanel buttons)
2025-10-14 01:11:14 +02:00
Gigi
5e03ef70a6 style: improve relay status indicator text layout
- Make subtitle text smaller (0.75rem) with reduced opacity
- Display text in column layout with proper line spacing
- Subtitle now appears on second line below title
- Apply consistent styling to offline and flight mode subtitles
2025-10-14 01:08:28 +02:00
Gigi
f05fb29c7b refactor: remove refresh spinner and text from pull-to-refresh
- Remove 'Refreshing...' text from indicator
- Remove spinner from pull-to-refresh (button already spins)
- Only show indicator when actively pulling, not when refreshing
- Simplify logic and improve UX consistency
2025-10-14 01:05:22 +02:00
Gigi
e737b1f7f0 fix: position relay status indicator in bottom-left corner
- Add fixed positioning (bottom-left) to match highlight button (bottom-right)
- Add modern styling with semi-transparent background, blur, and shadow
- Ensure proper visibility on mobile with smooth transitions
- Maintain responsive behavior for expanded/collapsed states
2025-10-14 01:04:18 +02:00
Gigi
21a7be2f98 fix: unify highlight visibility button styling across app
- Remove blue primary variant from highlight filter buttons
- Use opacity (1.0 active, 0.4 inactive) instead of variant change
- Update settings to use IconButton like sidebar (DRY)
- Consistent styling: ghost variant + opacity + custom colors
- Settings buttons now match sidebar buttons exactly
2025-10-14 01:02:46 +02:00
Gigi
4c720aa049 feat: add public profile pages at /p/:npub
- Make AuthorCard clickable to navigate to user profiles
- Add /p/:npub and /p/:npub/writings routes
- Reuse Me component for public profiles with pubkey prop
- Show highlights and writings tabs for any user
- Hide private tabs (reading-list, archive) on public profiles
- Public profiles show only public data (highlights, writings)
- Private data (bookmarks, read articles) only visible on own profile
- Add clickable author card hover styles with indigo border
- Decode npub to pubkey for profile viewing
- DRY: Single Me component serves both /me and /p/:npub routes
2025-10-14 01:01:10 +02:00
62 changed files with 2532 additions and 591 deletions

View File

@@ -5,4 +5,4 @@ alwaysApply: false
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations. We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
Never write "Loading" - always show a spinner, and just a spinner. Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).

View File

@@ -7,6 +7,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.6] - 2025-10-14
### Added
- Profile fetching and caching for explore page
- Automatically fetches kind:0 metadata for all blog post authors
- Stores profiles in event store for instant access across app
- Rebroadcasts profiles to local/all relays per user settings
- Fixes "Unknown" author names by proactively caching profiles
- Rich content rendering in highlight comments
- URLs automatically detected and rendered as clickable links
- Image URLs (jpg, png, gif, webp, etc.) render as inline images
- Images lazy-load with responsive sizing and rounded borders
- Links open in new tab with security attributes
### Changed
- Hide citation in highlights sidebar when viewing article
- Citation removed from sidebar since all highlights are from same source
- Citation still shown in Explore and Me pages where context is needed
- Reduces visual clutter and redundant information
### Fixed
- Blog posts with far-future publication dates no longer appear in explore
- Filter excludes posts with dates more than 1 day in future
- Allows 1 day tolerance for clock skew between systems
- Prevents spam posts with unrealistic dates (e.g., "53585 years from now")
- Layout breaks from long URLs in highlight comments
- Added word-wrap, overflow-wrap, and word-break CSS properties
- Set min-width: 0 to allow flex child to shrink properly
- Long URLs now wrap correctly instead of causing horizontal overflow
- Profile fetching implementation
- Use eventStore.add() directly for immediate profile storage
- Use tap() operator to process events as they arrive
- Correct TypeScript types and dependency array
## [0.6.5] - 2025-10-14
### Added
- Highlights tab on `/explore` page
- View highlights from friends and followed users
- Tab structure matching `/me` and profile pages
- Grid layout for highlights with cards
- Highlights shown first, writings second
- Clicking highlight opens source article and scrolls to position
- Opens highlights sidebar automatically when clicking from explore
- Citation attribution on highlight items
- Shows "— Author, Article Title" for Nostr-native content
- Shows "— domain.com" for web URLs
- Resolves author profiles and article titles automatically
- Comment icon (fa-comments) for highlights with comments
- Flipped horizontally for better visual alignment
- Colored based on highlight level (mine/friends/nostrverse)
- No background or extra indent for cleaner look
- Click timestamp to open highlight in native Nostr app
- Uses nostr:nevent links for native app integration
### Changed
- Highlight counter text color now matches article text (var(--color-text))
- Better readability in both light and dark modes
- Only forces white in overlay context (hero images)
- Highlight level colors applied to explore page highlights
- Yellow for own highlights
- Orange for friends' highlights
- Purple for nostrverse highlights
- Explore page tab order: Highlights first, Writings second
- Explore page tabs now extend full width to match content grid
### Fixed
- Highlight counter readability in light mode
- Theme-aware text color instead of hardcoded blue
- Consistent with reading time indicator styling
- Scroll-to-highlight reliability in article view
- Added retry mechanism for asynchronous content loading
- Attempts to find highlight element up to 20 times over 2 seconds
- Author attribution in highlight citations
- Correctly extracts author pubkey from highlight's p tag
- No more "Unknown" author names
- Explore page grid layout
- Removed max-width constraint blocking full-width display
- Tabs and content now properly aligned
### Style
- Replaced server icon with highlighter icon in highlight items
- Switch from solid comment icon to outlined comments icon (fa-regular)
- Removed background from highlight comments for cleaner appearance
- Removed extra left margin from comments (icon provides sufficient indent)
- Comment icon colored by highlight level with no opacity
### Dependencies
- Added @fortawesome/free-regular-svg-icons package for outlined icons
## [0.6.4] - 2025-10-14
### Added
- Color theme variants for light and dark modes
- Sepia, Classic (white/black), Rose, Sky, Mint, and Lavender themes
- Color swatches shown in theme selector instead of text labels
- CSS variable tokens and theme classes for consistent theming
- Playful empty state message for other users' profiles
- Profile links now open within app instead of external portals
### Changed
- Default light theme changed to sepia for better readability
- Theme setting labels renamed from 'Colors' to 'Theme'
- Highlight text now aligns properly with footer icons
- Increased spacing between highlight cards for better visual separation
- Increased bottom padding in highlight cards
- Simplified Me page tab labels for cleaner UI
- Highlight marker style applied to active Highlights tab
- All profile links open internally instead of via external Nostr portals
- Match highlight comment color to highlight level color
### Fixed
- Consistent yellow-300 highlight color across all themes
- Highlight contrast improved in light themes
- Text contrast improved in dark color themes
- Darker background for app body in dark themes
- Reading progress indicator now uses theme colors
- Highlights tab readability improved in light mode with proper background
- Empty state text color changed from red to gray for better aesthetics
- Replaced 'any' types with proper type definitions for better type safety
### Refactored
- Migrated entire codebase to semantic token system
- Pull-to-refresh components updated to use semantic tokens
- Cards, forms, and layout components migrated to semantic tokens
- All remaining components converted to semantic token usage
- Removed localStorage for theme persistence, using only Nostr (NIP-78)
- Theme colors applied to body element for consistent theming
## [0.6.3] - 2025-10-14
### Added
- Ants link to empty writings state for other users
### Changed
- Empty state text color from red to gray
### Fixed
- Match highlight comment color to highlight level color
- Open all profile links within app instead of external portals
- Playful empty state message for other users' profiles
## [0.6.2] - 2025-01-27 ## [0.6.2] - 2025-01-27
### Added ### Added
@@ -1096,7 +1253,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD [Unreleased]: https://github.com/dergigi/boris/compare/v0.6.6...HEAD
[0.6.6]: https://github.com/dergigi/boris/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/dergigi/boris/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2 [0.6.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.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.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0

View File

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

View File

@@ -25,6 +25,11 @@
<meta name="twitter:url" content="https://read.withboris.com/" /> <meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" /> <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." /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

27
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "boris", "name": "boris",
"version": "0.5.7", "version": "0.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "boris", "name": "boris",
"version": "0.5.7", "version": "0.6.6",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
@@ -25,6 +26,7 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-router-dom": "^7.9.3", "react-router-dom": "^7.9.3",
@@ -2263,6 +2265,18 @@
"node": ">=6" "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": { "node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
@@ -9807,6 +9821,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-loading-skeleton": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "boris", "name": "boris",
"version": "0.6.2", "version": "0.6.7",
"description": "A minimal nostr client for bookmark management", "description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/", "homepage": "https://read.withboris.com/",
"type": "module", "type": "module",
@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
@@ -28,6 +29,7 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-router-dom": "^7.9.3", "react-router-dom": "^7.9.3",

View File

@@ -13,6 +13,7 @@ import Toast from './components/Toast'
import { useToast } from './hooks/useToast' import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus' import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays' import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -70,6 +71,15 @@ function AppRoutes({
/> />
} }
/> />
<Route
path="/explore/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route <Route
path="/me" path="/me"
element={<Navigate to="/me/highlights" replace />} element={<Navigate to="/me/highlights" replace />}
@@ -110,6 +120,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 />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes> </Routes>
) )
@@ -244,22 +272,24 @@ function App() {
} }
return ( return (
<EventStoreProvider eventStore={eventStore}> <SkeletonThemeProvider>
<AccountsProvider manager={accountManager}> <EventStoreProvider eventStore={eventStore}>
<BrowserRouter> <AccountsProvider manager={accountManager}>
<div className="min-h-screen p-0 max-w-none m-0 relative"> <BrowserRouter>
<AppRoutes relayPool={relayPool} showToast={showToast} /> <div className="min-h-screen p-0 max-w-none m-0 relative">
</div> <AppRoutes relayPool={relayPool} showToast={showToast} />
</BrowserRouter> </div>
{toastMessage && ( </BrowserRouter>
<Toast {toastMessage && (
message={toastMessage} <Toast
type={toastType} message={toastMessage}
onClose={clearToast} type={toastType}
/> onClose={clearToast}
)} />
</AccountsProvider> )}
</EventStoreProvider> </AccountsProvider>
</EventStoreProvider>
</SkeletonThemeProvider>
) )
} }

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons' import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -12,6 +12,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from '../hooks/usePullToRefresh' import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator' import PullToRefreshIndicator from './PullToRefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
interface BookmarkListProps { interface BookmarkListProps {
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -128,8 +129,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{allIndividualBookmarks.length === 0 ? ( {allIndividualBookmarks.length === 0 ? (
loading ? ( loading ? (
<div className="loading"> <div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<FontAwesomeIcon icon={faSpinner} spin /> <div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div> </div>
) : ( ) : (
<div className="empty-state"> <div className="empty-state">

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons' import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
@@ -10,7 +11,7 @@ import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache' import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview' import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways' import { getEventUrl } from '../../config/nostrGateways'
interface CardViewProps { interface CardViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -190,16 +191,14 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-footer"> <div className="bookmark-footer">
<div className="bookmark-meta-minimal"> <div className="bookmark-meta-minimal">
<a <Link
href={getProfileUrl(authorNpub)} to={`/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal" className="author-link-minimal"
title="Open author in search" title="Open author profile"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{getAuthorDisplayName()} {getAuthorDisplayName()}
</a> </Link>
</div> </div>
{/* CTA removed */} {/* CTA removed */}
</div> </div>

View File

@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils' import { formatDate } from '../../utils/bookmarkUtils'
@@ -6,7 +7,7 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared' import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache' import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways' import { getEventUrl } from '../../config/nostrGateways'
interface LargeViewProps { interface LargeViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -93,15 +94,13 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-footer"> <div className="large-footer">
<span className="large-author"> <span className="large-author">
<a <Link
href={getProfileUrl(authorNpub)} to={`/p/${authorNpub}`}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal" className="author-link-minimal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{getAuthorDisplayName()} {getAuthorDisplayName()}
</a> </Link>
</span> </span>
{eventNevent && ( {eventNevent && (

View File

@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks' import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings' import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader' import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader' import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -25,18 +26,25 @@ interface BookmarksProps {
} }
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => { const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>() const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const previousLocationRef = useRef<string>() 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/') const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3)) ? decodeURIComponent(location.pathname.slice(3))
: undefined : undefined
const showSettings = location.pathname === '/settings' const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore' const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me') 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 // Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' : 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/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights' 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(() => { useEffect(() => {
if (!showSettings && !showMe && !showExplore) { if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname previousLocationRef.current = location.pathname
} }
}, [location.pathname, showSettings, showMe, showExplore]) }, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager() const accountManager = Hooks.useAccountManager()
@@ -107,6 +131,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]) }, [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 { const {
bookmarks, bookmarks,
bookmarksLoading, bookmarksLoading,
@@ -212,6 +249,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showSettings={showSettings} showSettings={showSettings}
showExplore={showExplore} showExplore={showExplore}
showMe={showMe} showMe={showMe}
showProfile={showProfile}
bookmarks={bookmarks} bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading} bookmarksLoading={bookmarksLoading}
viewMode={viewMode} viewMode={viewMode}
@@ -267,11 +305,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onCreateHighlight={handleCreateHighlight} onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)} hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? ( explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined} ) : undefined}
me={showMe ? ( me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined} ) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined} toastMessage={toastMessage ?? undefined}
toastType={toastType} toastType={toastType}
onClearToast={clearToast} onClearToast={clearToast}

View File

@@ -7,6 +7,7 @@ import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css' import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons' import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways' import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
@@ -406,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (loading) { if (loading) {
return ( return (
<div className="reader loading"> <div className="reader" aria-busy="true">
<div className="loading-spinner"> <ContentSkeleton />
<FontAwesomeIcon icon={faSpinner} spin />
</div>
</div> </div>
) )
} }

View File

@@ -1,30 +1,65 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons' import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard' import 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 { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator' import PullToRefreshIndicator from './PullToRefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
interface ExploreProps { interface ExploreProps {
relayPool: RelayPool relayPool: RelayPool
eventStore: IEventStore
settings?: UserSettings
activeTab?: TabType
} }
const Explore: React.FC<ExploreProps> = ({ relayPool }) => { type TabType = 'writings' | 'highlights'
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([]) const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([])
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null) const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse !== false,
friends: settings?.defaultHighlightVisibilityFriends !== false,
mine: settings?.defaultHighlightVisibilityMine !== false
})
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
useEffect(() => { useEffect(() => {
const loadBlogPosts = async () => { const loadData = async () => {
if (!activeAccount) { if (!activeAccount) {
setError('Please log in to explore content from your friends') setError('Please log in to explore content from your friends')
setLoading(false) setLoading(false)
@@ -32,14 +67,18 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
} }
try { try {
// show spinner but keep existing posts // show spinner but keep existing data
setLoading(true) setLoading(true)
setError(null) setError(null)
// Seed from in-memory cache if available to avoid empty flash // Seed from in-memory cache if available to avoid empty flash
const cached = getCachedPosts(activeAccount.pubkey) const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cached && cached.length > 0 && blogPosts.length === 0) { if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) {
setBlogPosts(cached) setBlogPosts(cachedPosts)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) {
setHighlights(cachedHighlights)
} }
// Fetch the user's contacts (friends) // Fetch the user's contacts (friends)
@@ -47,15 +86,19 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
relayPool, relayPool,
activeAccount.pubkey, activeAccount.pubkey,
(partial) => { (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) { if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const partialArray = Array.from(partial)
// Fetch blog posts
fetchBlogPostsFromAuthors( fetchBlogPostsFromAuthors(
relayPool, relayPool,
Array.from(partial), partialArray,
relayUrls, relayUrls,
(post) => { (post) => {
// merge into UI and cache as we stream
setBlogPosts((prev) => { setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id) const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev if (exists) return prev
@@ -69,7 +112,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
} }
).then((all) => { ).then((all) => {
// Ensure union of streamed + final is displayed
setBlogPosts((prev) => { setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p])) const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post) for (const post of all) byId.set(post.event.id, post)
@@ -82,45 +124,103 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return merged 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) { 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) setLoading(false)
return return
} }
// After full contacts, do a final pass for completeness // Store final followed pubkeys
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) setFollowedPubkeys(contacts)
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
if (posts.length === 0) { // Fetch both friends content and nostrverse content in parallel
setError('No blog posts found from your friends yet') const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
} }
setBlogPosts((prev) => { if (uniquePosts.length === 0 && uniqueHighlights.length === 0) {
const byId = new Map(prev.map(p => [p.event.id, p])) setError('No content found yet')
for (const post of posts) byId.set(post.event.id, post) }
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at setBlogPosts(uniquePosts)
const timeB = b.published || b.event.created_at setCachedPosts(activeAccount.pubkey, uniquePosts)
return timeB - timeA
}) setHighlights(uniqueHighlights)
setCachedPosts(activeAccount.pubkey, merged) setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
return merged
})
} catch (err) { } catch (err) {
console.error('Failed to load blog posts:', err) console.error('Failed to load data:', err)
setError('Failed to load blog posts. Please try again.') setError('Failed to load content. Please try again.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
loadBlogPosts() loadData()
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger]) }, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings])
// Pull-to-refresh // Pull-to-refresh
const pullToRefreshState = usePullToRefresh(exploreContainerRef, { const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
@@ -144,6 +244,137 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return `/a/${naddr}` 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 and apply visibility filters
const classifiedHighlights = useMemo(() => {
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine' && !visibility.mine) return false
if (h.level === 'friends' && !visibility.friends) return false
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
return true
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Filter blog posts by future dates and visibility
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
if (publishedTime > maxFutureTime) return false
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const isNostrverse = !isMine && !isFriend
if (isMine && !visibility.mine) return false
if (isFriend && !visibility.friends) return false
if (isNostrverse && !visibility.nostrverse) return false
return true
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
case 'writings':
return filteredBlogPosts.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">
{filteredBlogPosts.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" aria-busy="true">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
</div>
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
)}
</div>
</div>
)
}
if (error) { if (error) {
return ( return (
<div className="explore-container"> <div className="explore-container">
@@ -172,28 +403,69 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
Explore Explore
</h1> </h1>
<p className="explore-subtitle"> <p className="explore-subtitle">
Discover blog posts from your friends on Nostr Discover highlights and blog posts from your friends and others
</p> </p>
</div>
{loading && ( <div className="me-tabs">
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}> <button
<FontAwesomeIcon icon={faSpinner} spin /> 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>
)}
<div className="explore-grid"> {/* Visibility filters */}
{blogPosts.map((post) => ( <div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<BlogPostCard <IconButton
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`} icon={faNetworkWired}
post={post} onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
href={getPostUrl(post)} title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
style={{
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: visibility.nostrverse ? 1 : 0.4
}}
/> />
))} <IconButton
{!loading && blogPosts.length === 0 && ( icon={faUserGroup}
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}> onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
<p>No blog posts found yet.</p> title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
</div> ariaLabel="Toggle friends content"
)} variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: visibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: visibility.mine ? 1 : 0.4
}}
/>
</div>
</div> </div>
{renderTabContent()}
</div> </div>
) )
} }

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core' import { Models, IEventStore } from 'applesauce-core'
@@ -15,6 +16,159 @@ import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways' import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton' import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
} catch {
return false
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
const parts = text.split(urlPattern)
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
if (isImageUrl(part)) {
return (
<img
key={index}
src={part}
alt="Comment attachment"
className="highlight-comment-image"
loading="lazy"
/>
)
} else {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
)
}
}
return <span key={index}>{part}</span>
})}
</>
)
}
interface HighlightWithLevel extends Highlight { interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse' level?: 'mine' | 'friends' | 'nostrverse'
@@ -29,6 +183,7 @@ interface HighlightItemProps {
eventStore?: IEventStore | null eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void onHighlightDelete?: (highlightId: string) => void
showCitation?: boolean
} }
export const HighlightItem: React.FC<HighlightItemProps> = ({ export const HighlightItem: React.FC<HighlightItemProps> = ({
@@ -39,7 +194,8 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool, relayPool,
eventStore, eventStore,
onHighlightUpdate, onHighlightUpdate,
onHighlightDelete onHighlightDelete,
showCitation = true
}) => { }) => {
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
@@ -208,13 +364,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Always show relay list, use plane icon for local-only // Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator 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) { if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url => const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '') url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
) )
return { return {
icon: isLocalOrOffline ? faPlane : faServer, icon: isLocalOrOffline ? faPlane : faHighlighter,
tooltip: relayNames.join('\n'), tooltip: relayNames.join('\n'),
spin: false spin: false
} }
@@ -225,7 +381,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '') url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
) )
return { return {
icon: faServer, icon: faHighlighter,
tooltip: relayNames.join('\n'), tooltip: relayNames.join('\n'),
spin: false spin: false
} }
@@ -236,7 +392,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
url.replace(/^wss?:\/\//, '').replace(/\/$/, '') url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
) )
return { return {
icon: faServer, icon: faHighlighter,
tooltip: relayNames.join('\n'), tooltip: relayNames.join('\n'),
spin: false spin: false
} }
@@ -318,7 +474,10 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<CompactButton <CompactButton
className="highlight-timestamp" className="highlight-timestamp"
title={new Date(highlight.created_at * 1000).toLocaleString()} 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)} {formatDateCompact(highlight.created_at)}
</CompactButton> </CompactButton>
@@ -338,9 +497,19 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{highlight.content} {highlight.content}
</blockquote> </blockquote>
{showCitation && (
<HighlightCitation
highlight={highlight}
relayPool={relayPool}
/>
)}
{highlight.comment && ( {highlight.comment && (
<div className="highlight-comment"> <div className="highlight-comment">
{highlight.comment} <FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
<div className="highlight-comment-text">
<CommentContent text={highlight.comment} />
</div>
</div> </div>
)} )}

View File

@@ -11,6 +11,7 @@ import PullToRefreshIndicator from './PullToRefreshIndicator'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { HighlightSkeleton } from './Skeletons'
export interface HighlightVisibility { export interface HighlightVisibility {
nostrverse: boolean nostrverse: boolean
@@ -127,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
/> />
{loading && filteredHighlights.length === 0 ? ( {loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading"> <div className="highlights-list" aria-busy="true">
<FontAwesomeIcon icon={faHighlighter} spin /> {Array.from({ length: 4 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div> </div>
) : filteredHighlights.length === 0 ? ( ) : filteredHighlights.length === 0 ? (
<div className="highlights-empty"> <div className="highlights-empty">
@@ -162,6 +165,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
eventStore={eventStore} eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate} onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete} onHighlightDelete={handleHighlightDelete}
showCitation={false}
/> />
))} ))}
</div> </div>

View File

@@ -40,8 +40,11 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})} })}
title="Toggle nostrverse highlights" title="Toggle nostrverse highlights"
ariaLabel="Toggle nostrverse highlights" ariaLabel="Toggle nostrverse highlights"
variant={highlightVisibility.nostrverse ? 'primary' : 'ghost'} variant="ghost"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }} style={{
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/> />
<IconButton <IconButton
icon={faUserGroup} icon={faUserGroup}
@@ -51,9 +54,12 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})} })}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"} title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
ariaLabel="Toggle friends highlights" ariaLabel="Toggle friends highlights"
variant={highlightVisibility.friends ? 'primary' : 'ghost'} variant="ghost"
disabled={!currentUserPubkey} disabled={!currentUserPubkey}
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }} style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/> />
<IconButton <IconButton
icon={faUser} icon={faUser}
@@ -63,9 +69,12 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
})} })}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"} title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
ariaLabel="Toggle my highlights" ariaLabel="Toggle my highlights"
variant={highlightVisibility.mine ? 'primary' : 'ghost'} variant="ghost"
disabled={!currentUserPubkey} disabled={!currentUserPubkey}
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }} style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/> />
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons' import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -23,18 +24,24 @@ import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../ser
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from '../hooks/usePullToRefresh' import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator' import PullToRefreshIndicator from './PullToRefreshIndicator'
import { getProfileUrl } from '../config/nostrGateways'
interface MeProps { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
activeTab?: TabType activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
} }
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings' 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 activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights') 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 [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([]) const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
@@ -54,8 +61,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
if (!activeAccount) { if (!viewingPubkey) {
setError('Please log in to view your data') setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
setLoading(false) setLoading(false)
return return
} }
@@ -64,39 +71,48 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
setLoading(true) setLoading(true)
setError(null) setError(null)
// Seed from cache if available to avoid empty flash // Seed from cache if available to avoid empty flash (own profile only)
const cached = getCachedMeData(activeAccount.pubkey) if (isOwnProfile) {
if (cached) { const cached = getCachedMeData(viewingPubkey)
setHighlights(cached.highlights) if (cached) {
setBookmarks(cached.bookmarks) setHighlights(cached.highlights)
setReadArticles(cached.readArticles) setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
} }
// Fetch highlights, read articles, and writings // Fetch highlights and writings (public data)
const [userHighlights, userReadArticles, userWritings] = await Promise.all([ const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, activeAccount.pubkey), fetchHighlights(relayPool, viewingPubkey),
fetchReadArticlesWithData(relayPool, activeAccount.pubkey), fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
fetchBlogPostsFromAuthors(relayPool, [activeAccount.pubkey], RELAYS)
]) ])
setHighlights(userHighlights) setHighlights(userHighlights)
setReadArticles(userReadArticles)
setWritings(userWritings) setWritings(userWritings)
// Fetch bookmarks using callback pattern // Only fetch private data for own profile
let fetchedBookmarks: Bookmark[] = [] if (isOwnProfile && activeAccount) {
try { const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { setReadArticles(userReadArticles)
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data // Fetch bookmarks using callback pattern
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles) 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) { } catch (err) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
setError('Failed to load data. Please try again.') setError('Failed to load data. Please try again.')
@@ -106,7 +122,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
} }
loadData() loadData()
}, [relayPool, activeAccount, refreshTrigger]) }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh // Pull-to-refresh
const pullToRefreshState = usePullToRefresh(meContainerRef, { const pullToRefreshState = usePullToRefresh(meContainerRef, {
@@ -119,9 +135,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
const handleHighlightDelete = (highlightId: string) => { const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => { setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId) const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted // Update cache when highlight is deleted (own profile only)
if (activeAccount) { if (isOwnProfile && viewingPubkey) {
updateCachedHighlights(activeAccount.pubkey, updated) updateCachedHighlights(viewingPubkey, updated)
} }
return updated return updated
}) })
@@ -183,9 +199,26 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
if (loading && !hasData) { if (loading && !hasData) {
return ( return (
<div className="explore-container"> <div className="explore-container" aria-busy="true">
<div className="explore-loading"> {viewingPubkey && (
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> <div className="explore-header">
<AuthorCard authorPubkey={viewingPubkey} />
</div>
)}
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : activeTab === 'highlights' ? (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
) : (
Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))
)}
</div> </div>
</div> </div>
) )
@@ -206,8 +239,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
switch (activeTab) { switch (activeTab) {
case 'highlights': case 'highlights':
return highlights.length === 0 ? ( return highlights.length === 0 ? (
<div className="explore-error"> <div className="explore-empty">
<p>No highlights yet. Start highlighting content to see them here!</p> <p>
{isOwnProfile
? 'No highlights yet. Start highlighting content to see them here!'
: 'No highlights yet. You should shame them on nostr!'}
</p>
</div> </div>
) : ( ) : (
<div className="highlights-list me-highlights-list"> <div className="highlights-list me-highlights-list">
@@ -224,7 +261,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
case 'reading-list': case 'reading-list':
return allIndividualBookmarks.length === 0 ? ( return allIndividualBookmarks.length === 0 ? (
<div className="explore-error"> <div className="explore-empty">
<p>No bookmarks yet. Bookmark articles to see them here!</p> <p>No bookmarks yet. Bookmark articles to see them here!</p>
</div> </div>
) : ( ) : (
@@ -275,7 +312,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
case 'archive': case 'archive':
return readArticles.length === 0 ? ( 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> <p>No read articles yet. Mark articles as read to see them here!</p>
</div> </div>
) : ( ) : (
@@ -292,8 +329,25 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
case 'writings': case 'writings':
return writings.length === 0 ? ( return writings.length === 0 ? (
<div className="explore-error"> <div className="explore-empty">
<p>No articles written yet. Publish your first article to see it here!</p> <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>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid">
@@ -324,7 +378,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
isRefreshing={loading && pullToRefreshState.canRefresh} isRefreshing={loading && pullToRefreshState.canRefresh}
/> />
<div className="explore-header"> <div className="explore-header">
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />} {viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
{loading && hasData && ( {loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}> <div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
@@ -336,38 +390,38 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
<button <button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`} className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights" data-tab="highlights"
onClick={() => navigate('/me/highlights')} onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
> >
<FontAwesomeIcon icon={faHighlighter} /> <FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span> <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> </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 <button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`} className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings" data-tab="writings"
onClick={() => navigate('/me/writings')} onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
> >
<FontAwesomeIcon icon={faPenToSquare} /> <FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span> <span className="tab-label">Writings</span>
<span className="tab-count">({writings.length})</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowDown, faSpinner } from '@fortawesome/free-solid-svg-icons' import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
interface PullToRefreshIndicatorProps { interface PullToRefreshIndicatorProps {
isPulling: boolean isPulling: boolean
@@ -14,11 +14,10 @@ const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
isPulling, isPulling,
pullDistance, pullDistance,
canRefresh, canRefresh,
isRefreshing,
threshold = 80 threshold = 80
}) => { }) => {
// Don't show if not pulling and not refreshing // Only show when actively pulling, not when refreshing
if (!isPulling && !isRefreshing) return null if (!isPulling) return null
const opacity = Math.min(pullDistance / threshold, 1) const opacity = Math.min(pullDistance / threshold, 1)
const rotation = (pullDistance / threshold) * 180 const rotation = (pullDistance / threshold) * 180
@@ -27,31 +26,23 @@ const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
<div <div
className="pull-to-refresh-indicator" className="pull-to-refresh-indicator"
style={{ style={{
opacity: isRefreshing ? 1 : opacity, opacity,
transform: `translateY(${isRefreshing ? 0 : -20 + pullDistance / 2}px)` transform: `translateY(${-20 + pullDistance / 2}px)`
}} }}
> >
<div <div
className="pull-to-refresh-icon" className="pull-to-refresh-icon"
style={{ style={{
transform: isRefreshing ? 'none' : `rotate(${rotation}deg)` transform: `rotate(${rotation}deg)`
}} }}
> >
{isRefreshing ? ( <FontAwesomeIcon
<FontAwesomeIcon icon={faSpinner} spin /> icon={faArrowDown}
) : ( style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
<FontAwesomeIcon />
icon={faArrowDown}
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
/>
)}
</div> </div>
<div className="pull-to-refresh-text"> <div className="pull-to-refresh-text">
{isRefreshing {canRefresh ? 'Release to refresh' : 'Pull to refresh'}
? 'Refreshing...'
: canRefresh
? 'Release to refresh'
: 'Pull to refresh'}
</div> </div>
</div> </div>
) )

View File

@@ -38,7 +38,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
const isLongSummary = summary && summary.length > 150 const isLongSummary = summary && summary.length > 150
// Determine the dominant highlight color based on visibility and priority // Determine the dominant highlight color based on visibility and priority
const highlightIndicatorStyles = useMemo(() => { const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
if (!highlights.length) return undefined if (!highlights.length) return undefined
// Count highlights by level that are visible // Count highlights by level that are visible
@@ -65,7 +65,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
return { return {
backgroundColor: `rgba(${rgb}, 0.1)`, backgroundColor: `rgba(${rgb}, 0.1)`,
borderColor: `rgba(${rgb}, 0.3)`, 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]) }, [highlights, highlightVisibility, settings])
@@ -93,7 +94,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && ( {hasHighlights && (
<div <div
className="highlight-indicator" className="highlight-indicator"
style={highlightIndicatorStyles} style={getHighlightIndicatorStyles(true)}
> >
<FontAwesomeIcon icon={faHighlighter} /> <FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span> <span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -133,7 +134,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
{hasHighlights && ( {hasHighlights && (
<div <div
className="highlight-indicator" className="highlight-indicator"
style={highlightIndicatorStyles} style={getHighlightIndicatorStyles(false)}
> >
<FontAwesomeIcon icon={faHighlighter} /> <FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span> <span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

View File

@@ -29,28 +29,39 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
return ( return (
<div <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={{ style={{
'--left-offset': leftOffset, '--left-offset': leftOffset,
'--right-offset': rightOffset '--right-offset': rightOffset,
backgroundColor: 'var(--color-bg-elevated)',
opacity: 0.95
} as React.CSSProperties} } 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 <div
className={`h-full rounded-full transition-all duration-300 relative ${ className={`h-full rounded-full transition-all duration-300 relative ${
isComplete isComplete
? 'bg-green-500' ? '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 className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
</div> </div>
</div> </div>
{showPercentage && ( {showPercentage && (
<div className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${ <div
isComplete ? 'text-green-500' : 'text-gray-500' 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}%`} {isComplete ? '✓' : `${clampedProgress}%`}
</div> </div>
)} )}

View File

@@ -70,8 +70,12 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
} }
} }
// On mobile, default to collapsed (icon only). On desktop, always show details.
const showDetails = !isMobile || isExpanded const showDetails = !isMobile || isExpanded
// On mobile when collapsed, make it circular like the highlight button
const isCollapsedOnMobile = isMobile && !isExpanded
return ( return (
<div <div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`} 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 ) : undefined
} }
onClick={handleClick} 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"> <div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} /> <FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div> </div>
{showDetails && ( {showDetails && (
<> <>
<div className="relay-status-text"> <div
className="relay-status-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.125rem'
}}
>
{isConnecting ? ( {isConnecting ? (
<span className="relay-status-title">Connecting</span> <span className="relay-status-title">Connecting</span>
) : offlineMode ? ( ) : offlineMode ? (
<> <>
<span className="relay-status-title">Offline</span> <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-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> </div>

View File

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

View File

@@ -4,6 +4,7 @@ import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton' import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader' import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings' import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings' import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings' import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
@@ -159,6 +160,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
</div> </div>
<div className="settings-content"> <div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} /> <ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} /> <LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} /> <StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />

View File

@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton' import IconButton from '../IconButton'
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
<label>Default Highlight Visibility</label> <label>Default Highlight Visibility</label>
<div className="highlight-level-toggles"> <div className="highlight-level-toggles">
<button <IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })} onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights" title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default" ariaLabel="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }} variant="ghost"
> style={{
<FontAwesomeIcon icon={faNetworkWired} /> color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
</button> opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
<button }}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })} onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights" title="Friends highlights"
aria-label="Toggle friends highlights by default" ariaLabel="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }} variant="ghost"
> style={{
<FontAwesomeIcon icon={faUserGroup} /> color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
</button> opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
<button }}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })} onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights" title="My highlights"
aria-label="Toggle my highlights by default" ariaLabel="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }} variant="ghost"
> style={{
<FontAwesomeIcon icon={faUser} /> color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
</button> opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
}}
/>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ interface ThreePaneLayoutProps {
showSettings: boolean showSettings: boolean
showExplore?: boolean showExplore?: boolean
showMe?: boolean showMe?: boolean
showProfile?: boolean
// Bookmarks pane // Bookmarks pane
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -89,6 +90,9 @@ interface ThreePaneLayoutProps {
// Optional Me content // Optional Me content
me?: React.ReactNode me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
} }
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => { const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -221,8 +225,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return ( return (
<> <>
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me) */} {/* 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 && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button <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 ${ 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' showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -241,8 +245,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button> </button>
)} )}
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me) */} {/* 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 && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button <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 ${ 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' showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -320,6 +324,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<> <>
{props.me} {props.me}
</> </>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : ( ) : (
<ContentPanel <ContentPanel
loading={props.readerLoading} loading={props.readerLoading}

View File

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

View File

@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts' import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService' import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader' import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
interface UseSettingsParams { interface UseSettingsParams {
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style const root = document.documentElement.style
const fontKey = settings.readingFont || 'system' 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 // Load font first and wait for it to be ready
if (fontKey !== 'system') { if (fontKey !== 'system') {

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,126 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
/**
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch nostrverse blog posts:', error)
return []
}
}
/**
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: 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 nostrverse highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch nostrverse highlights:', error)
return []
}
}

View File

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

View File

@@ -47,6 +47,10 @@ export interface UserSettings {
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB) imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
// Mobile settings // Mobile settings
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true) 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( export async function loadSettings(

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
/* Bookmark item and blog post cards */ /* 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: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-item h3 { margin: 0 0 0.5rem 0; color: var(--color-text); 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-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-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-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: rgb(161 161 170); /* zinc-400 */ font-size: 0.9rem; margin-top: 0.5rem; } .bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
.individual-bookmarks { margin: 1rem 0; } .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 { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; } .bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
@@ -19,74 +19,76 @@
.bookmarks-grid.bookmarks-large { gap: 1rem; } .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 { 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: rgb(63 63 70); /* zinc-700 */ background: rgb(39 39 42); /* zinc-800 */ } .individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */ /* 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 { 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: rgb(39 39 42); /* zinc-800 */ border-bottom-color: rgb(63 63 70); /* zinc-700 */ transform: none; box-shadow: none; } .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-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-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.compact-row.clickable { cursor: pointer; } .compact-row.clickable { cursor: pointer; }
.compact-row.clickable:active { opacity: 0.8; } .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; } .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: rgb(212 212 216); /* zinc-300 */ font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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: rgb(113 113 122); /* zinc-500 */ flex-shrink: 0; 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: 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 { 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: rgb(212 212 216); /* zinc-300 */ } .compact-read-btn:hover { color: var(--color-text); }
.compact-read-btn:active { transform: translateY(1px); } .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-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-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: rgb(161 161 170); /* zinc-400 */ background: rgb(24 24 27); /* zinc-900 */ padding: 0.25rem 0.5rem; border-radius: 4px; } .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: rgb(113 113 122); /* zinc-500 */ } .bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
.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 { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: rgb(96 165 250); /* blue-400 */ text-decoration: underline; } .bookmark-date-link:hover { color: var(--color-primary); 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; } .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: rgb(161 161 170); /* zinc-400 */ cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; } .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: rgb(212 212 216); /* zinc-300 */ } .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-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 */ } .bookmark-meta-minimal { font-size: 0.8rem; color: var(--color-text-secondary); }
.author-link-minimal { color: rgb(161 161 170); /* zinc-400 */ text-decoration: none; transition: color 0.2s ease; } .author-link-minimal { color: var(--color-text-secondary); text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: rgb(212 212 216); /* zinc-300 */ } .author-link-minimal:hover { color: var(--color-text); }
.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 { 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: rgb(79 70 229); /* indigo-600 */ } .read-now-button-minimal:hover { background: var(--color-primary-hover); }
.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 { 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: rgb(129 140 248); /* indigo-400 */ } .expand-toggle-urls:hover { color: var(--color-primary-hover); }
/* Large preview view */ /* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgb(39 39 42); /* zinc-800 */ } .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: 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; } .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: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; } .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-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-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: rgb(161 161 170); /* zinc-400 */ padding-top: 0.75rem; border-top: 1px solid rgb(63 63 70); /* zinc-700 */ } .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-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 { 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: rgb(79 70 229); /* indigo-600 */ } .large-read-button:hover { background: var(--color-primary-hover); }
/* Blog cards (Explore) */ /* Blog cards (Explore) */
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; } .explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
.explore-header { text-align: center; margin-bottom: 3rem; } .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-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: rgba(255, 255, 255, 0.7); margin: 0; } .explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); 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 .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-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: rgb(239 68 68); /* red-500 */ } .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; } .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 { 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: rgb(99 102 241); /* indigo-500 */ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); } .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: rgb(9 9 11); /* zinc-950 */ display: flex; align-items: center; justify-content: center; } .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-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-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-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-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: 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-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 rgb(63 63 70); /* zinc-700 */ font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; } .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, .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; } .blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -97,4 +99,3 @@
.blog-post-card-content { padding: 1rem; } .blog-post-card-content { padding: 1rem; }
} }

View File

@@ -4,28 +4,28 @@
.setting-label { text-align: left; flex: 1; } .setting-label { text-align: left; flex: 1; }
.setting-control { display: flex; justify-content: flex-end; align-items: center; } .setting-control { display: flex; justify-content: flex-end; align-items: center; }
.setting-group.setting-inline label { margin-bottom: 0; } .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; } .setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
.color-picker { 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 { 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: rgb(161 161 170); /* zinc-400 */ } .color-swatch:hover { border-color: var(--color-text-secondary); }
.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 { 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); /* black */ font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); /* white */ } .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 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 { 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: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ } .font-size-btn:hover { background: var(--color-border); border-color: var(--color-text-muted); }
.font-size-btn.active { background: rgb(99 102 241); /* indigo-500 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; } .font-size-btn.active { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.setting-preview { .setting-preview {
margin: 1.5rem 0; margin: 1.5rem 0;
padding: 1rem; padding: 1rem;
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
border: 1px solid rgb(63 63 70); /* zinc-700 */ border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
max-width: 100%; max-width: 100%;
overflow: hidden; 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 { .preview-content {
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
line-height: 1.7; line-height: 1.7;
max-width: 100%; max-width: 100%;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -35,20 +35,20 @@
.preview-content h3 { .preview-content h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.5em; font-size: 1.5em;
color: rgb(255 255 255); /* white */ color: var(--color-text);
word-wrap: break-word; word-wrap: break-word;
} }
.preview-content p { .preview-content p {
margin: 0.75rem 0; margin: 0.75rem 0;
word-wrap: break-word; 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-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; } .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; } .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 */ } .setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: var(--color-primary); }
.checkbox-label span { color: rgb(228 228 231); /* zinc-200 */ text-align: left; font-weight: 500; } .checkbox-label span { color: var(--color-text); text-align: left; font-weight: 500; }
/* Mobile responsive styles */ /* Mobile responsive styles */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -81,4 +81,3 @@
} }
} }

View File

@@ -3,10 +3,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid rgb(82 82 91); /* zinc-600 */ border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
cursor: pointer; cursor: pointer;
min-width: 33px; min-width: 33px;
min-height: 33px; min-height: 33px;
@@ -14,16 +14,16 @@
box-sizing: border-box; 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: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.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.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 */ /* Mobile touch target improvements */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -42,9 +42,8 @@
/* Disable hover effects on touch devices */ /* Disable hover effects on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
.icon-button:hover { background: rgb(39 39 42); /* zinc-800 */ } .icon-button:hover { background: var(--color-bg-elevated); }
.icon-button.ghost:hover { background: rgb(39 39 42); /* zinc-800 */ } .icon-button.ghost:hover { background: var(--color-bg-elevated); }
.icon-button:active { background: rgb(63 63 70); /* zinc-700 */ } .icon-button:active { background: var(--color-border); }
} }

View File

@@ -3,7 +3,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 1rem; margin-top: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid var(--color-border);
overflow-x: auto; overflow-x: auto;
max-width: 600px; max-width: 600px;
margin-left: auto; margin-left: auto;
@@ -18,7 +18,7 @@
background: none; background: none;
border: none; border: none;
border-bottom: 2px solid transparent; 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-size: 0.95rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -28,25 +28,32 @@
} }
.me-tab:hover { .me-tab:hover {
color: var(--text-primary, rgb(228 228 231)); /* zinc-200 */ color: var(--color-text);
background: rgba(255, 255, 255, 0.05); background: var(--color-bg-elevated);
} }
.me-tab.active { .me-tab.active {
color: var(--primary-color, rgb(139 92 246)); /* purple-500 */ color: var(--color-primary);
border-bottom-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */ 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 { .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 */ 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 */ /* Reading List tab uses blue color to match bookmarks icon */
.me-tab[data-tab="reading-list"].active { .me-tab[data-tab="reading-list"].active {
color: rgb(99 102 241); /* indigo-500 */ color: var(--color-primary);
border-bottom-color: rgb(99 102 241); /* indigo-500 */ border-bottom-color: var(--color-primary);
} }
.me-tab svg { .me-tab svg {
@@ -80,25 +87,25 @@
/* Enhanced border styling for reading list cards */ /* Enhanced border styling for reading list cards */
.bookmarks-list .individual-bookmark { .bookmarks-list .individual-bookmark {
border: 1px solid rgb(82 82 91) !important; /* zinc-600 */ border: 1px solid var(--color-border-subtle) !important;
background: rgb(24 24 27) !important; /* zinc-900 */ background: var(--color-bg) !important;
} }
.bookmarks-list .individual-bookmark:hover { .bookmarks-list .individual-bookmark:hover {
border-color: rgb(113 113 122) !important; /* zinc-500 */ border-color: var(--color-border) !important;
background: rgb(39 39 42) !important; /* zinc-800 */ background: var(--color-bg-elevated) !important;
} }
.bookmark-item { .bookmark-item {
padding: 1rem; padding: 1rem;
background: var(--card-bg, rgb(255 255 255)); /* white */ background: var(--color-bg);
border: 1px solid var(--border-color, rgb(228 228 231)); /* zinc-200 */ border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.bookmark-item:hover { .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); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
@@ -110,13 +117,13 @@
.bookmark-item h3 { .bookmark-item h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.1rem; font-size: 1.1rem;
color: var(--text-primary, rgb(0 0 0)); /* black */ color: var(--color-text);
} }
.bookmark-item p { .bookmark-item p {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary, rgb(113 113 122)); /* zinc-500 */ color: var(--color-text-secondary);
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -1,28 +1,27 @@
/* Add Bookmark Modal */ /* 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-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) { @media (max-width: 768px) {
.modal-overlay { padding: 0; align-items: flex-end; } .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-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 { 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: rgb(255 255 255); /* white */ } .modal-header h2 { margin: 0; font-size: 1.5rem; color: var(--color-text); }
.modal-form { padding: 1.5rem; } .modal-form { padding: 1.5rem; }
.form-group { margin-bottom: 1.25rem; } .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; } .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: rgb(161 161 170); /* zinc-400 */ font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; } .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: 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, .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: rgb(99 102 241); /* indigo-500 */ } .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 input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
.form-group textarea { resize: vertical; min-height: 80px; } .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; } .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); /* red-600 */ border-radius: 6px; color: rgb(220 38 38); /* red-600 */ font-size: 0.9rem; margin-bottom: 1rem; } .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; } .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 { 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: rgb(63 63 70); /* zinc-700 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; } .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-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 { 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: rgb(79 70 229); /* indigo-600 */ } .btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }

View File

@@ -1,12 +1,14 @@
/* Profile UI fragments */ /* Profile UI fragments */
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; } .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 { 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-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-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 img { width: 100%; height: 100%; object-fit: cover; }
.author-card-avatar svg { font-size: 2.5rem; } .author-card-avatar svg { font-size: 2.5rem; }
.author-card-content { flex: 1; min-width: 0; text-align: left; } .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-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: 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-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) { @media (max-width: 768px) {
.author-card-container { .author-card-container {
@@ -26,4 +28,3 @@
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; } .author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
} }

View File

@@ -21,21 +21,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--background-secondary); background: var(--color-bg-elevated);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease; transition: transform 0.3s ease;
font-size: 1rem; font-size: 1rem;
color: var(--text-secondary); color: var(--color-text-secondary);
} }
.pull-to-refresh-text { .pull-to-refresh-text {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--color-text-secondary);
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
font-weight: 500; font-weight: 500;
background: var(--background-secondary); background: var(--color-bg-elevated);
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

View File

@@ -1,7 +1,7 @@
/* Reader view */ /* Reader view */
.reader { .reader {
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
border: 1px solid rgb(63 63 70); /* zinc-700 */ border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
@@ -21,22 +21,22 @@
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */ margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
background: rgb(0 0 0); /* black */ background: rgb(0 0 0); /* black */
} }
.reader.empty { 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: rgb(161 161 170); /* zinc-400 */ } .loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
.loading-spinner svg { font-size: 1.2rem; } .loading-spinner svg { font-size: 1.2rem; }
.reader-header { margin-bottom: 2rem; position: relative; } .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-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; } .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 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; } .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: 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 */ } .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; } .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; } .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-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: rgb(228 228 231); /* zinc-200 */ line-height: 1.7; 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 */ /* 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 .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
.reader center, .reader [align="center"] { text-align: left !important; } .reader center, .reader [align="center"] { text-align: left !important; }
@@ -49,7 +49,7 @@
line-height: 1.2; line-height: 1.2;
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
color: rgb(244 244 245); /* zinc-100 */ color: var(--color-text);
} }
.reader-markdown h2, .reader-html h2 { .reader-markdown h2, .reader-html h2 {
font-size: 1.875rem; /* text-3xl */ font-size: 1.875rem; /* text-3xl */
@@ -57,7 +57,7 @@
line-height: 1.3; line-height: 1.3;
margin-top: 1.75rem; margin-top: 1.75rem;
margin-bottom: 0.875rem; margin-bottom: 0.875rem;
color: rgb(244 244 245); /* zinc-100 */ color: var(--color-text);
} }
.reader-markdown h3, .reader-html h3 { .reader-markdown h3, .reader-html h3 {
font-size: 1.5rem; /* text-2xl */ font-size: 1.5rem; /* text-2xl */
@@ -65,7 +65,7 @@
line-height: 1.4; line-height: 1.4;
margin-top: 1.5rem; margin-top: 1.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
color: rgb(244 244 245); /* zinc-100 */ color: var(--color-text);
} }
.reader-markdown h4, .reader-html h4 { .reader-markdown h4, .reader-html h4 {
font-size: 1.25rem; /* text-xl */ font-size: 1.25rem; /* text-xl */
@@ -73,7 +73,7 @@
line-height: 1.4; line-height: 1.4;
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
} }
.reader-markdown h5, .reader-html h5 { .reader-markdown h5, .reader-html h5 {
font-size: 1.125rem; /* text-lg */ font-size: 1.125rem; /* text-lg */
@@ -81,7 +81,7 @@
line-height: 1.4; line-height: 1.4;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
} }
.reader-markdown h6, .reader-html h6 { .reader-markdown h6, .reader-html h6 {
font-size: 1rem; /* text-base */ font-size: 1rem; /* text-base */
@@ -89,7 +89,7 @@
line-height: 1.4; line-height: 1.4;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
} }
.reader-markdown p { margin: 0.5rem 0; } .reader-markdown p { margin: 0.5rem 0; }
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; } .reader-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 { .reader-markdown li, .reader-html li {
margin: 0.375rem 0; margin: 0.375rem 0;
line-height: 1.6; 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 { .reader-markdown ul ul, .reader-markdown ol ul, .reader-html ul ul, .reader-html ol ul {
list-style-type: circle; list-style-type: circle;
@@ -128,16 +128,16 @@
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; } .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: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 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 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 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: 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 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; } .reader-markdown pre code { background: transparent; border: none; padding: 0; font-size: 0.9em; display: block; }
/* Prism.js enhancements */ /* 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-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 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: 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 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; } .reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
/* Mobile: prevent code blocks from causing horizontal overflow */ /* Mobile: prevent code blocks from causing horizontal overflow */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -182,17 +182,17 @@
/* Article menu */ /* Article menu */
.article-menu-container { display: flex; justify-content: flex-end; padding: 1.5rem 0 0.5rem; margin-top: 2rem; } .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-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 { 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: rgb(99 102 241); /* indigo-500 */ background: rgba(99, 102, 241, 0.1); } .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: 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 { 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: 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 { 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: rgb(255 255 255); /* white */ } .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; } .article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
/* Mark as Read button */ /* 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-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 { 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: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ transform: translateY(-1px); } .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:active:not(:disabled) { transform: translateY(0); }
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; } .mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mark-as-read-btn svg { font-size: 1.1rem; } .mark-as-read-btn svg { font-size: 1.1rem; }
@@ -228,7 +228,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; } .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 { 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 { min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; } .reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
.reader-header-overlay { padding: 1.5rem 1rem 1rem; } .reader-header-overlay { padding: 1.5rem 1rem 1rem; }
@@ -237,4 +237,3 @@
/* Reading Progress Indicator - now using Tailwind utilities in component */ /* Reading Progress Indicator - now using Tailwind utilities in component */

View File

@@ -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-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 { margin-bottom: 2.5rem; }
.settings-section:last-child { margin-bottom: 0; } .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 { 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 { 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: rgb(79 70 229); /* indigo-600 */ } .settings-footer .btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } .settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
/* Setting groups */ /* Setting groups */
.setting-group { margin-bottom: 1.5rem; } .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 splits preset buttons */
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.zap-preset-btn { .zap-preset-btn {
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
border: 1px solid rgb(82 82 91); /* zinc-600 */ border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
color: rgb(212 212 216); /* zinc-300 */ color: var(--color-text);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
font-weight: 500; font-weight: 500;
} }
.zap-preset-btn:hover { .zap-preset-btn:hover {
background: rgb(63 63 70); /* zinc-700 */ background: var(--color-border);
border-color: rgb(99 102 241); /* indigo-500 */ border-color: var(--color-primary);
color: rgb(255 255 255); /* white */ color: var(--color-text);
} }
.zap-preset-btn.active { .zap-preset-btn.active {
background: rgb(99 102 241); /* indigo-500 */ background: var(--color-primary);
border-color: rgb(99 102 241); /* indigo-500 */ border-color: var(--color-primary);
color: rgb(255 255 255); /* white */ color: rgb(255 255 255); /* white */
} }
@@ -47,14 +47,14 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: rgb(161 161 170); /* zinc-400 */ color: var(--color-text-secondary);
} }
.zap-split-label { font-weight: 500; } .zap-split-label { font-weight: 500; }
.zap-split-slider { .zap-split-slider {
width: 100%; width: 100%;
height: 8px; height: 8px;
border-radius: 4px; border-radius: 4px;
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
outline: none; outline: none;
-webkit-appearance: none; -webkit-appearance: none;
} }
@@ -64,36 +64,36 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
background: rgb(99 102 241); /* indigo-500 */ background: var(--color-primary);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.zap-split-slider::-webkit-slider-thumb:hover { .zap-split-slider::-webkit-slider-thumb:hover {
background: rgb(79 70 229); /* indigo-600 */ background: var(--color-primary-hover);
transform: scale(1.1); transform: scale(1.1);
} }
.zap-split-slider::-moz-range-thumb { .zap-split-slider::-moz-range-thumb {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
background: rgb(99 102 241); /* indigo-500 */ background: var(--color-primary);
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.zap-split-slider::-moz-range-thumb:hover { .zap-split-slider::-moz-range-thumb:hover {
background: rgb(79 70 229); /* indigo-600 */ background: var(--color-primary-hover);
transform: scale(1.1); transform: scale(1.1);
} }
.zap-split-description { .zap-split-description {
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1rem; padding: 1rem;
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
border: 1px solid rgb(63 63 70); /* zinc-700 */ border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
color: rgb(161 161 170); /* zinc-400 */ color: var(--color-text-secondary);
} }
/* Relay items */ /* Relay items */
@@ -146,4 +146,3 @@
} }
} }

View File

@@ -0,0 +1,35 @@
/* Skeleton loading animations - respects prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.react-loading-skeleton {
animation: none !important;
}
}
/* Ensure skeletons have proper border radius to match design */
.react-loading-skeleton {
border-radius: 4px;
line-height: 1.2;
}
/* Image skeleton aspect ratio boxes to prevent CLS */
.blog-post-card-image .react-loading-skeleton,
.bookmark-card .react-loading-skeleton:first-child {
aspect-ratio: 16 / 9;
}
/* Skeleton spacing adjustments */
.highlights-list .react-loading-skeleton,
.bookmarks-list .react-loading-skeleton {
margin-bottom: 0.5rem;
}
/* Ensure skeletons inherit theme colors properly */
.react-loading-skeleton::after {
background: linear-gradient(
90deg,
transparent,
var(--color-border-subtle, rgba(255, 255, 255, 0.05)),
transparent
);
}

View File

@@ -1,5 +1,5 @@
/* Toast Notification */ /* 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) { @media (max-width: 768px) {
.toast { top: auto; bottom: calc(1rem + var(--safe-area-bottom)); right: 1rem; left: 1rem; max-width: calc(100% - 2rem); } .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; } } @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 */ } .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; } } @keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }

View File

@@ -111,7 +111,7 @@
max-width: 320px; max-width: 320px;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
z-index: 1001; /* Above backdrop */ z-index: 1001; /* Above backdrop */
transition: transform 0.3s ease; transition: transform 0.3s ease;
box-shadow: none; box-shadow: none;

View File

@@ -1,6 +1,6 @@
/* Highlights panel layout and interactions */ /* Highlights panel layout and interactions */
.highlights-container { .highlights-container {
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -25,8 +25,8 @@
} }
.highlights-container.collapsed .toggle-highlights-btn { .highlights-container.collapsed .toggle-highlights-btn {
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
border: none; border: none;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
@@ -39,7 +39,7 @@
height: 36px; 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:active { transform: translateY(1px); }
.highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; } .highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
@@ -48,8 +48,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ border-bottom: 1px solid var(--color-border);
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
} }
@@ -65,32 +65,32 @@
.highlights-title { display: flex; align-items: center; gap: 0.5rem; } .highlights-title { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; } .highlights-title 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 { 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 { 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: rgb(255 255 255); /* white */ } .highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: var(--color-text); }
.highlight-mode-toggle .mode-btn.active { background: rgb(99 102 241); /* indigo-500 */ color: rgb(255 255 255); /* white */ } .highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
/* Three-level highlight toggles */ /* 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 { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlights-loading, .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 { 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: rgb(113 113 122); /* zinc-500 */ margin-bottom: 0.5rem; } .highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: rgb(113 113 122); /* zinc-500 */ margin-top: 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; } .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 { 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: rgb(99 102 241); /* indigo-500 */ } .highlight-item:hover { border-color: var(--color-primary); }
.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.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 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 { 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: rgb(212 212 216); /* zinc-300 */ background: rgba(255, 255, 255, 0.05); } .compact-button:hover { color: var(--color-text); background: rgba(255, 255, 255, 0.05); }
.compact-button:active { transform: scale(0.95); } .compact-button:active { transform: scale(0.95); }
.compact-button:disabled { opacity: 0.5; cursor: not-allowed; } .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 { 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; } .highlight-header .compact-button { pointer-events: auto; }
@@ -126,13 +126,25 @@
.highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); } .highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); } .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-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
.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-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-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-citation { margin-left: 1.25rem; font-size: 0.8rem; color: var(--color-text-secondary); font-style: normal; padding-top: 0.25rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; display: flex; gap: 0.5rem; align-items: flex-start; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; min-width: 0; }
.highlight-comment-icon { flex-shrink: 0; margin-top: 0.125rem; }
.highlight-comment-text { flex: 1; min-width: 0; }
.highlight-comment-link { color: var(--color-primary); text-decoration: underline; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-link:hover { opacity: 0.8; }
.highlight-comment-nostr-id { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 0.8em; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.05); padding: 0.125rem 0.375rem; border-radius: 3px; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-image { display: block; max-width: 100%; height: auto; margin-top: 0.5rem; border-radius: 6px; border: 1px solid var(--color-border); }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; } /* Level-colored comment icons */
.highlight-item.level-mine .highlight-comment-icon { color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-comment-icon { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-comment-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--color-text-secondary); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } .highlight-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 */ /* Ensure relay indicator in footer uses normal flow and matches CompactButton spacing */
.highlight-item .highlight-footer .highlight-relay-indicator { .highlight-item .highlight-footer .highlight-relay-indicator {
@@ -143,11 +155,10 @@
padding: 0.25rem; /* CompactButton base */ padding: 0.25rem; /* CompactButton base */
} }
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; } .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 { 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: 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 { 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: rgb(255 255 255); /* white */ } .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: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-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; } .highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }

View File

@@ -1,6 +1,6 @@
/* Bookmarks and sidebar layout */ /* Bookmarks and sidebar layout */
.bookmarks-container { .bookmarks-container {
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -20,7 +20,7 @@
.bookmarks-container .view-mode-controls { .bookmarks-container .view-mode-controls {
margin-top: auto; margin-top: auto;
padding: 1rem; padding: 1rem;
border-top: 1px solid rgb(63 63 70); /* zinc-700 */ border-top: 1px solid var(--color-border);
background: transparent; background: transparent;
border-radius: 0; border-radius: 0;
} }
@@ -41,15 +41,15 @@
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: rgb(24 24 27); /* zinc-900 */ background: var(--color-bg);
border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ border-bottom: 1px solid var(--color-border);
margin-bottom: 0; margin-bottom: 0;
} }
/* Mobile: add borders and rounded corners */ /* Mobile: add borders and rounded corners */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar-header-bar { .sidebar-header-bar {
border: 1px solid rgb(63 63 70); /* zinc-700 */ border: 1px solid var(--color-border);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
} }
} }
@@ -95,10 +95,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
border: 1px solid rgb(82 82 91); /* zinc-600 */ border: 1px solid var(--color-border-subtle);
flex-shrink: 0; flex-shrink: 0;
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
} }
@@ -108,8 +108,8 @@
.sidebar-header-bar .toggle-sidebar-btn { .sidebar-header-bar .toggle-sidebar-btn {
background: transparent; background: transparent;
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
border: 1px solid rgb(82 82 91); /* zinc-600 */ border: 1px solid var(--color-border-subtle);
padding: 0; padding: 0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@@ -123,7 +123,7 @@
box-sizing: border-box; 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); } .sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
.bookmarks-container.collapsed { .bookmarks-container.collapsed {
@@ -136,8 +136,8 @@
} }
.bookmarks-container.collapsed .toggle-sidebar-btn { .bookmarks-container.collapsed .toggle-sidebar-btn {
background: rgb(39 39 42); /* zinc-800 */ background: var(--color-bg-elevated);
color: rgb(228 228 231); /* zinc-200 */ color: var(--color-text);
border: none; border: none;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
@@ -151,22 +151,21 @@
flex-shrink: 0; 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: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.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; } .user-info { margin: 0.5rem 0 0 0; color: var(--color-text-secondary); font-size: 0.9rem; font-family: monospace; }
.bookmark-count { color: rgb(113 113 122); /* zinc-500 */ font-size: 0.9rem; margin: 0.5rem 0; } .bookmark-count { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
.event-link { color: rgb(96 165 250); /* blue-400 */ text-decoration: none; font-weight: 500; } .event-link { color: var(--color-primary); text-decoration: none; font-weight: 500; }
.event-link:hover { text-decoration: underline; } .event-link:hover { text-decoration: underline; }
.bookmark-urls { margin: 0.75rem 0; } .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; } .bookmark-url:hover { text-decoration: underline; }
.url-row { display: flex; align-items: center; gap: 0.5rem; } .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 { 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 */ } .read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }

View File

@@ -39,7 +39,7 @@ export function extractNaddrUris(text: string): string[] {
/** /**
* Decode a NIP-19 identifier and return a human-readable link * 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 * For other types, returns an external gateway link
*/ */
export function createNostrLink(encoded: string): string { export function createNostrLink(encoded: string): string {
@@ -51,7 +51,13 @@ export function createNostrLink(encoded: string): string {
// For articles, link to our internal app route // For articles, link to our internal app route
return `/a/${encoded}` return `/a/${encoded}`
case 'npub': 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 'note':
case 'nevent': case 'nevent':
return getNostrUrl(encoded) return getNostrUrl(encoded)

63
src/utils/theme.ts Normal file
View 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 })
}