Compare commits

...

132 Commits

Author SHA1 Message Date
Gigi
d5076ff53e chore: bump version to 0.6.4 2025-10-14 10:39:44 +02:00
Gigi
41e452be1e Merge pull request #6 from dergigi/themes
Add comprehensive theme support with light/dark modes
2025-10-14 10:38:54 +02:00
Gigi
f267df8f60 feat(ui): increase bottom padding in highlight cards
- Increase bottom padding from 0.75rem to 2.5rem
- Reduces gap between cards from 1rem to 0.75rem (user edit)
- Provides more breathing room between text and footer
- Improves readability and visual balance
2025-10-14 10:35:34 +02:00
Gigi
7426c9d1fc feat(ui): increase spacing between highlight cards
- Increase gap from 0.75rem to 1rem in highlights list
- Provides better visual breathing room between cards
- Improves overall readability and card separation
2025-10-14 10:34:48 +02:00
Gigi
93d0c1052b feat(ui): align highlight text with footer icons
- Add 1.25rem left padding to highlight text content
- Add 1.25rem left margin to highlight comments
- Text now starts roughly where the fa-server icon ends
- Improves visual alignment and readability of highlight cards
2025-10-14 10:34:07 +02:00
Gigi
6537650757 feat(ui): apply highlight marker style to active Highlights tab
- Use actual highlight visual treatment (marker style) on tab
- Text remains in semantic color (--color-text) for readability
- Background uses 35% highlight color blend with glow effect
- Hover state intensifies to 50% for better interaction feedback
- Creates consistent visual language between tabs and content highlights
2025-10-14 10:32:31 +02:00
Gigi
a95f9b522b refactor(ui): simplify Me page tab labels
- Remove count numbers from all tabs (cleaner UI)
- Rename "Reading List" to "Bookmarks" (clearer naming)
- Keep tab names: Highlights, Bookmarks, Archive, Writings
- Reduces visual clutter and improves readability
2025-10-14 10:31:35 +02:00
Gigi
47d1335842 fix(ui): add background to Highlights tab for better contrast
- Add --color-bg-elevated background to active Highlights tab
- Improves contrast of yellow highlight color in light mode
- Creates visual separation while maintaining highlight color identity
- Keeps yellow text and border for consistent highlight theming
2025-10-14 10:30:27 +02:00
Gigi
168095e133 fix(ui): improve Highlights tab readability in light mode
- Use semantic text color (--color-text) for tab label in active state
- Keep highlight color for icon and bottom border as visual accent
- Ensures text is always readable regardless of theme
- Fixes contrast issues on /me page Highlights tab
2025-10-14 10:29:11 +02:00
Gigi
5c7b413a8d fix(theme): use consistent yellow-300 highlight color across all themes
- Revert to yellow-300 (#fde047) for all light and dark themes
- Use consistent Tailwind palette: yellow-300, orange-500, purple-600
- Previous darker colors were causing inconsistency with design system
- Ensures highlights use the same color values across all theme variants
2025-10-14 10:23:55 +02:00
Gigi
bca6458e44 fix(theme): improve highlight contrast in light themes
- Use darker yellow (yellow-400 instead of yellow-300) for better visibility
- Use darker orange (orange-600 instead of orange-500)
- Sepia theme uses even darker highlights (yellow-500, red-600)
- Ensures text and icons remain visible on highlighted text
- Applies to all light theme variants and system mode
2025-10-14 10:12:22 +02:00
Gigi
ebdfa3b5a3 fix(lint): replace 'any' types with proper type definitions
- Add DarkColorTheme and LightColorTheme type definitions
- Replace 'as any' with proper type assertions
- All eslint and TypeScript checks now pass
2025-10-14 10:10:57 +02:00
Gigi
17480dddbf fix(theme): improve text contrast in dark color themes
- Add text color definitions to all dark theme variants
- Ensure bright text (zinc-200) for readability on dark backgrounds
- Update --color-bg-subtle to be darker for better hierarchy
- Fixes low contrast issue where text was barely readable
2025-10-14 10:06:43 +02:00
Gigi
2a422fbeb9 fix(theme): use darker background for app body
- Change body background from --color-bg to --color-bg-subtle
- Creates visual depth and hierarchy between app bg and content
- Content panels now stand out more against the background
2025-10-14 10:05:10 +02:00
Gigi
22961ee479 fix(theme): update reading progress indicator to use theme colors
- Replace hard-coded dark background with --color-bg-elevated
- Use --color-border for progress track
- Use --color-primary for progress bar
- Use --color-text-muted for percentage text
- Indicator now adapts to light/dark themes
2025-10-14 10:02:43 +02:00
Gigi
18db905974 refactor(theme): rename labels from 'Colors' to 'Theme'
- Change 'Dark Colors' to 'Dark Theme'
- Change 'Light Colors' to 'Light Theme'
- More consistent and clearer labeling
2025-10-14 10:00:48 +02:00
Gigi
689963c041 refactor(theme): change default light theme to sepia
- Update default from paper-white to sepia for warmer reading
- Midnight remains default for dark mode
- Sepia provides warm, eye-friendly tones for light mode
2025-10-14 10:00:21 +02:00
Gigi
3f8869fd75 refactor(theme): show color swatches instead of text labels
- Replace text buttons with color swatches for theme selection
- Use actual background colors to preview each theme
- Add border for white swatch to make it visible
- Tooltips show theme names on hover
2025-10-14 09:58:47 +02:00
Gigi
129aced1a2 feat(theme): add color theme variants for light and dark modes
- Add darkColorTheme: black, midnight (default), charcoal
- Add lightColorTheme: paper-white (default), sepia, ivory
- Extend UserSettings with color theme fields
- Update ThemeSettings UI to show color options
- Add CSS variables for all color theme variants
- Sepia and Ivory have warm, reading-friendly palettes
- Black offers true black for OLED screens
- All color themes sync via Nostr (NIP-78)
2025-10-14 09:39:13 +02:00
Gigi
69febf4356 refactor(theme): remove localStorage, use only Nostr for persistence
- Remove localStorage.setItem/getItem from theme.ts
- Simplify early boot script to just default to system theme
- Theme now loads purely from NIP-78 settings
- Prevents race conditions between localStorage and Nostr settings
2025-10-14 09:34:54 +02:00
Gigi
65051c9c1f fix(theme): apply theme colors to body element
- Add background and text color to body
- Ensures page background changes with theme
2025-10-14 09:25:13 +02:00
Gigi
ba8229d464 refactor(css): update pull-to-refresh to use semantic tokens 2025-10-14 09:24:49 +02:00
Gigi
9251b017d4 refactor(css): migrate remaining components to semantic tokens
- Update icon-button.css, profile.css, me.css to use tokens
- Migrate reader.css to semantic colors for light theme
- Update toast.css with theme-aware colors
- All major UI components now support theme switching
2025-10-14 09:24:31 +02:00
Gigi
1ae76031f3 refactor(css): migrate cards/forms/layout to semantic tokens
- Replace hard-coded colors with CSS variables in cards.css
- Update forms.css, settings.css, modals.css with tokens
- Migrate sidebar.css and highlights.css to use semantic colors
- Update layout/app.css and base/global.css
- Enables proper light/dark theme switching
2025-10-14 09:13:42 +02:00
Gigi
994d834a0b feat(theme): add CSS variable tokens and theme classes
- Define semantic color tokens (--color-bg, --color-text, etc.)
- Add .theme-dark, .theme-light, .theme-system CSS classes
- Create theme.ts utility for theme application
- Add early boot theme script to prevent FOUC
- Support system preference with live updates
2025-10-14 09:11:38 +02:00
Gigi
67a4e17055 feat: add ants link to empty writings state for other users
- Update empty writings message for other users' profiles
- Show 'No articles written. You can find other stuff from this user using ants.'
- Link 'ants' to the ants.sh profile page for that user
- Keep original message for own profile
2025-10-14 01:42:29 +02:00
Gigi
1e82e3f240 fix: change empty state text color from red to gray
- Create new .explore-empty class with muted gray color (zinc-400)
- Keep .explore-error red for actual errors
- Update all empty state divs in Me.tsx to use .explore-empty
- Empty states (no highlights, no bookmarks, etc.) no longer appear as errors
2025-10-14 01:38:35 +02:00
Gigi
f973c75ff5 feat: match highlight comment color to highlight level color
- Remove hardcoded blue color from highlight comments
- Apply level-specific colors (mine/friends/nostrverse) to comment borders
- Use color-mix for subtle background tint matching highlight color
- Comment styling now respects user's highlight color settings
2025-10-14 01:36:59 +02:00
Gigi
28316a71c5 feat: open all profile links within app instead of external portals
- Update nostrUriResolver to return internal /p/:npub links for npub/nprofile
- Replace external profile links with React Router Link components
- Update ResolvedMention, LargeView, and CardView components
- Convert nprofile to npub before routing
- Keep note/nevent links as external (no internal viewer yet)
2025-10-14 01:32:28 +02:00
Gigi
cfc12e2d78 feat: add playful empty state message for other users' profiles
- Show 'You should shame them on nostr!' when viewing profiles with no highlights
- Keep original helpful message for own profile
- Conditional based on isOwnProfile flag
2025-10-14 01:29:54 +02:00
Gigi
7464a8b505 chore: bump version to 0.6.3 2025-10-14 01:27:22 +02:00
Gigi
938d79663b fix: remove unused isRefreshing parameter from PullToRefreshIndicator
- Keep prop in interface for backward compatibility
- Don't destructure unused parameter to satisfy linter
- All lint checks and type checks now pass
2025-10-14 01:13:02 +02:00
Gigi
cc0ad69275 chore: remove COLOR_SYSTEM.md documentation file 2025-10-14 01:11:32 +02:00
Gigi
810ff060f8 feat: make relay status indicator circular FAB on mobile
- Default to collapsed (icon only) on mobile
- Expand to show details when tapped on mobile
- Circular 56px FAB when collapsed, matching highlight button style
- Desktop always shows expanded with details
- Hide on scroll via showOnMobile prop (matches sidepanel buttons)
2025-10-14 01:11:14 +02:00
Gigi
5e03ef70a6 style: improve relay status indicator text layout
- Make subtitle text smaller (0.75rem) with reduced opacity
- Display text in column layout with proper line spacing
- Subtitle now appears on second line below title
- Apply consistent styling to offline and flight mode subtitles
2025-10-14 01:08:28 +02:00
Gigi
f05fb29c7b refactor: remove refresh spinner and text from pull-to-refresh
- Remove 'Refreshing...' text from indicator
- Remove spinner from pull-to-refresh (button already spins)
- Only show indicator when actively pulling, not when refreshing
- Simplify logic and improve UX consistency
2025-10-14 01:05:22 +02:00
Gigi
e737b1f7f0 fix: position relay status indicator in bottom-left corner
- Add fixed positioning (bottom-left) to match highlight button (bottom-right)
- Add modern styling with semi-transparent background, blur, and shadow
- Ensure proper visibility on mobile with smooth transitions
- Maintain responsive behavior for expanded/collapsed states
2025-10-14 01:04:18 +02:00
Gigi
21a7be2f98 fix: unify highlight visibility button styling across app
- Remove blue primary variant from highlight filter buttons
- Use opacity (1.0 active, 0.4 inactive) instead of variant change
- Update settings to use IconButton like sidebar (DRY)
- Consistent styling: ghost variant + opacity + custom colors
- Settings buttons now match sidebar buttons exactly
2025-10-14 01:02:46 +02:00
Gigi
4c720aa049 feat: add public profile pages at /p/:npub
- Make AuthorCard clickable to navigate to user profiles
- Add /p/:npub and /p/:npub/writings routes
- Reuse Me component for public profiles with pubkey prop
- Show highlights and writings tabs for any user
- Hide private tabs (reading-list, archive) on public profiles
- Public profiles show only public data (highlights, writings)
- Private data (bookmarks, read articles) only visible on own profile
- Add clickable author card hover styles with indigo border
- Decode npub to pubkey for profile viewing
- DRY: Single Me component serves both /me and /p/:npub routes
2025-10-14 01:01:10 +02:00
Gigi
6b240b01ec docs: update changelog for v0.6.2 2025-10-14 00:54:28 +02:00
Gigi
945894e3db chore: bump version to 0.6.2 2025-10-14 00:53:17 +02:00
Gigi
667397e528 fix: align title, summary, meta, and body text in reader
- Add consistent 2rem horizontal padding to reader-header on desktop
- Apply same padding to reader-summary-below-image, article-menu-container, and mark-as-read-container
- All content elements now align properly with body text
- Mobile (< 769px) retains base padding only
2025-10-14 00:52:19 +02:00
Gigi
e4b0d6d1cd refactor: unify button styles across sidebars using IconButton
- Convert HighlightsPanel buttons to use IconButton component
- Add style prop support to IconButton for custom styling
- Remove redundant CSS for old button classes (level-toggle-btn, refresh-highlights-btn, etc.)
- Keep only highlight-level-toggles container styling
- Consistent button appearance across left and right sidebars
- DRY: Single IconButton component handles all sidebar buttons
2025-10-14 00:50:52 +02:00
Gigi
3cdda2dcb7 refactor: move bookmark refresh button to footer with view controls
- Remove separate refresh section from bookmarks list
- Add refresh button to view-mode-controls footer
- Show last update time in button tooltip instead of inline text
- Cleaner UI with all controls in one footer section
2025-10-14 00:48:47 +02:00
Gigi
876ecc808d feat: add pull-to-refresh for mobile on all scrollable views
- Create reusable usePullToRefresh hook with touch gesture detection
- Add PullToRefreshIndicator component with visual feedback
- Implement pull-to-refresh on HighlightsPanel (right sidebar)
- Implement pull-to-refresh on Explore page
- Implement pull-to-refresh on Me pages (all tabs)
- Implement pull-to-refresh on BookmarkList (left sidebar)
- Only activates on touch devices for mobile-first experience
- Shows rotating arrow icon that becomes refresh spinner
- Displays contextual messages (pull/release/refreshing)
- Integrates with existing refresh handlers and loading states
2025-10-14 00:47:48 +02:00
Gigi
34671bd067 feat: add three-dot menu for external URLs in /r/ path
- Add menu button with options to open original URL, copy URL, and share
- Reuse existing menu styling for consistency
- Menu positioned at end of article content before mark-as-read button
2025-10-14 00:39:53 +02:00
Gigi
a6285f6a1d fix(highlights): precise normalized-to-original mapping to eliminate intra-word spaces
- Walk original text across node boundaries while tracking normalized positions
- Identify exact start/end nodes and offsets for the match
- Compute combined indices from node spans to create accurate DOM Range
- Eliminates artifacts like 'We b' by preventing whitespace from splitting words
- Keeps strict bounds checks and graceful failures
2025-10-14 00:37:56 +02:00
Gigi
36508d600a fix(highlights): remove existing highlight marks before applying new ones
- Strip all existing mark elements from HTML before re-highlighting
- Prevents old broken highlights from persisting in the DOM
- Ensures clean text is used as the base for new highlight application
- Fixes 'We b' spacing issue caused by corrupted marks from previous buggy renders
- Remove debug logging now that position mapping is working correctly
2025-10-14 00:34:55 +02:00
Gigi
a304bb7c26 debug: add detailed position mapping logging to diagnose spacing issues
- Log search text, match indices, and extracted text during position mapping
- Show sample of combined text around the extracted range
- Help identify where position mapping is going wrong for 'We b' issue
2025-10-14 00:31:59 +02:00
Gigi
04bab96a07 fix(highlights): improve normalized text position mapping to prevent character spacing issues
- Build explicit position map array from normalized to original text indices
- Properly handle whitespace sequences in position mapping
- Ensure each normalized character position maps to correct original position
- Validate mapped positions are within bounds before using
- Fixes spacing issues like 'We b' appearing instead of 'Web' in highlights
2025-10-14 00:27:38 +02:00
Gigi
22ebbff755 fix(highlights): add text validation before applying highlights
- Validate extracted range text matches search text before highlighting
- Check single-node matches are not empty or whitespace-only
- Compare both exact and normalized text to handle whitespace variations
- Prevent broken/corrupted highlights from being applied to DOM
- Add detailed logging for validation failures to aid debugging
2025-10-14 00:25:18 +02:00
Gigi
b43f40597f fix(highlights): add robust validation and error handling for multi-node highlighting
- Implement proper normalized-to-original text position mapping
- Add comprehensive validation for range indices and node offsets
- Verify range is not collapsed before extracting content
- Add try-catch block to handle DOM manipulation errors gracefully
- Add detailed warning logs for debugging failed highlight matches
- Prevent invalid ranges from corrupting the DOM structure
- Fix broken text nodes and visual artifacts in highlighted content
2025-10-14 00:23:43 +02:00
Gigi
fe3af25c5f docs: update changelog for v0.6.1 release 2025-10-14 00:21:31 +02:00
Gigi
ffafc6f64d chore: bump version to 0.6.1 2025-10-14 00:20:37 +02:00
Gigi
eadab9a37f fix(highlights): restore scroll-to-highlight functionality with MutationObserver
- Add MutationObserver to detect when highlights are added/removed from DOM
- Use contentVersion state to trigger re-attachment of click handlers
- Add contentVersion dependency to scroll effect so it re-runs after DOM updates
- Add 100ms delay to scroll effect to ensure DOM is fully rendered
- Add warning log when mark element cannot be found
- Fixes issue where clicking highlights in sidebar wouldn't scroll after DOM changes
2025-10-14 00:19:45 +02:00
Gigi
13b1692931 fix(highlights): create single continuous highlight element for multi-node selections
- Use DOM Range API to extract and wrap content in a single mark element
- Preserves internal DOM structure (links, formatting) within the highlight
- Eliminates visual breaks between multiple mark elements
- Ensures highlight appears as one continuous selection even across inline elements
2025-10-14 00:18:30 +02:00
Gigi
3a78289fee fix(highlights): make multi-node highlights appear seamless
- Remove border-radius (set to 0) to eliminate rounded corner breaks
- Remove horizontal padding (only 0.1rem vertical for slight breathing room)
- Remove all box-shadows that create visual separation
- Simplify hover states for cleaner appearance
- Highlights spanning multiple DOM nodes now appear as one continuous highlight
2025-10-14 00:16:37 +02:00
Gigi
12c70b06de fix(highlights): improve text matching to handle multi-node selections
- Add tryMultiNodeMatch function to find text spanning multiple DOM nodes
- Build combined text from all text nodes for comprehensive matching
- Handle highlighting across node boundaries with proper offsets
- Falls back to multi-node matching when single-node match fails
- Fixes issue where selections with inline formatting couldn't be matched
2025-10-14 00:05:43 +02:00
Gigi
c7c82954ad feat(highlights): force synchronous render for immediate highlight display
- Use flushSync to force React to render synchronously after highlight creation
- Eliminates render cycle delay for instant visual feedback
- Highlights now appear immediately in the text when created
2025-10-14 00:03:56 +02:00
Gigi
3b639e2783 feat(highlights): clear browser selection immediately after creating highlight
- Add window.getSelection().removeAllRanges() after highlight creation
- Ensures DOM can update immediately without selection interference
- Improves perceived responsiveness of highlight creation
2025-10-14 00:03:19 +02:00
Gigi
946584236d fix(mobile): prevent horizontal overflow from code blocks and wide content
- Add mobile-specific styles for pre/code elements with word-wrap
- Use pre-wrap for code blocks to wrap long lines on mobile
- Add max-width and overflow-x constraints to main pane container
- Add overflow-x: hidden to body to prevent horizontal scrolling
- Handle tables and images with max-width: 100% on mobile
- Ensure all content respects viewport width on mobile devices
2025-10-13 23:58:39 +02:00
Gigi
aadbf2084f fix(mobile): hide sidebar/highlights toggle buttons on settings, explore, and me pages
- Only show mobile floating buttons when viewing article content
- Hide buttons on settings/explore/me views to avoid UI clutter
- Update conditional rendering logic in ThreePaneLayout
2025-10-13 23:57:06 +02:00
Gigi
3d7b649cba fix(ui): prevent long relay URLs from causing horizontal overflow on mobile
- Add relay-url className to relay URL elements
- Override inline nowrap styles on mobile with word-break: break-all
- Allow relay URLs to wrap across multiple lines on mobile
- Prevent horizontal overflow from long relay names like proxy URLs
2025-10-13 23:55:35 +02:00
Gigi
caa07012a7 fix(ui): make settings view mobile-friendly
- Add max-width: 100% and overflow handling to preview content
- Add word-break and overflow-wrap to prevent text overflow
- Make inline settings stack vertically on mobile
- Reduce padding on mobile for settings view
- Make zap preset buttons flex to fit mobile width
- Ensure all setting controls respect viewport width
- Fix preview heading size on mobile
2025-10-13 23:53:43 +02:00
Gigi
ad5cd875de feat(ui): improve zap splits settings styling
- Add styled preset buttons (Default, Generous, Selfless, Boris)
- Make sliders 100% width with full-width container
- Style active preset button with indigo-500
- Add hover effects to preset buttons and slider thumbs
- Improve slider thumb appearance with rounded design
- Style description box with proper background and borders
- Use Tailwind colors throughout (zinc, indigo)
2025-10-13 23:50:00 +02:00
Gigi
0a4bc2cfbb fix(ui): show filename for videos instead of 'Error Loading Content'
- Add getFilenameFromUrl helper to extract filename from URL
- Use filename as title when content loading fails (e.g., for video files)
- Decode URI component to handle special characters in filenames
- Improves UX for video and media file viewing
2025-10-13 23:48:11 +02:00
Gigi
605dd41939 fix(ui): render AddBookmarkModal using portal to fix z-index stacking
Use React createPortal to render modal directly to document.body, bypassing the sidebar's stacking context (z-index: 1) which was preventing the modal from appearing above other elements
2025-10-13 23:46:50 +02:00
Gigi
8679ae7a37 feat(ui): increase horizontal padding for reader text content on desktop
Add 2rem left and right padding to reader-html and reader-markdown on desktop (min-width: 769px) for better text spacing from edges
2025-10-13 23:43:28 +02:00
Gigi
3c1e4312c9 feat(me): add Writings tab to display user's published articles
- Add 'writings' tab type to Me component
- Fetch articles written by logged-in user using fetchBlogPostsFromAuthors
- Display writings in same grid style as archive tab
- Add pen-to-square icon for writings tab
- Add /me/writings route
- Update Bookmarks component to handle writings tab routing
- Show article count in tab badge
- Empty state message for users with no published articles
2025-10-13 23:42:26 +02:00
Gigi
53ed6849af feat(ui): add vertical padding to blockquotes
Add 1rem top and bottom padding to blockquotes for better spacing and visual separation
2025-10-13 23:38:14 +02:00
Gigi
4b95e6c262 feat(ui): add comprehensive list styling for articles
- Add proper ul/ol styling with disc and decimal markers
- Add 2rem left padding for list indentation
- Add proper spacing between list items (0.375rem)
- Style nested lists with circle (ul) and lower-alpha (ol)
- Reduce margins for nested lists
- Handle paragraphs within list items with reduced margins
- Use zinc-200 color for list items
- Support both markdown and HTML content
2025-10-13 23:37:32 +02:00
Gigi
40ab215c4d refactor(ui): simplify blockquote styling to minimal indent and italic
- Remove colored left border
- Remove background color
- Remove border radius and padding
- Keep only 2rem left padding for indentation
- Keep italic style for differentiation
- Cleaner, more minimal appearance
2025-10-13 23:36:28 +02:00
Gigi
823927525f feat(ui): add comprehensive headline styling with Tailwind typography
- Add proper h1-h6 styling for both markdown and HTML content
- Use Tailwind font sizes: h1 (text-4xl), h2 (text-3xl), h3 (text-2xl), h4 (text-xl), h5 (text-lg), h6 (text-base)
- Apply appropriate font weights: h1 (700), h2-h6 (600)
- Use zinc-100 for h1-h3, zinc-200 for h4-h6 for proper hierarchy
- Add proper top and bottom margins for better spacing
- Set line heights for optimal readability
2025-10-13 23:35:38 +02:00
Gigi
6277824b32 feat(ui): add blockquote styling with Tailwind colors
- Add blockquote styles for both markdown and HTML content
- Use indigo-500 left border for visual distinction
- Use zinc-800 background for subtle emphasis
- Add proper spacing and rounded corners
- Apply zinc-300 color and italic style for readability
- Properly handle nested paragraph margins
2025-10-13 23:34:30 +02:00
Gigi
f94e4ba900 feat(ui): make article titles larger and show summaries
- Increase title font size to 2.5rem (desktop) and 2rem (mobile)
- Add font-weight: 700 and better line-height to titles
- Increase summary font size to 1.2rem with better line-height
- Fix missing summary display by passing summary prop to ReaderHeader
- Improve readability and visual hierarchy of article headers
2025-10-13 23:33:23 +02:00
Gigi
acf14ccee9 feat(ui): add 100vh height and drop-shadows to sidebars
- Set sidebars to always have 100vh height on desktop
- Add drop-shadow to left sidebar (2px right shadow)
- Add drop-shadow to right highlights panel (2px left shadow)
- Improves visual separation and depth perception
2025-10-13 23:28:00 +02:00
Gigi
f882b63359 fix(ui): remove padding gaps around sidebars
Remove md:p-4 padding from root container to eliminate gaps between screen edges and sidebars
2025-10-13 23:26:25 +02:00
Gigi
7b1e3be39b fix(api): resolve TypeScript errors in video-meta.ts
- Remove non-existent getVideoDetails import from youtube-caption-extractor
- Add Subtitle type definition for proper type conversion
- Remove invalid 'auto' parameter from getSubtitles call
- Convert Subtitle[] to Caption[] with proper type casting
- Simplify title/description extraction for YouTube metadata
2025-10-13 23:23:52 +02:00
Gigi
ee17018076 docs: add comprehensive color system documentation 2025-10-13 23:19:57 +02:00
Gigi
1dd2e1dc38 refactor: switch to brighter yellow-300 for highlight defaults and add semantic color aliases 2025-10-13 23:19:33 +02:00
Gigi
4cd1aa89ad chore: remove migration plan file 2025-10-13 23:18:26 +02:00
Gigi
e667cf05c2 refactor: update default highlight color to yellow-400 in useSettings 2025-10-13 23:18:13 +02:00
Gigi
7512375728 refactor: update default highlight color to yellow-400 in ThreePaneLayout 2025-10-13 23:17:46 +02:00
Gigi
f108e2e70a refactor: replace arbitrary color values with Tailwind utilities in ThreePaneLayout 2025-10-13 23:17:21 +02:00
Gigi
daa43ec4c4 refactor: migrate global.css to Tailwind color palette 2025-10-13 23:16:51 +02:00
Gigi
ab2223e739 refactor: migrate app.css to Tailwind color palette 2025-10-13 23:16:28 +02:00
Gigi
e8cbb3af4b refactor: migrate legacy.css to Tailwind color palette 2025-10-13 23:16:06 +02:00
Gigi
f374a9af28 refactor: migrate settings.css to Tailwind color palette 2025-10-13 23:15:40 +02:00
Gigi
f2cbc66a97 refactor: migrate profile.css to Tailwind color palette 2025-10-13 23:15:21 +02:00
Gigi
1627d4f53e refactor: migrate me.css to Tailwind color palette 2025-10-13 23:14:56 +02:00
Gigi
b93a4d072a refactor: migrate reader.css to Tailwind color palette 2025-10-13 23:14:02 +02:00
Gigi
3e4bc97684 refactor: migrate toast.css to Tailwind color palette 2025-10-13 23:12:48 +02:00
Gigi
3c0c20f61c refactor: migrate modals.css to Tailwind color palette 2025-10-13 23:12:26 +02:00
Gigi
dae63e210b refactor: migrate forms.css to Tailwind color palette 2025-10-13 23:11:42 +02:00
Gigi
dc500cc296 refactor: migrate cards.css to Tailwind color palette 2025-10-13 23:11:03 +02:00
Gigi
1fc1e4f102 refactor: migrate icon-button.css to Tailwind color palette 2025-10-13 23:08:31 +02:00
Gigi
524b5e1559 refactor: migrate sidebar.css to Tailwind color palette 2025-10-13 23:07:53 +02:00
Gigi
930de76d1f refactor: migrate highlights.css to Tailwind color palette 2025-10-13 23:06:09 +02:00
Gigi
b85fc820d1 refactor: setup Tailwind color foundation with semantic aliases and updated defaults 2025-10-13 23:03:07 +02:00
Gigi
b145aee29d docs: add comprehensive Tailwind color migration plan 2025-10-13 22:54:22 +02:00
Gigi
a0e65a48f1 refactor: clean up legacy.css removing unused debugging styles 2025-10-13 22:51:49 +02:00
Gigi
ccdfc54cdc style: increase spacing between highlight card header/footer and content 2025-10-13 22:49:37 +02:00
Gigi
61ce338b8c fix: show reading progress indicator on mobile at full width 2025-10-13 22:48:39 +02:00
Gigi
47de9a75b7 style: remove rounded corners from bookmark sidebar header and fix profile avatar size 2025-10-13 22:48:23 +02:00
Gigi
607f3d46f0 fix: remove sidebar margins and constrain reading progress bar to content pane 2025-10-13 22:44:59 +02:00
Gigi
bdbc08fdf1 style: make mobile sidebar buttons more subtle and refined 2025-10-13 22:40:01 +02:00
Gigi
3a28160ae8 fix: prevent icon blurriness on mobile by setting explicit sizes 2025-10-13 22:38:36 +02:00
Gigi
e03696eed7 style: make sidebars extend edge-to-edge on desktop 2025-10-13 22:37:20 +02:00
Gigi
f80fa3de7f fix: correct highlight card borders and rounded corners 2025-10-13 22:36:04 +02:00
Gigi
4518fc16a7 docs: update changelog for v0.6.0 release 2025-10-13 22:34:09 +02:00
Gigi
7f2b70779b chore: bump version to 0.6.0 2025-10-13 22:33:18 +02:00
Gigi
cc9cc47b51 Merge pull request #5 from dergigi/reading-position
feat: reading position tracking and Tailwind CSS v4 migration
2025-10-13 22:32:52 +02:00
Gigi
a19cb8b6dc fix: remove mobile content pane gap and ensure full width display 2025-10-13 22:26:13 +02:00
Gigi
c564d1608b fix: remove padding on mobile main pane for edge-to-edge content
- Changed mobile .pane.main padding from 0.5rem to 0
- Content now extends fully edge-to-edge on mobile
- Matches design expectation for mobile reading experience
2025-10-13 22:22:24 +02:00
Gigi
c146a8f7ec style: make reading progress indicator smaller and more subtle
- Reduced bar height from 4px to 2px (h-0.5)
- Made container more compact: py-1 instead of py-2
- Tiny text size: 0.625rem (10px) with tabular numbers
- Simplified background: less opacity, lighter blur
- Show just % or checkmark when complete
- Reduced reader bottom padding from 4rem to 2rem
- More minimalist and less intrusive design
2025-10-13 22:20:01 +02:00
Gigi
48cde27a5b refactor: extract legacy styles to dedicated file
- Created src/styles/utils/legacy.css for bookmark/nostr styles
- Reduced index.css from 214 lines to 17 lines
- Fixed duplicate .loading style definitions
- DRY improvement: shared word-break pattern across nostr classes
- Better organization and maintainability
2025-10-13 22:18:12 +02:00
Gigi
fdf0644bbb refactor: massive cleanup of index.css duplicates
- Reduced from 3175 lines to 213 lines (-2960 lines!)
- Removed all reader, bookmark, highlight, settings, modal styles
- These are already imported from modular CSS files
- Only kept truly unique utility classes
- Fixes CSS duplication and specificity issues
2025-10-13 22:16:32 +02:00
Gigi
ec7371c43b fix: remove duplicate pane styles from index.css
- Removed 230+ lines of duplicate layout CSS
- Old inline styles were overriding our document scroll fixes
- Styles now only defined in src/styles/layout/app.css
- This fixes panes having overflow-y: auto and height: 100%
2025-10-13 22:14:57 +02:00
Gigi
35204ee400 fix: force document scroll with !important overrides
- Add explicit overflow: visible to main pane
- Add height: auto to main pane
- Ensure three-pane container doesn't constrain height
- Force styles to override any inherited overflow
2025-10-13 22:13:47 +02:00
Gigi
d1031b3342 fix: update Tailwind CSS import syntax for v4
- Change from @tailwind directives to @import syntax
- Move shimmer keyframes to CSS file (v4 convention)
- Fix Tailwind classes not being processed
2025-10-13 22:11:44 +02:00
Gigi
db67e94b9e fix: update PostCSS config for Tailwind v4
- Install @tailwindcss/postcss for Tailwind v4 compatibility
- Update postcss.config.js to use new plugin format
- Fix dev server PostCSS errors
2025-10-13 22:03:38 +02:00
Gigi
a0e5ba3a63 docs: add comprehensive Tailwind migration documentation
- Document completed migration phases (setup, base, layout, components)
- Track metrics: 190+ lines of CSS removed
- Define strategy for incremental component migrations
- Document z-index layering and responsive breakpoints
- Provide technical notes for future development
- Mark core migration as complete and production-ready
2025-10-13 22:00:34 +02:00
Gigi
f3f80449a6 refactor(layout): migrate mobile buttons to Tailwind utilities
- Convert mobile hamburger and highlights buttons to Tailwind
- Migrate mobile backdrop to Tailwind utilities
- Remove 60+ lines of CSS from app.css and sidebar.css
- Maintain responsive behavior and z-index layering
- Keep dynamic color support for highlight button
2025-10-13 21:58:50 +02:00
Gigi
bd0b4e848f docs: update changelog with Tailwind migration progress 2025-10-13 21:38:10 +02:00
Gigi
4f5ba99214 feat(reader): convert reading progress indicator to Tailwind
- Replace CSS classes with Tailwind utilities
- Use gradient backgrounds with conditional colors
- Add shimmer animation to Tailwind config
- Remove 80+ lines of CSS from reader.css
- Maintain z-index layering (1102) above mobile overlays
- Responsive design with utility classes
2025-10-13 21:37:08 +02:00
Gigi
aab67d8375 refactor(layout): switch to document scroll with sticky sidebars
- Remove fixed container heights from three-pane layout
- Desktop: sticky sidebars with max-height, document scrolls
- Mobile: keep fixed overlays unchanged
- Update scroll direction hook to use window scroll
- Update progress indicator z-index to 1102 (above mobile overlays)
- Apply Tailwind utilities to App container
- Maintain responsive behavior across breakpoints
2025-10-13 21:36:08 +02:00
Gigi
dbc0a48194 style(global): reconcile base styles with Tailwind preflight
- Add CSS variables for user-settable highlight colors
- Add reading font and font size variables
- Simplify global.css to work with Tailwind preflight
- Remove redundant body/root styles handled by Tailwind
- Keep app-specific overrides (mobile sidebar lock, loading states)
2025-10-13 21:18:31 +02:00
Gigi
6a84646b0b chore(tailwind): setup Tailwind CSS with preflight on
- Install tailwindcss, postcss, autoprefixer
- Add tailwind.config.js and postcss.config.js
- Create src/styles/tailwind.css with base/components/utilities
- Import Tailwind before index.css in main.tsx
2025-10-13 21:17:11 +02:00
Gigi
e921967082 fix: move progress indicator outside reader and fix position tracking
- Move ReadingProgressIndicator outside reader div for true fixed positioning
- Replace position-indicator library with custom scroll tracking
- Track document scroll position instead of content scroll
- Remove unused position-indicator dependency
- Ensure progress indicator is always visible and shows correct percentage
2025-10-13 21:04:39 +02:00
Gigi
ec34bc3d04 fix: position reading progress indicator at bottom of screen
- Move progress indicator from top to bottom of viewport
- Add box shadow for better visual separation
- Update hide animation to slide up from bottom
- Add padding to reader content to prevent overlap
- Ensure indicator is always visible while scrolling
2025-10-13 21:02:52 +02:00
Gigi
96ce12b952 feat: add reading position tracking with visual progress indicator
- Install position-indicator library for scroll position tracking
- Create useReadingPosition hook for position management
- Add ReadingProgressIndicator component with animated progress bar
- Integrate reading progress in ContentPanel for text content only
- Add CSS styles for fixed progress indicator with shimmer animation
- Track reading completion at 90% threshold
- Exclude video content from position tracking
2025-10-13 21:01:44 +02:00
Gigi
1066c43d6c docs(changelog): add v0.5.7 entry with video features and improvements 2025-10-13 20:57:33 +02:00
62 changed files with 3702 additions and 4061 deletions

View File

@@ -7,9 +7,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.2] - 2025-01-27
### Added
- Pull-to-refresh gesture on mobile for all scrollable views
- HighlightsPanel (right sidebar) - refresh highlights for current article
- Explore page - refresh blog posts from friends
- Me pages (all tabs) - refresh user data
- BookmarkList (left sidebar) - refresh bookmarks
- Touch-only activation using coarse pointer detection
- Visual indicator with rotating arrow and contextual messages
- Three-dot menu for external URLs in reader (`/r/` path)
- Open in browser, Copy URL, Share URL actions
- Consistent with article menu functionality
### Changed
- Bookmark refresh button moved to footer alongside view mode controls
- Last update time now shown in button tooltip
- Cleaner UI with all controls consolidated in footer
- Unified button styles across left and right sidebars
- All sidebar buttons now use IconButton component for consistency
- Removed 73 lines of redundant CSS for old button classes
- Highlights panel buttons match bookmark sidebar styling
### Fixed
- Reader content alignment on desktop
- Title, summary, metadata, and body text now properly aligned
- All reader elements now have consistent 2rem horizontal padding
- Mobile layout retains compact padding
- Highlight text matching with multiple improvements
- Precise normalized-to-original character position mapping
- Remove existing highlight marks before applying new ones
- Robust validation and error handling for multi-node highlights
- Prevent character spacing issues in highlighted text
- Add text validation before applying highlights
- Eliminate intra-word spaces in highlighted text
## [0.6.1] - 2025-10-13
### Added
- Writings tab on `/me` page to display user's published articles
- Comprehensive headline styling (h1-h6) with Tailwind typography
- List styling for ordered and unordered lists in articles
- Blockquote styling with indentation and italics
- Vertical padding to blockquotes for better readability
- Horizontal padding for reader text content on desktop
- Drop-shadows to sidebars for visual depth
- MutationObserver for tracking highlight DOM changes
### Changed
- Article titles now larger and more prominent
- Article summaries now display properly in reader header
- Zap splits settings UI with preset buttons and full-width sliders
- Sidebars now extend to 100vh height
- Blockquote styling simplified to minimal indent and italic
- Improved zap splits settings visual design
### Fixed
- Horizontal overflow from code blocks and wide content on mobile
- Settings view now mobile-friendly with proper width constraints
- Long relay URLs no longer cause horizontal overflow on mobile
- Sidebar/highlights toggle buttons hidden on settings/explore/me pages
- Video titles now show filename instead of 'Error Loading Content'
- AddBookmarkModal z-index issue fixed using React Portal
- Highlight matching for text spanning multiple DOM nodes/inline elements
- Highlights now appear as single continuous element across DOM nodes
- Highlights display immediately after creation with synchronous render
- Scroll-to-highlight functionality restored after DOM updates
- Padding gaps around sidebars removed
- TypeScript errors in video-meta.ts resolved
### Refactored
- Migrated entire color system to Tailwind v4 color palette
- Migrated all CSS files (sidebar, highlights, cards, forms, reader, etc.) to Tailwind colors
- Updated default highlight colors to yellow-400 for markers and yellow-300 for other contexts
- Added comprehensive color system documentation (COLOR_SYSTEM.md)
- Cleaned up legacy.css removing unused debugging styles
## [0.6.0] - 2025-10-13
### Added
- Tailwind CSS v4 integration with preflight enabled
- Reading position tracking with visual progress indicator
- Document-level scrolling with sticky sidebars on desktop
- Dedicated legacy styles file for better organization
### Changed
- Refactored layout system to use document scroll instead of pane scroll
- Migrated reading progress indicator to Tailwind utilities
- Migrated mobile buttons to Tailwind utilities
- Simplified global CSS to work with Tailwind preflight
- Updated PostCSS configuration for Tailwind v4
- Reconciled base styles with Tailwind preflight
- Reorganized and cleaned up duplicate styles in index.css
- Made reading progress indicator smaller and more subtle
### Fixed
- Reading position indicator now always visible at bottom of screen
- Progress tracking now accurately reflects reading position
- Scroll behavior consistent across desktop and mobile
- Removed padding on mobile main pane for edge-to-edge content
- Removed mobile content pane gap for better layout
- Document scroll with important overrides for consistent behavior
## [0.5.7] - 2025-01-14
### Added
- Vimeo video metadata extraction support
- YouTube video metadata extraction with title, description, and captions
- Responsive video player with aspect ratio support
- Thumbnail images in compact view
- URL routing for /me page tabs
- Bookmark navigation in reading list
- Video duration display for video URLs
- Three-dot menu for videos with open/native/copy/share actions
- External video embedding in reader using react-player
- Video detection for Vimeo, Dailymotion, and other platforms
### Changed
- Enhanced borders for reading list cards
- Reading list tab colored blue to match bookmarks icon
- Left-aligned text in reading list elements
- Increased spacing between mobile buttons and profile element
- Main pane now full width when displaying videos
- Video container breaks out of reader padding for full width
- Simplified video container layout
### Fixed
- Video player edge-to-edge display with negative margins
- Prevent profile element from bleeding off screen on mobile
- Resolved TypeScript errors in youtube-meta.ts
- Improved type safety in youtube-meta handler
- More lenient YouTube description extraction
- Corrected setTimeout ref type in Settings
- Proper react-player responsive pattern implementation
- Removed unused getIconForUrlType in CompactView
### Style
- Hide tab counts on mobile for /me page
- Remove max-width on main pane, constrain reader instead
- Full width layout for videos
- Reader-video specific styles
## [0.5.6] - 2025-10-13
### Added
- Three-dot menu for articles and enhanced highlight menus
- Prism.js syntax highlighting for code blocks
- Inline image rendering in nostr-native blog posts
@@ -17,23 +174,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Caching on `/me` page for faster loading
### Changed
- Reading List on `/me` now uses the same components as the bookmarks sidebar
- Improve bookmarks sidebar visual design
- Make article menu button more subtle by removing border
### Fixed
- Use round checkmark icon (faCheckCircle) for Mark as Read button
- Remove extra horizontal divider above article menu
- Ensure code blocks consistently use monospace fonts
- Preserve reading font settings in markdown images
### Style
- Remove horizontal divider above Mark as Read button
- Remove horizontal divider below article menu button
## [0.5.5] - 2025-01-27
### Added
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
- Two-pane layout for `/me` page with article sources and highlights
- Custom FontAwesome Pro books icon for Archive tab
@@ -41,6 +202,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Instant mark-as-read functionality with checkmark animation and read status checking
### Changed
- Rename Library tab to Archive
- Move highlight timestamp to top-right corner of cards
- Replace username with AuthorCard component on `/me` page
@@ -50,6 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Make quote icon a CompactButton in top-left corner
### Fixed
- Include currentArticle in useEffect deps to satisfy lint
- Deduplicate article events in library to prevent showing duplicates
- Remove incorrect useSettings hook usage in Me component
@@ -61,6 +224,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Position relay indicator in bottom-left corner to prevent overlap with author
### Style
- Match `/me` profile card width to highlight cards
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
- Reduce margins/paddings to make highlight cards more compact
@@ -75,6 +239,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.5.4] - 2025-10-13
### Changed
- Refactor CSS into modular structure
- Split 3600+ line monolithic `index.css` into organized modules
- Created `src/styles/` directory with base, layout, components, and utils subdirectories
@@ -83,11 +248,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- No functional changes to styling
### Fixed
- Mobile button positioning now uses safe area insets for symmetrical layout on notched devices
## [0.5.3] - 2025-10-13
### Changed
- Relay status indicator is now more compact
- Smaller padding and font sizes on desktop
- Auto-collapsed on mobile (icon-only by default, tap to expand)
@@ -95,6 +262,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Hides when scrolling down, shows when scrolling up (consistent with other mobile controls)
### Fixed
- Invalid bookmarks without IDs no longer appear in bookmark list
- Previously showed as "Now" timestamp with no content
- Bookmarks without valid IDs are now filtered out entirely
@@ -104,12 +272,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.5.2] - 2025-10-12
### Added
- Three-dot menu to highlight cards for more compact UI
- Combines "Open on Nostr" and "Delete" actions into dropdown menu
- Uses horizontal ellipsis icon (⋯)
- Click-outside functionality to close menu
### Changed
- Switch Nostr gateway from njump.me/search.dergigi.com to ants.sh
- Centralized gateway URLs in config file
- All profile and event links now use ants.sh
@@ -118,34 +288,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- "Open on Nostr" now links to the highlight event itself instead of the article
### Fixed
- Gateway URL routing for ants.sh requirements (/p/ for profiles, /e/ for events)
- Linting errors in HighlightItem component
## [0.5.1] - 2025-10-12
### Added
- Highlight color customization to UI elements
- Apply user's "my highlights" color to highlight creation buttons
- Apply highlight group colors to highlight count indicators
- Apply "my highlights" color to collapsed highlights panel button
### Fixed
- Highlight count indicator styling to match reading-time element
- Brightness and border styling for highlight count indicator
- User highlight color now applies to both marker and arrow icons
- Highlight group color properly applied to count indicator background
### Removed
- MOBILE_IMPLEMENTATION.md documentation file
## [0.5.0] - 2025-10-12
### Added
- Upgrade to full PWA with `vite-plugin-pwa`
- Replace placeholder icons with branded favicons
- Author info card for nostr-native articles
### Changed
- Explore: shrink refresh spinner footprint; inline-sized loading row
- Explore: preserve posts across navigations; seed from cache; merge streamed and final results
- Explore: keep posts visible during refresh; inline spinner; no list wipe
@@ -155,12 +331,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lint/TypeScript: satisfy react-hooks dependencies; fix worker typings; clear ESLint/TS issues
### Fixed
- Highlights: merge remote results after local for article/url
- Explore: always query remote relays after local; stream merge into UI
- Improve mobile touch targets for highlight icons
- Color `/me` highlights with "my highlights" color setting
### Performance
- Local-first then remote follow-up across services (titles, bookmarks, highlights)
- Run local and remote fetches concurrently; stream and dedupe results
- Stream contacts and early posts from local; merge remote later
@@ -168,6 +346,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stream results to UI; display cached/local immediately (articles, highlights, explore)
### Documentation
- PWA implementation summary and launch checklist updates
- Update docs to reflect branded icons and final steps
- Remove temporary PWA launch checklist and implementation summary
@@ -175,6 +354,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.3] - 2025-10-11
### Added
- Mark as read functionality for articles (NIP-25)
- Button at the end of each article to mark as read with 📚 emoji
- Creates kind:7 reactions for nostr-native articles (`/a/` paths)
@@ -199,6 +379,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevents accidental destructive actions
### Changed
- Relay status indicator on mobile now displays in compact mode
- Shows only airplane icon by default (44x44px touch target)
- Tap to expand for full connection details
@@ -209,6 +390,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.2] - 2025-10-11
### Added
- NIP-19 identifier resolution in article content (NIP-19, NIP-27)
- Support for `nostr:npub1...`, `nostr:note1...`, `nostr:nprofile1...`, `nostr:nevent1...`, `nostr:naddr1...`
- Converts nostr: URIs to clickable links with human-readable labels
@@ -223,6 +405,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable threshold and enable/disable options
### Changed
- Article references (`naddr`) now link internally to `/a/{naddr}` instead of external njump.me
- Sidebar auto-closes on mobile when navigating to content via routes
- Handles clicking on blog posts in Explore view
@@ -231,6 +414,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Article title resolution fetches titles in parallel for better performance
### Fixed
- Mobile button scroll detection now correctly monitors main pane element
- Previously monitored window scroll which didn't work on mobile
- Content scrolls within `.pane.main` div on mobile devices
@@ -243,11 +427,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.1] - 2025-10-10
### Fixed
- Long article summaries overlapping with hero image content on mobile devices
- Article summary now moves below hero image on mobile when longer than 150 characters
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
### Changed
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
- Improved image cropping with center positioning for better visual presentation
@@ -256,6 +442,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.0] - 2025-10-10
### Added
- Mobile-responsive design with overlay sidebar drawer
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
- Auto-collapse sidebar setting for mobile devices
@@ -270,6 +457,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mobile highlights panel as overlay with toggle button
### Changed
- Sidebar now displays as overlay drawer on mobile (≤768px)
- Highlights panel hidden on mobile for better content focus
- Sidebar auto-closes when selecting content on mobile
@@ -277,6 +465,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Replace hamburger icon with bookmark icon on mobile
### Fixed
- Ensure bookmarks container fills mobile sidepane properly
- Restore desktop grid layout for highlights panel
- Improve empty state and loading visibility in mobile sidepanes
@@ -288,22 +477,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.3.8] - 2025-10-10
### Fixed
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
## [0.3.7] - 2025-10-10
### Fixed
- Logout button functionality - now properly clears active account using clearActive() method
## [0.3.6] - 2025-10-10
### Added
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
- Ultra-compact date format for bookmarks sidebar
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
- Render /explore within ThreePaneLayout to keep side panels visible
### Fixed
- Remove incorrect padding-right from highlights container
- Reduce font size of highlight metadata for cleaner look
- Position highlight FAB button relative to article pane instead of viewport
@@ -315,6 +508,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Change explore header icon from compass to newspaper
### Changed
- Make connecting notification more subtle with muted blue background
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
@@ -322,20 +516,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.3.5] - 2025-10-09
### Fixed
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
- Add Cloudflare Pages routing config for SPA paths
### Changed
- Extend connecting state duration and remove subtitle text for cleaner UI
## [0.3.4] - 2025-10-09
### Fixed
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
## [0.3.3] - 2025-10-09
### Added
- Service Worker for robust offline image caching
- /explore route to discover blog posts from friends on Nostr
- Explore button (newspaper icon) in bookmarks header
@@ -343,12 +541,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Last fetch time display with relative timestamps in bookmarks list
### Changed
- Simplify image caching to use Service Worker transparently
- Move refresh button from top bar to end of bookmarks list
- Make explore page article cards proper links (supports CMD+click to open in new tab)
- Reorganize bookmarks UI for better UX
### Fixed
- Improve image cache resilience for offline viewing and hard reloads
- Correct TypeScript types for cache stats state
- Resolve linter errors for unused parameters
@@ -359,6 +559,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.3.0] - 2025-10-09
### Added
- Flight Mode with offline highlight creation and local relay support
- Automatic offline sync - rebroadcast local events when back online
- Relay indicator icon on highlight items showing sync status
@@ -371,6 +572,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 6th font size option for better UI scaling
### Fixed
- Highlight creation resilient to offline/flight mode
- TypeScript type errors in offline sync
- Relay indicator tooltip accuracy and reliability
@@ -385,6 +587,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Include local relays in relay indicator tooltip
### Changed
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
- Move publication date to top-right corner with subtle border styling
- Consolidate relay/status indicators into single unified icon
@@ -398,6 +601,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplify rebroadcast settings UI with consistent checkbox style
### Performance
- Make highlight creation instant with non-blocking relay publish
- Reduce relay status polling interval to 20 seconds
- Show sync progress and hide indicator after successful sync
@@ -405,6 +609,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.10] - 2025-10-09
### Added
- URL-based settings navigation with /settings route
- Active zap split preset highlighting
- Educational links about relays in reader view
@@ -413,10 +618,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Relays section showing active and recently connected relays
### Fixed
- Remove trailing slash from relay URLs
- Constrain Reading Font dropdown width
### Changed
- Rename 'Default View Mode' to 'Default Bookmark View' in settings
- Reorganize settings layout for better UX
- Use sidebar-style colored buttons for highlight visibility
@@ -425,26 +632,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.9] - 2025-10-09
### Fixed
- Deduplicate highlights in streaming callbacks
## [0.2.8] - 2025-10-09
### Added
- Display article summary in header
- Overlay title and metadata on hero images
- Apply reading font to article titles
### Fixed
- Pass article summary through to ReadableContent
- Correct Jina AI Reader proxy URL format
### Changed
- Update homepage URL to read.withboris.com
- Reorder toolbar buttons for better UX
## [0.2.7] - 2025-10-08
### Added
- Web bookmark creation (NIP-B0, kind:39701)
- Tags support for web bookmarks per NIP-B0
- Auto-fetch title and description when URL is pasted
@@ -455,6 +667,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Respect existing zap tags in source content when creating highlights
### Fixed
- Revert to fetchReadableContent to avoid CORS issues
- Improve modal spacing with proper box-sizing
- Prevent sliders from jumping when resetting settings
@@ -462,6 +675,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Correct type signature for addZapTags function
### Changed
- Reorder toolbar buttons for better UX
- DRY up tag extraction with normalizeTags helper
- Use url-metadata package for robust metadata extraction
@@ -472,16 +686,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.6] - 2025-10-08
### Added
- Home button to bookmark bar
- Configurable zap split for highlights on nostr-native content
## [0.2.5] - 2025-10-07
### Fixed
- Wire preview ref to markdown conversion hook
- Add missing useEffect dependencies for article loading
### Changed
- DRY up highlight classification and URL normalization
- Split highlighting utilities into modules
- Extract highlights panel components
@@ -493,14 +710,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.4] - 2025-10-07
### Added
- Domain configuration for https://xn--bris-v0b.com/
- Domain configuration for <https://xn--bris-v0b.com/>
- Public assets and deployment configuration
- Hide bookmarks without content or URL
### Fixed
- Encode/decode URLs in /r/ route to preserve special characters
### Changed
- Cleanup after build fixes (remove shims, update locks)
- Stop tracking node_modules/dist
- Update dependencies and dedupe
@@ -509,39 +729,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.3] - 2025-10-07
### Added
- Parse and display summary tag for nostr articles
- Merge and flatten bookmarks from multiple lists
- Update URL path when opening bookmarks from sidebar
### Fixed
- Ensure bookmarks are sorted newest first after merging lists
- Hide empty bookmarks without content
- Remove encrypted cyphertext display from bookmark list
### Changed
- Remove created date from bookmark list display
## [0.2.2] - 2025-10-06
### Added
- Support for web bookmarks (NIP-B0, kind:39701)
- Default highlight visibility settings
- Proxy.nostr-relay.app relay to configuration
- Comprehensive logging to settings service
### Fixed
- Handle web bookmarks with URLs in d tag and prevent crash
- Load settings from local cache first to eliminate FOUT
- Ensure fonts are fully loaded before applying styles
- Improve highlight rendering pipeline with comprehensive debugging
### Changed
- Use icon toggle buttons for highlight visibility settings
- Change nostrverse icon from fa-globe to fa-network-wired
## [0.2.1] - 2025-10-05
### Added
- Local relay support and centralize relay configuration
- Optimistic updates for highlight creation
- Enable highlight creation from external URLs
@@ -550,28 +777,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Boris branding to highlight alt tag
### Fixed
- Properly await account loading from localStorage on refresh
- Add protected routes to prevent logout on page refresh
- Use undo icon for reset to defaults button
- Update local relay port to 10547
### Changed
- Remove dedicated login page, handle login through main UI
- Simplify to single RELAYS constant (DRY)
## [0.2.0] - 2025-10-05
### Added
- Simple highlight creation feature (FAB style)
- Reset to defaults button in settings
- Load and apply settings upon login
### Fixed
- Replace any types with proper NostrEvent types
- Move FAB to Bookmarks component for proper floating
- Highlight button positioning with scroll
### Changed
- Update color palette to include default friends/nostrverse colors
- Show author name in highlight cards
- Sync highlight level toggles between sidebar and main article text
@@ -580,67 +812,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.11] - 2025-10-05
### Added
- Stream highlights progressively as they arrive from relays
### Fixed
- Display article immediately without waiting for highlights to load
- Show highlights immediately when opening panel if already loaded
- Prevent bookmark text from being cut off in compact view
- Correct default highlight color for 'mine' to yellow (#ffff00)
### Changed
- Reduce padding between bookmark items and panel edge
- Update default highlight colors to orange for friends and purple for nostrverse
## [0.1.10] - 2025-10-05
### Added
- Three-level highlight system (mine/friends/nostrverse)
### Fixed
- Ensure highlights always render on markdown content
- Classify highlights before passing to ContentPanel
- Position toggle buttons directly adjacent to main panel
- Remove redundant setReaderLoading call in error handler
### Changed
- Always show friends and user highlight buttons
- Remove Highlights title and count from panel
## [0.1.9] - 2025-10-05
### Fixed
- Show markdown content immediately when finalHtml is empty
- Prevent highlight bleeding into sidebar
## [0.1.8] - 2025-10-05
### Fixed
- Prevent 'No readable content' flash for markdown articles
- Enable highlights display and scroll-to for markdown content
### Added
- Persist accounts to localStorage
### Changed
- Simplify login by handling it directly in sidebar
## [0.1.7] - 2025-10-05
### Added
- Show highlights in article content and add mode toggle
### Fixed
- Show highlights for nostr articles by skipping URL filter
- Refresh button now works without login for article highlights
- Query highlights using both a-tag and e-tag
### Changed
- Keep Bookmarks.tsx under 210 lines by extracting logic
## [0.1.6] - 2025-10-03
### Added
- Native support for rendering Nostr long-form articles (NIP-23)
- Display article titles for kind:30023 bookmarks
- Enable clicking on kind:30023 articles to open in reader
@@ -649,10 +895,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Highlight style setting (marker & underline)
### Fixed
- Use bookmark pubkey for article author instead of tag lookup
- Ensure highlight color CSS variable inherits from parent
### Changed
- Integrate long-form article rendering into existing reader view
- Extract components to keep files under 210 lines
- Make font size and color buttons match icon button size (33px)
@@ -660,6 +908,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.5] - 2025-10-03
### Added
- Settings panel with NIP-78 storage
- Auto-save for settings with toast notifications
- Reading time estimate to articles
@@ -669,12 +918,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Settings subscription to watch for Nostr updates
### Fixed
- Prevent settings from saving unnecessarily
- Prevent save settings button from being cut off
- Replace custom reading time with reading-time-estimator package
- Update originalHtmlRef when content changes
### Changed
- Reduce file sizes to meet 210 line limit
- Extract settings logic into custom hook
- Consolidate settings initialization on login
@@ -683,6 +934,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.4] - 2025-10-03
### Added
- Inline highlight annotations in content panel
- NIP-84 highlights panel with three-pane layout
- Toggle button to show/hide highlight underlines
@@ -690,12 +942,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Pulsing animation when scrolling to highlight
### Fixed
- Apply highlights to markdown content as well as HTML
- Use requestAnimationFrame for highlight DOM manipulation
- Improve HTML highlight matching with DOM manipulation
- Filter highlights panel to show only current article
### Changed
- Use applesauce helpers for highlight parsing
- DRY up highlightMatching to stay under 210 lines
- Change highlights to fluorescent marker style
@@ -704,12 +958,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.3] - 2025-10-03
### Added
- View mode switching for bookmarks with compact list view
- Large preview view mode
- Image preview for large view cards
- Hero images using free CORS proxy
### Changed
- Make entire compact list row clickable to open reader
- Make card view timestamp clickable to open event
- Enhance card view design with modern styling
@@ -717,15 +973,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.2] - 2025-10-03
### Added
- Open bookmark URLs in reader instead of new window
- localStorage caching for fetched articles
- Collapsible bookmarks sidebar
### Fixed
- Make sidebar and reader scroll independently
- Replace relative-time with date-fns for timestamp formatting
### Changed
- Display timestamps as relative time
- Replace user text with profile image in sidebar header
- Move user info and logout to sidebar header bar
@@ -734,16 +993,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.1] - 2025-10-03
### Added
- Classify URLs by type and adjust action buttons
- Collapse/expand functionality for bookmarks sidebar
- IconButton component with square styling
- Resolve nprofile/npub mentions to names in content
### Fixed
- Enforce 210-char truncation for both plain and parsed content
- Use Rules of Hooks correctly
### Changed
- Use IconButton for all icon-only actions to enforce square sizing
- Sort bookmarks by added_at (recently added first)
- Make kind icon square to match IconButton sizing
@@ -752,6 +1014,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2025-10-03
### Added
- Two-pane layout and content fetching pipeline
- ContentPanel component to render readable HTML
- Lightweight readability fetcher via r.jina.ai proxy
@@ -761,11 +1024,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- FontAwesome icons for event kinds
### Fixed
- Show bookmarked event author instead of list signer
- Enable reactive profile fetch via address loader
- Left-align content and constrain images in content panel
### Changed
- Resolve author names using applesauce ProfileModel
- Propagate URL selection through BookmarkList to parent
- Display URLs clearly in individual bookmarks
@@ -773,16 +1038,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.0.3] - 2025-10-02
### Added
- Manual decryption for unrecognized event kinds
- Try NIP-44 then NIP-04 for manual decryption
- Detailed debugging for decryption process
- Support for hidden bookmarks decryption
### Fixed
- Surface manually decrypted hidden tags in UI
- Dedupe individual bookmarks by id
### Changed
- Sort individual bookmarks by timestamp (newest first)
- Increase bookmark loading timeout by 2x
- Extract helpers and event processing
@@ -790,6 +1058,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.0.2] - 2025-10-02
### Added
- Fetch all NIP-51 events
- Unlock private bookmarks via applesauce helpers
- Copy-to-clipboard icons for event id and author pubkey
@@ -797,12 +1066,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Display content identically for private/public bookmarks
### Fixed
- Properly configure browser extension signer
- Aggregate list(10003) + set(30001)
- Handle applesauce bookmark structure correctly
- Resolve loading state stuck issue
### Changed
- Change bookmarks display from grid to social feed list layout
- Simplify bookmark service using applesauce helpers
- Extract components and utilities to keep files under 210 lines
@@ -810,6 +1081,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.0.1] - 2025-10-02
### Added
- Initial release
- Browser extension login support
- NIP-51 bookmark fetching from nostr relays
@@ -818,12 +1090,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic UI with profile resolution
### Changed
- Migrate to applesauce-accounts for proper account management
- Use proper applesauce-loaders for NIP-51 bookmark fetching
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.2...HEAD
[0.6.2]: https://github.com/dergigi/boris/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/dergigi/boris/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/dergigi/boris/compare/v0.5.7...v0.6.0
[0.5.7]: https://github.com/dergigi/boris/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/dergigi/boris/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1
@@ -835,8 +1113,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
@@ -864,4 +1140,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[0.0.3]: https://github.com/dergigi/boris/compare/v0.0.2...v0.0.3
[0.0.2]: https://github.com/dergigi/boris/compare/v0.0.1...v0.0.2
[0.0.1]: https://github.com/dergigi/boris/releases/tag/v0.0.1

188
TAILWIND_MIGRATION.md Normal file
View File

@@ -0,0 +1,188 @@
# Tailwind CSS Migration Status
## ✅ Completed (Core Infrastructure)
### Phase 1: Setup & Foundation
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
- [x] Create `src/styles/tailwind.css` with base/components/utilities
- [x] Import Tailwind before existing CSS in `main.tsx`
- [x] Enable Tailwind preflight (CSS reset)
### Phase 2: Base Styles Reconciliation
- [x] Add CSS variables for user-settable theme colors
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
- `--reading-font`, `--reading-font-size`
- [x] Simplify `global.css` to work with Tailwind preflight
- [x] Remove redundant base styles handled by Tailwind
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
- [x] Switch from pane-scrolling to document-scrolling
- [x] Make sidebars sticky on desktop (`position: sticky`)
- [x] Update `app.css` to remove fixed container heights
- [x] Update `ThreePaneLayout.tsx` to use window scroll
- [x] Fix reading position tracking to work with document scroll
- [x] Maintain mobile overlay behavior
### Phase 4: Component Migrations
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
- Removed 80+ lines of CSS
- Added shimmer animation to Tailwind config
- Z-index layering maintained (1102)
- [x] **Mobile UI Elements**: Tailwind utilities
- Mobile hamburger button
- Mobile highlights button
- Mobile backdrop
- Removed 60+ lines of CSS
- [x] **App Container**: Tailwind utilities
- Responsive padding (p-0 md:p-4)
- Min-height viewport support
## 📊 Impact & Metrics
### Lines of CSS Removed
- `global.css`: ~50 lines removed
- `reader.css`: ~80 lines removed (progress indicator)
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
- `sidebar.css`: ~30 lines removed (mobile hamburger)
- **Total**: ~190 lines removed
### Key Achievements
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
2. **Tailwind Integration**: Fully functional with preflight enabled
3. **No Breaking Changes**: All existing functionality preserved
4. **Type Safety**: TypeScript checks passing
5. **Lint Clean**: ESLint checks passing
6. **Responsive**: Mobile/tablet/desktop layouts working
## 🔄 Remaining Work (Incremental)
The following migrations are **optional enhancements** that can be done as components are touched:
### High-Value Components
- [ ] **ContentPanel** - Large component, high impact
- Reader header, meta info, loading states
- Mark as read button
- Article/video menus
- [ ] **BookmarkList & BookmarkItem** - Core UI
- Card layouts (compact/cards/large views)
- Bookmark metadata display
- Interactive states
- [ ] **HighlightsPanel** - Feature-rich
- Header with toggles
- Highlight items
- Level-based styling
- [ ] **Settings Components** - Forms & controls
- Color pickers
- Font selectors
- Toggle switches
- Sliders
### CSS Files to Prune
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
- `src/styles/components/cards.css` - Bookmark card styles
- `src/styles/components/modals.css` - Modal dialogs
- `src/styles/layout/highlights.css` - Highlight panel layout
## 🎯 Migration Strategy
### For New Components
Use Tailwind utilities from the start. Reference:
```tsx
// Good: Tailwind utilities
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
// Avoid: New CSS classes
<div className="custom-component">
```
### For Existing Components
Migrate incrementally when touching files:
1. Replace layout utilities (flex, grid, spacing, sizing)
2. Replace color/background utilities
3. Replace typography utilities
4. Replace responsive variants
5. Remove old CSS rules
6. Keep file under 210 lines
### CSS Variable Usage
Dynamic values should still use CSS variables or inline styles:
```tsx
// User-settable colors
style={{ backgroundColor: settings.highlightColorMine }}
// Or reference CSS variable
className="bg-[var(--highlight-color-mine)]"
```
## 📝 Technical Notes
### Z-Index Layering
- Mobile sidepanes: `z-[1001]`
- Mobile backdrop: `z-[999]`
- Progress indicator: `z-[1102]`
- Mobile buttons: `z-[900]`
- Relay status: `z-[999]`
- Modals: `z-[10000]`
### Responsive Breakpoints
- Mobile: `< 768px`
- Tablet: `768px - 1024px`
- Desktop: `> 1024px`
Use Tailwind: `md:` (768px), `lg:` (1024px)
### Safe Area Insets
Mobile notch support:
```tsx
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))'
}}
```
### Custom Animations
Add to `tailwind.config.js`:
```js
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}
```
## ✅ Success Criteria Met
- [x] Tailwind CSS fully integrated and functional
- [x] Document scrolling working correctly
- [x] Reading position tracking accurate
- [x] Progress indicator always visible
- [x] No TypeScript errors
- [x] No linting errors
- [x] Mobile responsiveness maintained
- [x] Theme colors (user settings) working
- [x] All existing features functional
## 🚀 Next Steps
1. **Ship It**: Current state is production-ready
2. **Incremental Migration**: Convert components as you touch them
3. **Monitor**: Watch for any CSS conflicts
4. **Cleanup**: Eventually remove unused CSS files
5. **Document**: Update component docs with Tailwind patterns
---
**Status**: ✅ **CORE MIGRATION COMPLETE**
**Date**: 2025-01-14
**Commits**: 8 conventional commits
**Lines Removed**: ~190 lines of CSS
**Breaking Changes**: None

View File

@@ -1,8 +1,10 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { getSubtitles, getVideoDetails } from '@treeee/youtube-caption-extractor'
import { getSubtitles } from '@treeee/youtube-caption-extractor'
type Caption = { start: number; dur: number; text: string }
type Subtitle = { start: string | number; dur: string | number; text: string }
type CacheEntry = {
body: unknown
expires: number
@@ -75,9 +77,15 @@ function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo'
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
for (const lang of preferredLangs) {
try {
const caps = await getSubtitles({ videoID, lang, auto: !manualFirst ? true : false })
const caps = await getSubtitles({ videoID, lang })
if (Array.isArray(caps) && caps.length > 0) {
return { caps, lang, isAuto: !manualFirst }
// Convert the returned subtitles to our Caption format
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
text: cap.text
}))
return { caps: convertedCaps, lang, isAuto: !manualFirst }
}
} catch {
// try next
@@ -139,13 +147,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
if (videoInfo.source === 'youtube') {
// YouTube handling
const details: unknown = await getVideoDetails({ videoID: videoInfo.id, lang })
// Be tolerant to possible shapes returned by the extractor
const title = (details as { title?: string } | undefined)?.title || ''
const d1 = (details as { description?: string } | undefined)?.description
const d2 = (details as { shortDescription?: string } | undefined)?.shortDescription
const d3 = (details as { descriptionText?: string } | undefined)?.descriptionText
const description = d1 || d2 || d3 || ''
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
const title = ''
const description = ''
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))

View File

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

660
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.5.6",
"version": "0.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.5.6",
"version": "0.5.7",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -34,20 +34,37 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -3029,6 +3046,292 @@
"string.prototype.matchall": "^4.0.6"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz",
"integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.0",
"lightningcss": "1.30.1",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.14"
}
},
"node_modules/@tailwindcss/node/node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz",
"integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.5.1"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.14",
"@tailwindcss/oxide-darwin-arm64": "4.1.14",
"@tailwindcss/oxide-darwin-x64": "4.1.14",
"@tailwindcss/oxide-freebsd-x64": "4.1.14",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.14",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.14",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.14",
"@tailwindcss/oxide-linux-x64-musl": "4.1.14",
"@tailwindcss/oxide-wasm32-wasi": "4.1.14",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.14",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.14"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz",
"integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz",
"integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz",
"integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz",
"integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz",
"integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz",
"integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz",
"integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz",
"integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz",
"integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz",
"integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.5",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
"integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz",
"integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz",
"integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.14",
"@tailwindcss/oxide": "4.1.14",
"postcss": "^8.4.41",
"tailwindcss": "4.1.14"
}
},
"node_modules/@treeee/youtube-caption-extractor": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@treeee/youtube-caption-extractor/-/youtube-caption-extractor-1.5.5.tgz",
@@ -4128,6 +4431,44 @@
"node": ">= 4.0.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -4952,6 +5293,20 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -5915,6 +6270,20 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -7196,6 +7565,16 @@
"node": ">=10"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7357,6 +7736,245 @@
],
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/load-script": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
@@ -8533,6 +9151,16 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nostr-tools": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.17.0.tgz",
@@ -9010,6 +9638,13 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -10369,6 +11004,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.5.7",
"version": "0.6.4",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -37,14 +37,18 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -101,6 +101,33 @@ function AppRoutes({
/>
}
/>
<Route
path="/me/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/p/:npub/writings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -238,7 +265,7 @@ function App() {
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="app">
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
@@ -183,7 +184,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
}
}
return (
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
@@ -280,7 +281,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
</div>
</form>
</div>
</div>
</div>,
document.body
)
}

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
@@ -10,6 +10,8 @@ import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -48,6 +50,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
settings,
isMobile = false
}) => {
const bookmarksListRef = useRef<HTMLDivElement>(null)
// Pull-to-refresh for bookmarks
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
isRefreshing: isRefreshing || false,
disabled: !onRefresh
})
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
// Check if has content (text)
@@ -124,7 +139,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
</div>
)
) : (
<div className="bookmarks-list">
<div
ref={bookmarksListRef}
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={isRefreshing || false}
/>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{allIndividualBookmarks.map((individualBookmark, index) =>
<BookmarkItem
@@ -137,37 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
@@ -25,7 +26,7 @@ interface BookmarksProps {
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -37,19 +38,37 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/archive' ? 'archive' : 'highlights'
location.pathname === '/me/archive' ? 'archive' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Track previous location for going back from settings/me/explore
// Extract tab from profile routes
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
// Decode npub to pubkey for profile view
let profilePubkey: string | undefined
if (npub && showProfile) {
try {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
profilePubkey = decoded.data
}
} catch (err) {
console.error('Failed to decode npub:', err)
}
}
// Track previous location for going back from settings/me/explore/profile
useEffect(() => {
if (!showSettings && !showMe && !showExplore) {
if (!showSettings && !showMe && !showExplore && !showProfile) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings, showMe, showExplore])
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -211,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -271,6 +291,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -33,6 +33,8 @@ import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
interface ContentPanelProps {
loading: boolean
@@ -59,6 +61,9 @@ interface ContentPanelProps {
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -68,6 +73,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
markdown,
selectedUrl,
image,
summary,
published,
highlights = [],
showHighlights = true,
@@ -83,15 +89,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey,
followedPubkeys = new Set(),
onTextSelection,
onClearSelection
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
@@ -115,6 +125,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection
})
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
const { isReadingComplete, progressPercentage } = useReadingPosition({
enabled: isTextContent,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
}
}
})
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -125,15 +147,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
setShowVideoMenu(false)
}
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
setShowExternalMenu(false)
}
}
if (showArticleMenu || showVideoMenu) {
if (showArticleMenu || showVideoMenu || showExternalMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showArticleMenu, showVideoMenu])
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
@@ -260,6 +285,38 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowVideoMenu(false)
}
}
// External article actions
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
const handleOpenExternalUrl = () => {
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
setShowExternalMenu(false)
}
const handleCopyExternalUrl = async () => {
try {
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
} catch (e) {
console.warn('Clipboard copy failed', e)
} finally {
setShowExternalMenu(false)
}
}
const handleShareExternalUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowExternalMenu(false)
}
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -360,8 +417,20 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const highlightRgb = hexToRgb(highlightColor)
return (
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
<>
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
{isTextContent && (
<ReadingProgressIndicator
progress={progressPercentage}
isComplete={isReadingComplete}
showPercentage={true}
isSidebarCollapsed={isSidebarCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
/>
)}
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown
@@ -385,7 +454,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<ReaderHeader
title={ytMeta?.title || title}
image={image}
summary={undefined}
summary={summary}
published={published}
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
hasHighlights={hasHighlights}
@@ -501,6 +570,47 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
/>
)}
{/* Article menu for external URLs */}
{!isNostrArticle && !isExternalVideo && selectedUrl && (
<div className="article-menu-container">
<div className="article-menu-wrapper" ref={externalMenuRef}>
<button
className="article-menu-btn"
onClick={toggleExternalMenu}
title="More options"
>
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showExternalMenu && (
<div className="article-menu">
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original URL</span>
</button>
<button
className="article-menu-item"
onClick={handleCopyExternalUrl}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span>
</button>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
</div>
)}
</div>
</div>
)}
{/* Article menu for nostr-native articles */}
{isNostrArticle && currentArticle && articleLinks && (
<div className="article-menu-container">
@@ -567,7 +677,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<p>No readable content found for this URL.</p>
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
@@ -8,6 +8,8 @@ import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
interface ExploreProps {
relayPool: RelayPool
@@ -18,6 +20,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
useEffect(() => {
const loadBlogPosts = async () => {
@@ -116,7 +120,15 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
loadBlogPosts()
}, [relayPool, activeAccount, blogPosts.length])
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
// Pull-to-refresh
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
isRefreshing: loading
})
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -144,7 +156,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
return (
<div className="explore-container">
<div
ref={exploreContainerRef}
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />

View File

@@ -1,11 +1,13 @@
import React, { useState } from 'react'
import React, { useState, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
@@ -57,12 +59,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
const highlightsListRef = useRef<HTMLDivElement>(null)
const handleToggleHighlights = () => {
const newValue = !showHighlights
setShowHighlights(newValue)
onToggleHighlights?.(newValue)
}
// Pull-to-refresh for highlights
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
onRefresh: () => {
if (onRefresh) {
onRefresh()
}
},
isRefreshing: loading,
disabled: !onRefresh
})
// Keep track of highlight updates
React.useEffect(() => {
@@ -127,7 +141,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
</p>
</div>
) : (
<div className="highlights-list">
<div
ref={highlightsListRef}
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading}
/>
{filteredHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage } 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 { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
@@ -10,7 +10,8 @@ import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { BlogPostPreview } from '../services/exploreService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
@@ -20,24 +21,35 @@ import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { getProfileUrl } from '../config/nostrGateways'
interface MeProps {
relayPool: RelayPool
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
}
type TabType = 'highlights' | 'reading-list' | 'archive'
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const meContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Update local state when prop changes
useEffect(() => {
@@ -48,8 +60,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setError('Please log in to view your data')
if (!viewingPubkey) {
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
setLoading(false)
return
}
@@ -58,37 +70,48 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
setLoading(true)
setError(null)
// Seed from cache if available to avoid empty flash
const cached = getCachedMeData(activeAccount.pubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
// Seed from cache if available to avoid empty flash (own profile only)
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
}
// Fetch highlights and read articles
const [userHighlights, userReadArticles] = await Promise.all([
fetchHighlights(relayPool, activeAccount.pubkey),
fetchReadArticlesWithData(relayPool, activeAccount.pubkey)
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setReadArticles(userReadArticles)
setWritings(userWritings)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
// Update cache with all fetched data
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
} catch (err) {
console.error('Failed to load data:', err)
setError('Failed to load data. Please try again.')
@@ -98,14 +121,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
}
loadData()
}, [relayPool, activeAccount])
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Pull-to-refresh
const pullToRefreshState = usePullToRefresh(meContainerRef, {
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
isRefreshing: loading
})
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted
if (activeAccount) {
updateCachedHighlights(activeAccount.pubkey, updated)
// Update cache when highlight is deleted (own profile only)
if (isOwnProfile && viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated)
}
return updated
})
@@ -163,7 +194,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
// Only show full loading screen if we don't have any data yet
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
if (loading && !hasData) {
return (
@@ -190,8 +221,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
switch (activeTab) {
case 'highlights':
return highlights.length === 0 ? (
<div className="explore-error">
<p>No highlights yet. Start highlighting content to see them here!</p>
<div className="explore-empty">
<p>
{isOwnProfile
? 'No highlights yet. Start highlighting content to see them here!'
: 'No highlights yet. You should shame them on nostr!'}
</p>
</div>
) : (
<div className="highlights-list me-highlights-list">
@@ -208,7 +243,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
case 'reading-list':
return allIndividualBookmarks.length === 0 ? (
<div className="explore-error">
<div className="explore-empty">
<p>No bookmarks yet. Bookmark articles to see them here!</p>
</div>
) : (
@@ -259,7 +294,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
case 'archive':
return readArticles.length === 0 ? (
<div className="explore-error">
<div className="explore-empty">
<p>No read articles yet. Mark articles as read to see them here!</p>
</div>
) : (
@@ -274,15 +309,58 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
</div>
)
case 'writings':
return writings.length === 0 ? (
<div className="explore-empty">
<p>
{isOwnProfile
? 'No articles written yet. Publish your first article to see it here!'
: (
<>
No articles written. You can find other stuff from this user using{' '}
<a
href={viewingPubkey ? getProfileUrl(nip19.npubEncode(viewingPubkey)) : '#'}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'rgb(99 102 241)', textDecoration: 'underline' }}
>
ants
</a>
.
</>
)}
</p>
</div>
) : (
<div className="explore-grid">
{writings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<div
ref={meContainerRef}
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
>
<PullToRefreshIndicator
isPulling={pullToRefreshState.isPulling}
pullDistance={pullToRefreshState.pullDistance}
canRefresh={pullToRefreshState.canRefresh}
isRefreshing={loading && pullToRefreshState.canRefresh}
/>
<div className="explore-header">
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
{loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
@@ -294,29 +372,38 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate('/me/highlights')}
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
<span className="tab-count">({highlights.length})</span>
</button>
{isOwnProfile && (
<>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
</button>
</>
)}
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
>
<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>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>

View File

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

View File

@@ -0,0 +1,70 @@
import React from 'react'
interface ReadingProgressIndicatorProps {
progress: number // 0 to 100
isComplete?: boolean
showPercentage?: boolean
className?: string
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
}
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
progress,
isComplete = false,
showPercentage = true,
className = '',
isSidebarCollapsed = false,
isHighlightsCollapsed = false
}) => {
const clampedProgress = Math.min(100, Math.max(0, progress))
// Calculate left and right offsets based on sidebar states (desktop only)
const leftOffset = isSidebarCollapsed
? 'var(--sidebar-collapsed-width)'
: 'var(--sidebar-width)'
const rightOffset = isHighlightsCollapsed
? 'var(--highlights-collapsed-width)'
: 'var(--highlights-width)'
return (
<div
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
style={{
'--left-offset': leftOffset,
'--right-offset': rightOffset,
backgroundColor: 'var(--color-bg-elevated)',
opacity: 0.95
} as React.CSSProperties}
>
<div
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
style={{ backgroundColor: 'var(--color-border)' }}
>
<div
className={`h-full rounded-full transition-all duration-300 relative ${
isComplete
? 'bg-green-500'
: ''
}`}
style={{
width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
</div>
</div>
{showPercentage && (
<div
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : ''
}`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
>
{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
// On mobile when collapsed, make it circular like the highlight button
const isCollapsedOnMobile = isMobile && !isExpanded
return (
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
@@ -85,25 +89,75 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
) : undefined
}
onClick={handleClick}
style={{ cursor: isMobile ? 'pointer' : 'default' }}
style={{
position: 'fixed',
bottom: '32px',
left: '32px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: showDetails ? '0.5rem' : '0',
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
width: isCollapsedOnMobile ? '56px' : 'auto',
height: isCollapsedOnMobile ? '56px' : 'auto',
backgroundColor: 'rgba(39, 39, 42, 0.9)',
backdropFilter: 'blur(8px)',
border: '1px solid rgb(82, 82, 91)',
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
color: 'rgb(228, 228, 231)',
fontSize: '0.875rem',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
cursor: isMobile ? 'pointer' : 'default',
opacity: isMobile && !showOnMobile ? 0 : 1,
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
transition: 'all 0.3s ease',
userSelect: 'none',
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
}}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
{showDetails && (
<>
<div className="relay-status-text">
<div
className="relay-status-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.125rem'
}}
>
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
No relays connected
</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
<span
className="relay-status-subtitle"
style={{
fontSize: '0.75rem',
opacity: 0.7,
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
</span>
</>
)}
</div>

View File

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

View File

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

View File

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

View File

@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
<div
className="relay-url"
style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{formatRelayUrl(relay.url)}
</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

@@ -31,6 +31,7 @@ interface ThreePaneLayoutProps {
showSettings: boolean
showExplore?: boolean
showMe?: boolean
showProfile?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -89,6 +90,9 @@ interface ThreePaneLayoutProps {
// Optional Me content
me?: React.ReactNode
// Optional Profile content
profile?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -98,11 +102,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// On mobile, scroll happens in the main pane, not on window
// Now using window scroll (document scroll) instead of pane scroll
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
elementRef: mainPaneRef
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
})
const showMobileButtons = scrollDirection !== 'down'
@@ -222,38 +225,54 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
{/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : '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'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
left: 'calc(1rem + env(safe-area-inset-left))',
width: '40px',
height: '40px'
}}
onClick={props.onToggleSidebar}
aria-label="Open bookmarks"
aria-expanded={props.isSidebarOpen}
>
<FontAwesomeIcon icon={faBookmark} />
<FontAwesomeIcon icon={faBookmark} size="sm" />
</button>
)}
{/* Mobile highlights button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
<button
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : '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'
}`}
style={{
top: 'calc(1rem + env(safe-area-inset-top))',
right: 'calc(1rem + env(safe-area-inset-right))',
width: '40px',
height: '40px',
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
color: '#000'
}}
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
style={{
backgroundColor: props.settings.highlightColorMine || '#ffff00',
color: '#000'
}}
>
<FontAwesomeIcon icon={faHighlighter} />
<FontAwesomeIcon icon={faHighlighter} size="sm" />
</button>
)}
{/* Mobile backdrop */}
{isMobile && (
<div
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
@@ -305,6 +324,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.me}
</>
) : props.showProfile && props.profile ? (
// Render Profile inside the main pane to keep side panels
<>
{props.profile}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -330,6 +354,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
/>
)}
</div>

View File

@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
// Decode URI component to handle special characters
return decodeURIComponent(filename) || url
} catch {
return url
}
}
interface UseExternalUrlLoaderProps {
url: string | undefined
relayPool: RelayPool | null
@@ -84,8 +97,10 @@ export function useExternalUrlLoader({
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: 'Error Loading Content',
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})

View File

@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react'
import { flushSync } from 'react-dom'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
@@ -77,8 +78,18 @@ export const useHighlightCreation = ({
publishedRelays: newHighlight.publishedRelays
})
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
highlightButtonRef.current?.clearSelection()
onHighlightCreated(newHighlight)
// Force synchronous render to show highlight immediately
flushSync(() => {
onHighlightCreated(newHighlight)
})
} catch (error) {
console.error('❌ Failed to create highlight:', error)
// Re-throw to allow parent to handle

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react'
import { useEffect, useCallback, useRef, useState } from 'react'
interface UseHighlightInteractionsParams {
onHighlightClick?: (highlightId: string) => void
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
onClearSelection
}: UseHighlightInteractionsParams) => {
const contentRef = useRef<HTMLDivElement>(null)
const [contentVersion, setContentVersion] = useState(0)
// Watch for DOM changes (highlights being added/removed)
useEffect(() => {
if (!contentRef.current) return
const observer = new MutationObserver(() => {
// Increment version to trigger re-attachment of handlers
setContentVersion(prev => prev + 1)
})
observer.observe(contentRef.current, {
childList: true,
subtree: true,
characterData: false
})
return () => observer.disconnect()
}, [])
// Attach click handlers to highlight marks
useEffect(() => {
@@ -37,24 +56,33 @@ export const useHighlightInteractions = ({
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick])
}, [onHighlightClick, contentVersion])
// Scroll to selected highlight
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Use a small delay to ensure DOM is updated
const timeoutId = setTimeout(() => {
if (!contentRef.current) return
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId])
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
} else {
console.warn('Could not find mark element for highlight:', selectedHighlightId)
}
}, 100)
return () => clearTimeout(timeoutId)
}, [selectedHighlightId, contentVersion])
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {

View File

@@ -0,0 +1,153 @@
import { useEffect, useRef, useState, RefObject } from 'react'
import { useIsCoarsePointer } from './useMediaQuery'
interface UsePullToRefreshOptions {
onRefresh: () => void | Promise<void>
isRefreshing?: boolean
disabled?: boolean
threshold?: number // Distance in pixels to trigger refresh
resistance?: number // Resistance factor (higher = harder to pull)
}
interface PullToRefreshState {
isPulling: boolean
pullDistance: number
canRefresh: boolean
}
/**
* Hook to enable pull-to-refresh gesture on touch devices
* @param containerRef - Ref to the scrollable container element
* @param options - Configuration options
* @returns State of the pull gesture
*/
export function usePullToRefresh(
containerRef: RefObject<HTMLElement>,
options: UsePullToRefreshOptions
): PullToRefreshState {
const {
onRefresh,
isRefreshing = false,
disabled = false,
threshold = 80,
resistance = 2.5
} = options
const isTouch = useIsCoarsePointer()
const [pullState, setPullState] = useState<PullToRefreshState>({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
const touchStartY = useRef<number>(0)
const startScrollTop = useRef<number>(0)
const isDragging = useRef<boolean>(false)
useEffect(() => {
const container = containerRef.current
if (!container || !isTouch || disabled || isRefreshing) return
const handleTouchStart = (e: TouchEvent) => {
// Only start if scrolled to top
const scrollTop = container.scrollTop
if (scrollTop <= 0) {
touchStartY.current = e.touches[0].clientY
startScrollTop.current = scrollTop
isDragging.current = true
}
}
const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.current) return
const currentY = e.touches[0].clientY
const deltaY = currentY - touchStartY.current
const scrollTop = container.scrollTop
// Only pull down when at top and pulling down
if (scrollTop <= 0 && deltaY > 0) {
// Prevent default scroll behavior
e.preventDefault()
// Apply resistance to make pulling feel natural
const distance = Math.min(deltaY / resistance, threshold * 1.5)
const canRefresh = distance >= threshold
setPullState({
isPulling: true,
pullDistance: distance,
canRefresh
})
} else {
// Reset if scrolled or pulling up
isDragging.current = false
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
}
const handleTouchEnd = async () => {
if (!isDragging.current) return
isDragging.current = false
if (pullState.canRefresh && !isRefreshing) {
// Keep the indicator visible while refreshing
setPullState(prev => ({
...prev,
isPulling: false
}))
// Trigger refresh
await onRefresh()
}
// Reset state
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
const handleTouchCancel = () => {
isDragging.current = false
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
// Add event listeners with passive: false to allow preventDefault
container.addEventListener('touchstart', handleTouchStart, { passive: true })
container.addEventListener('touchmove', handleTouchMove, { passive: false })
container.addEventListener('touchend', handleTouchEnd, { passive: true })
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
return () => {
container.removeEventListener('touchstart', handleTouchStart)
container.removeEventListener('touchmove', handleTouchMove)
container.removeEventListener('touchend', handleTouchEnd)
container.removeEventListener('touchcancel', handleTouchCancel)
}
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
// Reset pull state when refresh completes
useEffect(() => {
if (!isRefreshing && pullState.isPulling) {
setPullState({
isPulling: false,
pullDistance: 0,
canRefresh: false
})
}
}, [isRefreshing, pullState.isPulling])
return pullState
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react'
interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => void
onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
useEffect(() => {
if (!enabled) return
const handleScroll = () => {
// Get the main content area (reader content)
const readerContent = document.querySelector('.reader-html, .reader-markdown')
if (!readerContent) return
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Calculate position based on how much of the content has been scrolled through
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
setPosition(clampedProgress)
onPositionChange?.(clampedProgress)
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
}
}
// Initial calculation
handleScroll()
// Add scroll listener
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
// Reset reading complete state when enabled changes
useEffect(() => {
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
}
}, [enabled])
return {
position,
isReadingComplete,
progressPercentage: Math.round(position * 100)
}
}

View File

@@ -5,6 +5,7 @@ import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays'
interface UseSettingsParams {
@@ -47,7 +48,14 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
// Apply theme with color variants (defaults to 'system' if not set)
applyTheme(
settings.theme ?? 'system',
settings.darkColorTheme ?? 'midnight',
settings.lightColorTheme ?? 'sepia'
)
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
@@ -61,7 +69,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#fde047')
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './styles/tailwind.css'
import './index.css'
// Register Service Worker for PWA functionality

View File

@@ -47,6 +47,10 @@ export interface UserSettings {
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
// Mobile settings
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
// Theme preference
theme?: 'dark' | 'light' | 'system' // default: system
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
}
export async function loadSettings(

View File

@@ -1,17 +1,13 @@
/* Global element styles and app container */
/* Global element styles and app container (Tailwind-compatible) */
/* Body - keep only app-specific overrides */
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--color-bg-subtle);
color: var(--color-text);
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Use dynamic viewport height if supported */
@supports (height: 100dvh) {
body {
min-height: 100dvh;
}
overflow-x: hidden;
max-width: 100vw;
}
body.mobile-sidebar-open {
@@ -20,49 +16,15 @@ body.mobile-sidebar-open {
width: 100%;
}
#root {
max-width: none;
margin: 0;
padding: 1rem;
}
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app {
text-align: center;
position: relative;
}
.app header {
margin-bottom: 2rem;
}
.app header h1 {
font-size: 2.5rem;
margin: 0;
color: #646cff;
}
.app header p {
margin: 0.5rem 0 0 0;
color: #888;
}
.loading {
text-align: center;
padding: 2rem;
color: #ccc;
}
/* App loading states */
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: var(--color-text);
}

View File

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

View File

@@ -1,14 +1,14 @@
/* Bookmark item and blog post cards */
.bookmark-item { background: #1a1a1a; padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.bookmark-item { background: var(--color-bg); padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
.bookmark-item:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); }
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: #fff; font-size: 1.2rem; }
.bookmark-url { color: #646cff; text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: var(--color-text); font-size: 1.2rem; }
.bookmark-url { color: var(--color-primary); text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-url:hover { text-decoration: underline; }
.bookmark-content { color: #ccc; margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: #888; font-size: 0.9rem; margin-top: 0.5rem; }
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
.individual-bookmarks { margin: 1rem 0; }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: #fff; }
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
@@ -19,74 +19,75 @@
.bookmarks-grid.bookmarks-large { gap: 1rem; }
}
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid #2a2a2a; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
.individual-bookmark:hover { border-color: #3a3a3a; background: #252525; }
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid var(--color-bg-elevated); word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid #2a2a2a; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: #252525; border-bottom-color: #333; transform: none; box-shadow: none; }
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); transform: none; box-shadow: none; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.compact-row.clickable { cursor: pointer; }
.compact-row.clickable:active { opacity: 0.8; }
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: #646cff; font-size: 0.85rem; flex-shrink: 0; }
.compact-text { flex: 1; min-width: 0; color: #ccc; font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-date-compact { font-size: 0.7rem; color: #666; flex-shrink: 0; white-space: nowrap; }
.compact-read-btn { background: transparent; color: #888; border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
.compact-read-btn:hover { color: #ccc; }
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: var(--color-primary); font-size: 0.85rem; flex-shrink: 0; }
.compact-text { flex: 1; min-width: 0; color: var(--color-text); font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-date-compact { font-size: 0.7rem; color: var(--color-text-muted); flex-shrink: 0; white-space: nowrap; }
.compact-read-btn { background: transparent; color: var(--color-text-secondary); border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
.compact-read-btn:hover { color: var(--color-text); }
.compact-read-btn:active { transform: translateY(1px); }
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
.bookmark-type { color: #646cff; font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: #888; background: #1a1a1a; padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: #666; }
.bookmark-date-link { font-size: 0.8rem; color: #666; text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: #8ab4f8; text-decoration: underline; }
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: #ccc; line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: #888; cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
.expand-toggle:hover { color: #bbb; }
.bookmark-type { color: var(--color-primary); font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: var(--color-text-secondary); background: var(--color-bg); padding: 0.25rem 0.5rem; border-radius: 4px; }
.bookmark-date { font-size: 0.8rem; color: var(--color-text-muted); }
.bookmark-date-link { font-size: 0.8rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.2s ease; }
.bookmark-date-link:hover { color: var(--color-primary); text-decoration: underline; }
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: var(--color-text); line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: var(--color-text-secondary); cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
.expand-toggle:hover { color: var(--color-text); }
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
.bookmark-meta-minimal { font-size: 0.8rem; color: #888; }
.author-link-minimal { color: #888; text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: #aaa; }
.read-now-button-minimal { background: #646cff; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
.read-now-button-minimal:hover { background: #535bf2; }
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: #646cff; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: #8088ff; }
.bookmark-meta-minimal { font-size: 0.8rem; color: var(--color-text-secondary); }
.author-link-minimal { color: var(--color-text-secondary); text-decoration: none; transition: color 0.2s ease; }
.author-link-minimal:hover { color: var(--color-text); }
.read-now-button-minimal { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
.read-now-button-minimal:hover { background: var(--color-primary-hover); }
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: var(--color-primary); cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
.expand-toggle-urls:hover { color: var(--color-primary-hover); }
/* Large preview view */
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid #2a2a2a; }
.large-preview-image { width: 100%; height: 180px; background: #1a1a1a; background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid #333; position: relative; }
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
.large-preview-image:hover { opacity: 0.9; }
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
.preview-placeholder { font-size: 3rem; color: #444; }
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); }
.large-content { padding: 1.25rem; }
.large-text { color: #ccc; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: #888; padding-top: 0.75rem; border-top: 1px solid #333; }
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
.large-author { flex: 1; }
.large-read-button { background: #646cff; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
.large-read-button:hover { background: #535bf2; }
.large-read-button { background: var(--color-primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
.large-read-button:hover { background: var(--color-primary-hover); }
/* Blog cards (Explore) */
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
.explore-header { text-align: center; margin-bottom: 3rem; }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: #646cff; display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
.explore-loading, .explore-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: rgba(255, 255, 255, 0.7); }
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--color-primary); display: flex; align-items: center; justify-content: center; gap: 1rem; }
.explore-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; }
.explore-loading, .explore-error, .explore-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: var(--color-text-secondary); }
.explore-loading { min-height: 0; padding: 0.25rem 0; }
.explore-error { color: #ff6b6b; }
.explore-error { color: rgb(239 68 68); /* red-500 */ }
.explore-empty { color: var(--color-text-secondary); }
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
.blog-post-card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: #646cff; transform: translateY(-4px); box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: #0f0f0f; display: flex; align-items: center; justify-content: center; }
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
.blog-post-card:hover { border-color: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
.blog-post-image-placeholder { font-size: 3rem; color: #444; display: flex; align-items: center; justify-content: center; }
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); display: flex; align-items: center; justify-content: center; }
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: rgba(255, 255, 255, 0.95); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-post-card-summary { font-size: 0.875rem; color: rgba(255, 255, 255, 0.6); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid #333; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; }
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text-muted); flex-wrap: wrap; }
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
@media (max-width: 768px) {
@@ -97,4 +98,3 @@
.blog-post-card-content { padding: 1rem; }
}

View File

@@ -4,27 +4,80 @@
.setting-label { text-align: left; flex: 1; }
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
.setting-group.setting-inline label { margin-bottom: 0; }
.setting-group label { display: block; margin-bottom: 0.5rem; color: #ccc; font-weight: 500; text-align: left; }
.setting-group label { display: block; margin-bottom: 0.5rem; color: var(--color-text); font-weight: 500; text-align: left; }
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
.color-swatch { width: 33px; height: 33px; border: 1px solid #444; border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
.color-swatch:hover { border-color: #888; }
.color-swatch.active { border-color: #646cff; box-shadow: 0 0 0 2px #646cff; }
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #000; font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px #fff; }
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid #444; border-radius: 6px; color: #ccc; cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.font-size-btn:hover { background: #333; border-color: #666; }
.font-size-btn.active { background: #646cff; border-color: #646cff; color: white; }
.setting-preview { margin: 1.5rem 0; padding: 1rem; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; }
.preview-label { font-size: 0.875rem; color: #999; margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-content { color: #ddd; line-height: 1.7; }
.preview-content h3 { margin: 0 0 1rem 0; font-size: 1.5em; color: #fff; }
.preview-content p { margin: 0.75rem 0; }
.setting-select { width: 100%; padding: 0.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 1rem; }
.color-swatch { width: 33px; height: 33px; border: 1px solid var(--color-border-subtle); border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
.color-swatch:hover { border-color: var(--color-text-secondary); }
.color-swatch.active { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary); }
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); }
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.font-size-btn:hover { background: var(--color-border); border-color: var(--color-text-muted); }
.font-size-btn.active { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.setting-preview {
margin: 1.5rem 0;
padding: 1rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
max-width: 100%;
overflow: hidden;
}
.preview-label { font-size: 0.875rem; color: var(--color-text-secondary); margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-content {
color: var(--color-text);
line-height: 1.7;
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.preview-content h3 {
margin: 0 0 1rem 0;
font-size: 1.5em;
color: var(--color-text);
word-wrap: break-word;
}
.preview-content p {
margin: 0.75rem 0;
word-wrap: break-word;
}
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
.setting-select:focus { outline: none; border-color: #646cff; }
.setting-select:focus { outline: none; border-color: var(--color-primary); }
.font-select option { padding: 0.5rem; font-size: 1rem; }
.checkbox-label { display: flex !important; align-items: center; gap: 0.75rem; cursor: pointer; user-select: none; text-align: left; justify-content: flex-start; margin-bottom: 0 !important; font-weight: normal !important; }
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: #646cff; }
.checkbox-label span { color: #ddd; text-align: left; font-weight: 500; }
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: var(--color-primary); }
.checkbox-label span { color: var(--color-text); text-align: left; font-weight: 500; }
/* Mobile responsive styles */
@media (max-width: 768px) {
.setting-group.setting-inline {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.setting-inline .setting-select {
width: 100%;
min-width: unset;
}
.setting-control {
width: 100%;
justify-content: flex-start;
}
.setting-buttons {
flex-wrap: wrap;
}
.color-picker {
flex-wrap: wrap;
}
.preview-content h3 {
font-size: 1.25em;
}
}

View File

@@ -3,10 +3,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #444;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
background: #2a2a2a;
color: #ddd;
background: var(--color-bg-elevated);
color: var(--color-text);
cursor: pointer;
min-width: 33px;
min-height: 33px;
@@ -14,16 +14,16 @@
box-sizing: border-box;
}
.icon-button:hover { background: #333; }
.icon-button:hover { background: var(--color-border); }
.icon-button:active { transform: translateY(1px); }
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
.icon-button.primary { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.icon-button.primary:hover { filter: brightness(1.05); }
.icon-button.success { background: #646cff; color: white; border-color: #646cff; }
.icon-button.success { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.icon-button.success:hover { filter: brightness(1.1); }
.icon-button.ghost { background: #2a2a2a; }
.icon-button.ghost { background: var(--color-bg-elevated); }
/* Mobile touch target improvements */
@media (max-width: 768px) {
@@ -31,13 +31,19 @@
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
}
/* Keep icon size consistent to prevent blurriness */
.icon-button svg {
font-size: 1rem;
width: 1em;
height: 1em;
}
}
/* Disable hover effects on touch devices */
@media (pointer: coarse) {
.icon-button:hover { background: #2a2a2a; }
.icon-button.ghost:hover { background: #2a2a2a; }
.icon-button:active { background: #333; }
.icon-button:hover { background: var(--color-bg-elevated); }
.icon-button.ghost:hover { background: var(--color-bg-elevated); }
.icon-button:active { background: var(--color-border); }
}

View File

@@ -3,7 +3,7 @@
display: flex;
gap: 0.5rem;
margin-top: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
max-width: 600px;
margin-left: auto;
@@ -18,7 +18,7 @@
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, #999);
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
@@ -28,25 +28,32 @@
}
.me-tab:hover {
color: var(--text-primary, #ddd);
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
background: var(--color-bg-elevated);
}
.me-tab.active {
color: var(--primary-color, #8b5cf6);
border-bottom-color: var(--primary-color, #8b5cf6);
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* Highlights tab uses the user's custom "my highlights" color */
/* Highlights tab uses the actual highlight style when active */
.me-tab[data-tab="highlights"].active {
color: var(--highlight-color-mine, #ffff00);
border-bottom-color: var(--highlight-color-mine, #ffff00);
color: var(--color-text);
border-bottom-color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 20%, transparent);
}
.me-tab[data-tab="highlights"].active:hover {
background: color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, rgb(253 224 71)) 30%, transparent);
}
/* Reading List tab uses blue color to match bookmarks icon */
.me-tab[data-tab="reading-list"].active {
color: #646cff;
border-bottom-color: #646cff;
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.me-tab svg {
@@ -80,25 +87,25 @@
/* Enhanced border styling for reading list cards */
.bookmarks-list .individual-bookmark {
border: 1px solid #444 !important;
background: #1a1a1a !important;
border: 1px solid var(--color-border-subtle) !important;
background: var(--color-bg) !important;
}
.bookmarks-list .individual-bookmark:hover {
border-color: #555 !important;
background: #252525 !important;
border-color: var(--color-border) !important;
background: var(--color-bg-elevated) !important;
}
.bookmark-item {
padding: 1rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
transition: all 0.2s ease;
}
.bookmark-item:hover {
border-color: var(--primary-color, #8b5cf6);
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
@@ -110,13 +117,13 @@
.bookmark-item h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
color: var(--text-primary, #000);
color: var(--color-text);
}
.bookmark-item p {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary, #666);
color: var(--color-text-secondary);
line-height: 1.5;
}

View File

@@ -1,28 +1,27 @@
/* Add Bookmark Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
.modal-content { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
.modal-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
@media (max-width: 768px) {
.modal-overlay { padding: 0; align-items: flex-end; }
.modal-content { max-width: 100%; max-height: 95vh; max-height: 95dvh; border-radius: 16px 16px 0 0; margin: 0; padding-bottom: var(--safe-area-bottom); }
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid #333; }
.modal-header h2 { margin: 0; font-size: 1.5rem; color: #fff; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid var(--color-border); }
.modal-header h2 { margin: 0; font-size: 1.5rem; color: var(--color-text); }
.modal-form { padding: 1.5rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: #ccc; font-size: 0.9rem; font-weight: 500; }
.fetching-indicator { font-size: 0.8rem; color: #999; font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #646cff; }
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: var(--color-text); font-size: 0.9rem; font-weight: 500; }
.fetching-indicator { font-size: 0.8rem; color: var(--color-text-secondary); font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--color-primary); }
.form-group input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
.form-group textarea { resize: vertical; min-height: 80px; }
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: #999; line-height: 1.4; }
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid #dc3545; border-radius: 6px; color: #dc3545; font-size: 0.9rem; margin-bottom: 1rem; }
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: var(--color-text-secondary); line-height: 1.4; }
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); border-radius: 6px; color: rgb(220 38 38); font-size: 0.9rem; margin-bottom: 1rem; }
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
.btn-secondary { padding: 0.75rem 1.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #ccc; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-secondary:hover:not(:disabled) { background: #333; border-color: #646cff; color: white; }
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-text); font-size: 1rem; cursor: pointer; transition: all 0.2s; }
.btn-secondary:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-primary); color: var(--color-text); }
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary { padding: 0.75rem 1.5rem; background: #646cff; border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
.btn-primary:hover:not(:disabled) { background: #535bf2; }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--color-primary); border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }

View File

@@ -1,12 +1,14 @@
/* Profile UI fragments */
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 600px; width: 100%; }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; color: #666; }
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
.author-card-clickable:hover { border-color: var(--color-primary); background: var(--color-bg-elevated); transform: translateY(-1px); }
.author-card-clickable:active { transform: translateY(0); }
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); }
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
.author-card-avatar svg { font-size: 2.5rem; }
.author-card-content { flex: 1; min-width: 0; text-align: left; }
.author-card-name { font-size: 1rem; font-weight: 600; color: #ddd; margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: #999; line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
.author-card-name { font-size: 1rem; font-weight: 600; color: var(--color-text); margin-bottom: 0.5rem; text-align: left; }
.author-card-bio { font-size: 0.9rem; color: var(--color-text-secondary); line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
@media (max-width: 768px) {
.author-card-container {
@@ -26,4 +28,3 @@
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
}

View File

@@ -0,0 +1,53 @@
/* Pull-to-refresh indicator styles */
.pull-to-refresh-indicator {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
z-index: 100;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.pull-to-refresh-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-elevated);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
font-size: 1rem;
color: var(--color-text-secondary);
}
.pull-to-refresh-text {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: center;
white-space: nowrap;
font-weight: 500;
background: var(--color-bg-elevated);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Container needs relative positioning for absolute indicator */
.pull-to-refresh-container {
position: relative;
}
/* Ensure smooth transitions during pull */
.pull-to-refresh-container.is-pulling {
overflow: visible;
}

View File

@@ -1,13 +1,14 @@
/* Reader view */
.reader {
background: #1a1a1a;
border: 1px solid #333;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem;
text-align: left;
overflow: hidden;
max-width: 900px;
margin: 0 auto;
padding-bottom: 2rem; /* Add space for progress indicator */
}
/* Video container - responsive wrapper following react-player docs */
@@ -18,61 +19,193 @@
max-width: 1000px; /* Maximum width */
aspect-ratio: 16/9;
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
background: #000;
background: rgb(0 0 0); /* black */
}
.reader.empty { color: #888; }
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: #888; }
.reader.empty { color: var(--color-text-secondary); }
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
.loading-spinner svg { font-size: 1.2rem; }
.reader-header { margin-bottom: 2rem; position: relative; }
.reader-title { margin: 0 0 0.75rem 0; font-family: var(--reading-font); }
.reader-summary { color: #aaa; font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; font-family: var(--reading-font); }
.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: var(--color-text); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; font-family: var(--reading-font); }
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: rgba(136, 136, 136, 0.7); opacity: 0.85; }
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); opacity: 0.85; }
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: #fff; padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(136, 136, 136, 0.1); border: 1px solid rgba(136, 136, 136, 0.3); border-radius: 6px; font-size: 0.875rem; color: #888; }
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(100, 108, 255, 0.1); border: 1px solid rgba(100, 108, 255, 0.3); border-radius: 6px; font-size: 0.875rem; color: #646cff; }
.highlight-indicator { 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-primary); }
.highlight-indicator svg { font-size: 0.875rem; }
.reader-html { color: #ddd; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: #ddd; line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure content is left-aligned even if source markup uses center */
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
.reader center, .reader [align="center"] { text-align: left !important; }
/* Tame images from external content */
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
.reader-markdown h1, .reader-markdown h2, .reader-markdown h3, .reader-markdown h4 { margin-top: 1.2rem; }
/* Headlines with Tailwind typography */
.reader-markdown h1, .reader-html h1 {
font-size: 2.25rem; /* text-4xl */
font-weight: 700;
line-height: 1.2;
margin-top: 2rem;
margin-bottom: 1rem;
color: var(--color-text);
}
.reader-markdown h2, .reader-html h2 {
font-size: 1.875rem; /* text-3xl */
font-weight: 600;
line-height: 1.3;
margin-top: 1.75rem;
margin-bottom: 0.875rem;
color: var(--color-text);
}
.reader-markdown h3, .reader-html h3 {
font-size: 1.5rem; /* text-2xl */
font-weight: 600;
line-height: 1.4;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--color-text);
}
.reader-markdown h4, .reader-html h4 {
font-size: 1.25rem; /* text-xl */
font-weight: 600;
line-height: 1.4;
margin-top: 1.25rem;
margin-bottom: 0.625rem;
color: var(--color-text);
}
.reader-markdown h5, .reader-html h5 {
font-size: 1.125rem; /* text-lg */
font-weight: 600;
line-height: 1.4;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
.reader-markdown h6, .reader-html h6 {
font-size: 1rem; /* text-base */
font-weight: 600;
line-height: 1.4;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
.reader-markdown p { margin: 0.5rem 0; }
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
.reader-markdown a { color: #8ab4f8; text-decoration: none; }
/* Lists */
.reader-markdown ul, .reader-html ul {
list-style-type: disc;
margin: 1rem 0;
padding-left: 2rem;
}
.reader-markdown ol, .reader-html ol {
list-style-type: decimal;
margin: 1rem 0;
padding-left: 2rem;
}
.reader-markdown li, .reader-html li {
margin: 0.375rem 0;
line-height: 1.6;
color: var(--color-text);
}
.reader-markdown ul ul, .reader-markdown ol ul, .reader-html ul ul, .reader-html ol ul {
list-style-type: circle;
margin: 0.25rem 0;
}
.reader-markdown ul ol, .reader-markdown ol ol, .reader-html ul ol, .reader-html ol ol {
list-style-type: lower-alpha;
margin: 0.25rem 0;
}
.reader-markdown li p, .reader-html li p {
margin: 0.25rem 0;
}
.reader-markdown blockquote, .reader-html blockquote {
margin: 1.5rem 0;
padding: 1rem 0 1rem 2rem;
font-style: italic;
}
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
.reader-markdown a:hover { text-decoration: underline; }
.reader-markdown code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-markdown pre code { background: transparent; border: none; padding: 0; font-size: 0.9em; display: block; }
/* Prism.js enhancements */
.reader-markdown pre[class*="language-"] { background: #1e1e1e; border: 1px solid #333; }
.reader-markdown pre[class*="language-"] { background: var(--color-bg-subtle); border: 1px solid var(--color-border); }
.reader-markdown code[class*="language-"] { background: transparent; text-shadow: none; }
.reader-html pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html pre { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
/* Mobile: prevent code blocks from causing horizontal overflow */
@media (max-width: 768px) {
.reader-markdown pre, .reader-html pre {
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.reader-markdown code, .reader-html code {
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Also handle tables and other wide elements */
.reader-markdown table, .reader-html table {
display: block;
max-width: 100%;
overflow-x: auto;
}
.reader-markdown img, .reader-html img {
max-width: 100%;
height: auto;
}
}
/* Desktop: increase horizontal padding for text content */
@media (min-width: 769px) {
.reader-header,
.reader-summary-below-image,
.reader-html,
.reader-markdown,
.article-menu-container,
.mark-as-read-container {
padding-left: 2rem;
padding-right: 2rem;
}
}
/* Article menu */
.article-menu-container { display: flex; justify-content: flex-end; padding: 1.5rem 0 0.5rem; margin-top: 2rem; }
.article-menu-wrapper { position: relative; }
.article-menu-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: #646cff; background: rgba(100, 108, 255, 0.1); }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
.article-menu-btn { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: var(--color-primary); background: rgba(99, 102, 241, 0.1); }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
/* Mark as Read button */
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 1rem; }
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: #2a2a2a; color: #ddd; border: 1px solid #444; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
.mark-as-read-btn:hover:not(:disabled) { background: #333; border-color: #555; transform: translateY(-1px); }
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: var(--color-bg-elevated); color: var(--color-text); border: 1px solid var(--color-border-subtle); border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
.mark-as-read-btn:hover:not(:disabled) { background: var(--color-border); border-color: var(--color-text-muted); transform: translateY(-1px); }
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mark-as-read-btn svg { font-size: 1.1rem; }
@media (max-width: 768px) {
.reader {
max-width: 100%;
width: 100%;
margin: 0;
padding: 0.5rem;
border-radius: 0;
border-left: none;
border-right: none;
}
.mark-as-read-container { padding: 1.5rem 1rem; }
.mark-as-read-btn { width: 100%; max-width: 300px; }
}
@@ -84,8 +217,8 @@
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; }
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
.reader-header-overlay .reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.reader-header-overlay .publish-date { color: rgba(255, 255, 255, 0.65); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); opacity: 1; }
.reader-header-overlay .publish-date svg { opacity: 0.7; }
@@ -95,11 +228,12 @@
@media (max-width: 768px) {
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
.reader-summary-below-image .reader-summary { color: #aaa; font-size: 1rem; line-height: 1.6; margin: 0; }
.reader-summary-below-image .reader-summary { color: var(--color-text-secondary); font-size: 1rem; line-height: 1.6; margin: 0; }
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
.reader-header-overlay .reader-title { font-size: 2rem; line-height: 1.3; }
}
/* Reading Progress Indicator - now using Tailwind utilities in component */

View File

@@ -6,10 +6,143 @@
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
.settings-section { margin-bottom: 2.5rem; }
.settings-section:last-child { margin-bottom: 0; }
.section-title { font-size: 1rem; font-weight: 600; color: #fff; margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid #333; text-transform: uppercase; letter-spacing: 0.05em; }
.section-title { font-size: 1rem; font-weight: 600; color: var(--color-text); margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border); text-transform: uppercase; letter-spacing: 0.05em; }
.settings-footer { display: flex; justify-content: flex-start; padding: 1rem 0 0.5rem 0; flex-shrink: 0; }
.settings-footer .btn-primary { background: #646cff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; gap: 0.5rem; }
.settings-footer .btn-primary:hover:not(:disabled) { background: #535bf2; }
.settings-footer .btn-primary { background: var(--color-primary); color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; gap: 0.5rem; }
.settings-footer .btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
/* Setting groups */
.setting-group { margin-bottom: 1.5rem; }
.setting-label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; font-weight: 500; color: var(--color-text); }
/* Zap splits preset buttons */
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.zap-preset-btn {
padding: 0.625rem 1.25rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.zap-preset-btn:hover {
background: var(--color-border);
border-color: var(--color-primary);
color: var(--color-text);
}
.zap-preset-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: rgb(255 255 255); /* white */
}
/* Zap split sliders */
.zap-split-container { width: 100%; }
.zap-split-labels {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.zap-split-label { font-weight: 500; }
.zap-split-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--color-bg-elevated);
outline: none;
-webkit-appearance: none;
}
.zap-split-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.zap-split-slider::-webkit-slider-thumb:hover {
background: var(--color-primary-hover);
transform: scale(1.1);
}
.zap-split-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.zap-split-slider::-moz-range-thumb:hover {
background: var(--color-primary-hover);
transform: scale(1.1);
}
.zap-split-description {
margin-top: 1.5rem;
padding: 1rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
/* Relay items */
.relay-item {
max-width: 100%;
}
.relay-url {
max-width: 100%;
}
/* Mobile responsive styles */
@media (max-width: 768px) {
.settings-view {
padding: 0.5rem;
max-width: 100vw;
overflow-x: hidden;
}
.settings-content {
padding: 0 0.25rem 2rem 0.25rem;
max-width: 100%;
}
.settings-section {
max-width: 100%;
overflow-x: hidden;
}
.zap-preset-buttons {
width: 100%;
}
.zap-preset-btn {
flex: 1 1 auto;
min-width: 70px;
}
.relay-item {
max-width: 100%;
overflow-x: hidden;
}
.relay-url {
white-space: normal !important;
word-break: break-all !important;
overflow: visible !important;
text-overflow: unset !important;
line-height: 1.4;
}
}

View File

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

View File

@@ -18,51 +18,79 @@
.two-pane.sidebar-collapsed { grid-template-columns: 60px 1fr; }
/* Three-pane layout */
/* Three-pane layout - document scroll, sticky sidebars */
.three-pane {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
column-gap: 0;
height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease;
position: relative;
}
@supports (height: 100dvh) {
.three-pane { height: calc(100dvh - 2rem); }
min-height: 100vh;
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.three-pane.sidebar-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width); }
.three-pane.highlights-collapsed { grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width); }
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); }
/* Mobile three-pane layout */
@media (max-width: 768px) {
.three-pane {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
/* Desktop: sticky sidebars, document scroll */
@media (min-width: 769px) {
.pane.sidebar {
position: sticky;
top: 0;
height: 100vh;
height: 100dvh;
max-height: 100vh;
overflow-y: auto;
align-self: start;
margin: 0;
padding: 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
}
.pane.main {
margin: 0 auto;
padding: 0 var(--main-horizontal-padding);
min-height: 100vh;
overflow: visible !important;
height: auto !important;
}
.pane.highlights {
position: sticky;
top: 0;
height: 100vh;
max-height: 100vh;
overflow-y: auto;
align-self: start;
margin: 0;
padding: 0;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
}
.three-pane.sidebar-collapsed,
.three-pane.highlights-collapsed,
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: 1fr; }
}
.pane.sidebar { overflow-y: auto; height: 100%; }
.pane.main {
overflow-y: auto;
height: 100%;
margin: 0 auto;
padding: 0 var(--main-horizontal-padding);
overflow-x: hidden;
contain: layout style;
}
/* Remove padding when sidebar is collapsed for zero gap */
.three-pane.sidebar-collapsed .pane.main { padding-left: 0; }
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main { padding-left: 0; }
.pane.highlights { overflow-y: auto; height: 100%; }
/* Mobile three-pane layout */
@media (max-width: 768px) {
.three-pane {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.three-pane.sidebar-collapsed,
.three-pane.highlights-collapsed,
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: 1fr; }
.pane.main {
margin: 0 auto;
padding: 0;
max-width: 100vw;
overflow-x: hidden;
}
}
/* Ensure panes are stacked in the correct order on desktop */
@media (min-width: 769px) {
@@ -83,7 +111,7 @@
max-width: 320px;
height: 100vh;
height: 100dvh;
background: #1a1a1a;
background: var(--color-bg);
z-index: 1001; /* Above backdrop */
transition: transform 0.3s ease;
box-shadow: none;
@@ -102,43 +130,17 @@
/* Highlights sidebar from right */
.pane.highlights { right: 0; transform: translateX(100%); }
.pane.highlights.mobile-open { transform: translateX(0); box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5); }
.pane.main { grid-column: 1; grid-row: 1; padding: 0.5rem; max-width: 100%; transition: opacity 0.2s ease; }
.pane.main {
grid-column: 1;
grid-row: 1;
padding: 0;
max-width: 100%;
width: 100%;
transition: opacity 0.2s ease;
}
/* Hide main content when sidepanes are open on mobile */
.three-pane .pane.main.mobile-hidden { opacity: 0; pointer-events: none; }
.mobile-sidebar-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999; /* Below sidepanes */
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-sidebar-backdrop.visible { display: block; opacity: 1; }
.mobile-highlights-btn {
display: none;
position: fixed;
top: calc(1rem + env(safe-area-inset-top));
right: calc(1rem + env(safe-area-inset-right));
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-highlights-btn.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.mobile-highlights-btn.visible { opacity: 1; visibility: visible; }
@media (max-width: 768px) { .mobile-highlights-btn { display: flex; } }
/* Mobile buttons and backdrop now use Tailwind utilities in component */
}

View File

@@ -1,14 +1,20 @@
/* Highlights panel layout and interactions */
.highlights-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
background: var(--color-bg);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* Desktop: no border or rounded corners for edge-to-edge sidebars */
@media (min-width: 769px) {
.highlights-container {
border: none;
border-radius: 0;
}
}
.highlights-container.collapsed {
display: flex;
align-items: flex-start;
@@ -19,8 +25,8 @@
}
.highlights-container.collapsed .toggle-highlights-btn {
background: #2a2a2a;
color: #ddd;
background: var(--color-bg-elevated);
color: var(--color-text);
border: none;
padding: 0;
border-radius: 0;
@@ -33,7 +39,7 @@
height: 36px;
}
.highlights-container.collapsed .toggle-highlights-btn:hover { background: #333; color: #fff; }
.highlights-container.collapsed .toggle-highlights-btn:hover { background: var(--color-border); color: var(--color-text); }
.highlights-container.collapsed .toggle-highlights-btn:active { transform: translateY(1px); }
.highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
@@ -42,93 +48,58 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #333;
background: #1a1a1a;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
border-radius: 12px 12px 0 0;
}
/* Desktop: no rounded corners for edge-to-edge header */
@media (min-width: 769px) {
.highlights-header {
border-radius: 0;
}
}
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.highlights-title .count { color: #888; font-size: 0.875rem; }
.highlights-title .count { color: var(--color-text-secondary); font-size: 0.875rem; }
.highlight-mode-toggle { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-mode-toggle .mode-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.highlight-mode-toggle .mode-btn.active { background: #646cff; color: #fff; }
.highlight-mode-toggle .mode-btn { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: var(--color-text); }
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
/* Three-level highlight toggles */
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
.refresh-highlights-btn,
.toggle-highlight-display-btn,
.toggle-highlights-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
padding: 0;
border-radius: 6px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.refresh-highlights-btn:hover,
.toggle-highlight-display-btn:hover,
.toggle-highlights-btn:hover { background: #2a2a2a; color: #fff; }
.refresh-highlights-btn:active,
.toggle-highlight-display-btn:active,
.toggle-highlights-btn:active { transform: translateY(1px); }
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-highlights-btn:disabled:hover { background: transparent; color: #ddd; }
.highlights-loading,
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: #888; text-align: center; gap: 0.5rem; }
.highlights-empty svg { color: #555; margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: #666; margin-top: 0.5rem; }
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }
.highlights-empty svg { color: var(--color-text-muted); margin-bottom: 0.5rem; }
.empty-hint { font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.5rem; }
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.highlight-item { background: #1e1e1e; border-left: 1px solid #333; border-right: 1px solid #333; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
.highlight-item:hover { border-color: #646cff; }
.highlight-item:hover .highlight-header,
.highlight-item:hover .highlight-footer { border-color: #646cff; }
.highlight-item.selected { border-color: #646cff; background: #252525; box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3); }
.highlight-item.selected .highlight-header,
.highlight-item.selected .highlight-footer { border-color: #646cff; }
.highlight-item { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
.highlight-item:hover { border-color: var(--color-primary); }
.highlight-item.selected { border-color: var(--color-primary); background: var(--color-bg-elevated); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
/* Compact button for highlight cards */
.compact-button { background: none; border: none; color: #888; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; gap: 0.25rem; transition: all 0.2s ease; border-radius: 4px; min-width: 20px; min-height: 20px; }
.compact-button:hover { color: #aaa; background: rgba(255, 255, 255, 0.05); }
.compact-button { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.25rem; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; gap: 0.25rem; transition: all 0.2s ease; border-radius: 4px; min-width: 20px; min-height: 20px; }
.compact-button:hover { color: var(--color-text); background: rgba(255, 255, 255, 0.05); }
.compact-button:active { transform: scale(0.95); }
.compact-button:disabled { opacity: 0.5; cursor: not-allowed; }
.compact-button:disabled:hover { background: none; color: #888; 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: 1px solid #333; 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-timestamp { font-size: 0.75rem; font-weight: 500; white-space: nowrap; }
/* Level colors in sidebar items */
.highlight-item.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
.highlight-item.level-mine .highlight-header,
.highlight-item.level-mine .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); }
.highlight-item.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
.highlight-item.level-friends .highlight-header,
.highlight-item.level-friends .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); }
.highlight-item.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
.highlight-item.level-nostrverse .highlight-header,
.highlight-item.level-nostrverse .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); }
.highlight-quote-button { position: absolute; top: 0.25rem; left: 0.5rem; z-index: 10; }
.highlight-item.level-mine .highlight-quote-button { color: var(--highlight-color-mine, #ffff00); }
@@ -141,6 +112,13 @@
@media (max-width: 768px) {
.highlight-relay-indicator { padding: 8px; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
.compact-button { padding: 0.5rem; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
/* Keep icon size consistent to prevent blurriness */
.compact-button svg {
font-size: 0.875rem;
width: 1em;
height: 1em;
}
}
/* Level-colored quote icon */
@@ -148,13 +126,18 @@
.highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1.75rem 0.75rem; }
.highlight-text { margin: 0; padding: 0; font-style: italic; color: #ddd; line-height: 1.6; border-left: none; font-size: 0.95rem; }
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(100, 108, 255, 0.1); border-left: 3px solid #646cff; border-radius: 4px; font-size: 0.875rem; color: #ddd; line-height: 1.5; }
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 2.25rem 0.75rem 2.5rem; }
.highlight-text { margin: 0; padding: 0 0 0 1.25rem; font-style: italic; color: var(--color-text); line-height: 1.6; border-left: none; font-size: 0.95rem; }
.highlight-comment { margin-top: 0.5rem; margin-left: 1.25rem; padding: 0.75rem; border-left: 3px solid; border-radius: 4px; font-size: 0.875rem; color: var(--color-text); line-height: 1.5; }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: #888; border-bottom: 1px solid #333; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
/* Level-colored comments */
.highlight-item.level-mine .highlight-comment { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 10%, transparent); border-left-color: var(--highlight-color-mine, #ffff00); }
.highlight-item.level-friends .highlight-comment { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 10%, transparent); border-left-color: var(--highlight-color-friends, #f97316); }
.highlight-item.level-nostrverse .highlight-comment { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 10%, transparent); border-left-color: var(--highlight-color-nostrverse, #9333ea); }
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--color-text-secondary); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.highlight-author { color: #aaa; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
.highlight-author { color: var(--color-text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
/* Ensure relay indicator in footer uses normal flow and matches CompactButton spacing */
.highlight-item .highlight-footer .highlight-relay-indicator {
@@ -165,11 +148,10 @@
padding: 0.25rem; /* CompactButton base */
}
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
.highlight-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.highlight-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
.highlight-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.highlight-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: #ff4444; }
.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; }

View File

@@ -1,8 +1,6 @@
/* Bookmarks and sidebar layout */
.bookmarks-container {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
background: var(--color-bg);
display: flex;
flex-direction: column;
height: 100%;
@@ -11,10 +9,18 @@
padding: 0;
}
/* Desktop: no border or rounded corners for edge-to-edge sidebars */
@media (min-width: 769px) {
.bookmarks-container {
border: none;
border-radius: 0;
}
}
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 1rem;
border-top: 1px solid #333;
border-top: 1px solid var(--color-border);
background: transparent;
border-radius: 0;
}
@@ -35,12 +41,19 @@
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px 12px 0 0;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
margin-bottom: 0;
}
/* Mobile: add borders and rounded corners */
@media (max-width: 768px) {
.sidebar-header-bar {
border: 1px solid var(--color-border);
border-radius: 12px 12px 0 0;
}
}
.sidebar-header-right {
display: flex;
align-items: center;
@@ -48,48 +61,21 @@
margin-left: auto;
}
.mobile-hamburger-btn {
display: none;
position: fixed;
top: calc(1rem + env(safe-area-inset-top));
left: calc(1rem + env(safe-area-inset-left));
z-index: 900;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
color: #ddd;
width: var(--min-touch-target);
height: var(--min-touch-target);
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-hamburger-btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-hamburger-btn.visible {
opacity: 1;
visibility: visible;
}
.mobile-hamburger-btn:active {
transform: scale(0.95);
}
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
.mobile-close-btn {
display: none;
}
@media (max-width: 768px) {
.mobile-hamburger-btn { display: flex; }
.sidebar-header-bar .toggle-sidebar-btn { display: none; }
.mobile-close-btn { display: flex; }
/* Keep icon sizes consistent to prevent blurriness */
.sidebar-header-bar svg {
width: 1em;
height: 1em;
}
}
.view-mode-controls {
@@ -100,6 +86,8 @@
}
.profile-avatar {
min-width: 33px;
min-height: 33px;
width: 33px;
height: 33px;
border-radius: 6px;
@@ -107,20 +95,21 @@
display: flex;
align-items: center;
justify-content: center;
background: #2a2a2a;
border: 1px solid #444;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle);
flex-shrink: 0;
color: #ddd;
color: var(--color-text);
box-sizing: border-box;
padding: 0;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar svg { font-size: 1.25rem; }
.profile-avatar svg { font-size: 1rem; }
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;
color: #ddd;
border: 1px solid #444;
color: var(--color-text);
border: 1px solid var(--color-border-subtle);
padding: 0;
border-radius: 6px;
cursor: pointer;
@@ -134,7 +123,7 @@
box-sizing: border-box;
}
.sidebar-header-bar .toggle-sidebar-btn:hover { background: #2a2a2a; color: #fff; }
.sidebar-header-bar .toggle-sidebar-btn:hover { background: var(--color-bg-elevated); color: var(--color-text); }
.sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
.bookmarks-container.collapsed {
@@ -147,8 +136,8 @@
}
.bookmarks-container.collapsed .toggle-sidebar-btn {
background: #2a2a2a;
color: #ddd;
background: var(--color-bg-elevated);
color: var(--color-text);
border: none;
padding: 0;
border-radius: 0;
@@ -162,22 +151,21 @@
flex-shrink: 0;
}
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: #333; color: #fff; }
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: var(--color-border); color: var(--color-text); }
.bookmarks-container.collapsed .toggle-sidebar-btn:active { transform: translateY(1px); }
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue { color: #646cff; filter: drop-shadow(0 0 4px rgba(100, 108, 255, 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: #888; font-size: 0.9rem; font-family: monospace; }
.bookmark-count { color: #666; font-size: 0.9rem; margin: 0.5rem 0; }
.event-link { color: #8ab4f8; text-decoration: none; font-weight: 500; }
.user-info { margin: 0.5rem 0 0 0; color: var(--color-text-secondary); font-size: 0.9rem; font-family: monospace; }
.bookmark-count { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
.event-link { color: var(--color-primary); text-decoration: none; font-weight: 500; }
.event-link:hover { text-decoration: underline; }
.bookmark-urls { margin: 0.75rem 0; }
.bookmark-url { display: block; margin: 0.25rem 0; color: #007bff; text-decoration: none; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-url { display: block; margin: 0.25rem 0; color: var(--color-primary); text-decoration: none; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
.bookmark-url:hover { text-decoration: underline; }
.url-row { display: flex; align-items: center; gap: 0.5rem; }
.read-inline-btn { background: #28a745; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
.read-inline-btn:hover { background: #218838; }
.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 */ }

7
src/styles/tailwind.css Normal file
View File

@@ -0,0 +1,7 @@
@import "tailwindcss";
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}

View File

@@ -21,4 +21,12 @@
75% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
}
/* Reading progress bar positioning */
@media (min-width: 769px) {
.reading-progress-bar {
left: var(--left-offset) !important;
right: var(--right-offset) !important;
}
}

View File

@@ -0,0 +1,62 @@
/* Nostr content parsing and URI resolution styles */
.parsed-content,
.nostr-mention,
.nostr-link,
.nostr-uri-link {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.parsed-content {
margin: 1rem 0;
line-height: 1.6;
}
.nostr-mention,
.nostr-uri-link {
color: rgb(59 130 246); /* blue-500 */
text-decoration: none;
font-family: monospace;
background: rgb(248 250 252); /* slate-50 */
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9rem;
}
.nostr-uri-link {
font-size: 0.9em;
border-radius: 4px;
}
.nostr-mention:hover,
.nostr-uri-link:hover {
background: rgb(226 232 240); /* slate-200 */
text-decoration: underline;
}
.nostr-link {
color: rgb(59 130 246); /* blue-500 */
text-decoration: none;
}
.nostr-link:hover {
text-decoration: underline;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: rgb(161 161 170); /* zinc-400 */
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0.5rem 0;
}

View File

@@ -6,11 +6,12 @@ export function hexToRgb(hex: string): string {
: '255, 255, 0'
}
// Tailwind color palette for highlight colors
export const HIGHLIGHT_COLORS = [
{ name: 'Yellow', value: '#ffff00' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Pink', value: '#ff69b4' },
{ name: 'Green', value: '#00ff7f' },
{ name: 'Blue', value: '#4da6ff' },
{ name: 'Purple', value: '#9333ea' }
{ name: 'Yellow', value: '#fde047' }, // yellow-300
{ name: 'Orange', value: '#f97316' }, // orange-500
{ name: 'Pink', value: '#ec4899' }, // pink-500
{ name: 'Green', value: '#22c55e' }, // green-500
{ name: 'Blue', value: '#3b82f6' }, // blue-500
{ name: 'Purple', value: '#9333ea' } // purple-600
]

View File

@@ -52,6 +52,7 @@ export function tryMarkInTextNodes(
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
// First try: Single text node match
for (const textNode of textNodes) {
const text = textNode.textContent || ''
const searchIn = useNormalized ? normalizeWhitespace(text) : text
@@ -73,12 +74,216 @@ export function tryMarkInTextNodes(
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
// Validate the match makes sense (not just whitespace or empty)
if (!match || match.trim().length === 0) {
console.warn('Invalid match (empty or whitespace only)')
continue
}
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
return true
}
return false
// Second try: Multi-node match (for text spanning multiple elements)
return tryMultiNodeMatch(textNodes, searchText, highlight, useNormalized, highlightStyle)
}
/**
* Try to find and mark text that spans multiple text nodes
*/
function tryMultiNodeMatch(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean,
highlightStyle: 'marker' | 'underline' = 'marker'
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
// Build a combined text from all nodes
let combinedText = ''
const nodeMap: Array<{ node: Text; start: number; end: number; originalText: string }> = []
for (const node of textNodes) {
const text = node.textContent || ''
const start = combinedText.length
const end = start + text.length
nodeMap.push({ node, start, end, originalText: text })
combinedText += text
}
// Search in combined text
const searchIn = useNormalized ? normalizeWhitespace(combinedText) : combinedText
const searchFor = useNormalized ? normalizedSearch : searchText
const matchIndex = searchIn.indexOf(searchFor)
if (matchIndex === -1) return false
// Map normalized index back to original if needed
let startIndex = matchIndex
let endIndex = matchIndex + searchFor.length
if (useNormalized) {
// Build precise mapping by walking original text and advancing a normalized counter
const endPos = matchIndex + searchFor.length // end position in normalized text (exclusive)
let normPos = 0
let foundStart = false
let foundEnd = false
let startNode: Text | null = null
let startOffset = 0
let endNode: Text | null = null
let endOffset = 0
for (const nodeInfo of nodeMap) {
const text = nodeInfo.originalText
let prevWasWs = false
for (let i = 0; i < text.length && (!foundStart || !foundEnd); i++) {
const ch = text[i]
const isWs = /\s/.test(ch)
if (isWs) {
if (!prevWasWs) {
// This whitespace sequence counts as one in normalized text
if (!foundStart && normPos === matchIndex) {
startNode = nodeInfo.node
startOffset = i
foundStart = true
}
if (!foundEnd && normPos === endPos) {
endNode = nodeInfo.node
endOffset = i
foundEnd = true
}
normPos++
}
prevWasWs = true
} else {
if (!foundStart && normPos === matchIndex) {
startNode = nodeInfo.node
startOffset = i
foundStart = true
}
normPos++
if (!foundEnd && normPos === endPos) {
endNode = nodeInfo.node
endOffset = i + 1 // end after this character
foundEnd = true
}
prevWasWs = false
}
}
if (foundStart && foundEnd) break
}
if (!foundStart || !foundEnd || !startNode || !endNode) {
console.warn('Failed to map normalized positions to nodes', { matchIndex, endPos, normPos })
return false
}
// Set indices relative to combinedText by reconstructing start/end using nodeMap
const startNodeInfo = nodeMap.find(n => n.node === startNode)!
const endNodeInfo = nodeMap.find(n => n.node === endNode)!
startIndex = startNodeInfo.start + startOffset
endIndex = endNodeInfo.start + endOffset
if (startIndex < 0 || endIndex <= startIndex || endIndex > combinedText.length) {
console.warn('Mapped indices invalid', { startIndex, endIndex, combinedTextLength: combinedText.length })
return false
}
}
// Validate indices
if (startIndex < 0 || endIndex > combinedText.length || startIndex >= endIndex) {
console.warn('Invalid highlight range:', { startIndex, endIndex, combinedTextLength: combinedText.length })
return false
}
// Find which nodes contain the match
const affectedNodes: Array<{ node: Text; startOffset: number; endOffset: number }> = []
for (const nodeInfo of nodeMap) {
if (startIndex < nodeInfo.end && endIndex > nodeInfo.start) {
const nodeStart = Math.max(0, startIndex - nodeInfo.start)
const nodeEnd = Math.min(nodeInfo.originalText.length, endIndex - nodeInfo.start)
// Validate node offsets
if (nodeStart < 0 || nodeEnd > nodeInfo.originalText.length || nodeStart > nodeEnd) {
console.warn('Invalid node offsets:', { nodeStart, nodeEnd, nodeLength: nodeInfo.originalText.length })
continue
}
affectedNodes.push({ node: nodeInfo.node, startOffset: nodeStart, endOffset: nodeEnd })
}
}
if (affectedNodes.length === 0) {
console.warn('No affected nodes found for highlight')
return false
}
try {
// Create a Range to wrap the entire selection in a single mark element
const range = document.createRange()
const firstNode = affectedNodes[0]
const lastNode = affectedNodes[affectedNodes.length - 1]
range.setStart(firstNode.node, firstNode.startOffset)
range.setEnd(lastNode.node, lastNode.endOffset)
// Verify the range isn't collapsed or invalid
if (range.collapsed) {
console.warn('Range is collapsed, skipping highlight')
return false
}
// Get the text content before extraction to verify it matches
const rangeText = range.toString()
const normalizedRangeText = normalizeWhitespace(rangeText)
const normalizedSearchText = normalizeWhitespace(searchText)
// Validate that the extracted text matches what we're searching for
if (!rangeText.includes(searchText) &&
!normalizedRangeText.includes(normalizedSearchText) &&
normalizedRangeText !== normalizedSearchText) {
console.warn('Range text does not match search text:', {
rangeText: rangeText.substring(0, 100),
searchText: searchText.substring(0, 100),
rangeLength: rangeText.length,
searchLength: searchText.length
})
return false
}
// Extract the content from the range
const extractedContent = range.extractContents()
// Verify we actually extracted something
if (!extractedContent || extractedContent.childNodes.length === 0) {
console.warn('No content extracted from range')
return false
}
// Create a single mark element
const mark = document.createElement('mark')
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
// Append the extracted content to the mark
mark.appendChild(extractedContent)
// Insert the mark at the range position
range.insertNode(mark)
return true
} catch (error) {
console.error('Error applying multi-node highlight:', error)
return false
}
}

View File

@@ -22,6 +22,17 @@ export function applyHighlightsToHTML(
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// CRITICAL: Remove any existing highlight marks to start with clean HTML
// This prevents old broken highlights from corrupting the new rendering
const existingMarks = tempDiv.querySelectorAll('mark[data-highlight-id]')
existingMarks.forEach(mark => {
// Replace the mark with its text content
const textNode = document.createTextNode(mark.textContent || '')
mark.parentNode?.replaceChild(textNode, mark)
})
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
let appliedCount = 0
for (const highlight of highlights) {

View File

@@ -39,7 +39,7 @@ export function extractNaddrUris(text: string): string[] {
/**
* Decode a NIP-19 identifier and return a human-readable link
* For articles (naddr), returns an internal app link
* For articles (naddr) and profiles (npub/nprofile), returns internal app links
* For other types, returns an external gateway link
*/
export function createNostrLink(encoded: string): string {
@@ -51,7 +51,13 @@ export function createNostrLink(encoded: string): string {
// For articles, link to our internal app route
return `/a/${encoded}`
case 'npub':
case 'nprofile':
// For profiles, link to our internal app route
return `/p/${encoded}`
case 'nprofile': {
// For nprofile, convert to npub and link to our internal app route
const npub = npubEncode(decoded.data.pubkey)
return `/p/${npub}`
}
case 'note':
case 'nevent':
return getNostrUrl(encoded)

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

35
tailwind.config.js Normal file
View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
// Semantic color aliases for the app
'app-bg': '#18181b', // zinc-900
'app-bg-elevated': '#27272a', // zinc-800
'app-bg-subtle': '#1e1e1e', // Custom between 800-900
'app-border': '#3f3f46', // zinc-700
'app-border-subtle': '#52525b', // zinc-600
'app-text': '#e4e4e7', // zinc-200
'app-text-secondary': '#a1a1aa', // zinc-400
'app-text-muted': '#71717a', // zinc-500
'primary': '#6366f1', // indigo-500
'primary-hover': '#4f46e5', // indigo-600
'highlight-mine': '#fde047', // yellow-300
'highlight-friends': '#f97316', // orange-500
'highlight-nostrverse': '#9333ea', // purple-600
},
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
},
},
},
plugins: [],
}