diff --git a/TAILWIND_MIGRATION.md b/TAILWIND_MIGRATION.md deleted file mode 100644 index 3cdf4107..00000000 --- a/TAILWIND_MIGRATION.md +++ /dev/null @@ -1,188 +0,0 @@ -# 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 -
- -// Avoid: New CSS classes -
-``` - -### 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 - diff --git a/api/video-meta.ts b/api/video-meta.ts index 006d4d3c..25cb263f 100644 --- a/api/video-meta.ts +++ b/api/video-meta.ts @@ -94,7 +94,7 @@ async function pickCaptions(videoID: string, preferredLangs: string[], manualFir return null } -async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> { +async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string; thumbnail_url?: string }> { const vimeoUrl = `https://vimeo.com/${videoId}` const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}` @@ -107,7 +107,8 @@ async function getVimeoMetadata(videoId: string): Promise<{ title: string; descr return { title: data.title || '', - description: data.description || '' + description: data.description || '', + thumbnail_url: data.thumbnail_url || '' } } @@ -147,9 +148,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { try { if (videoInfo.source === 'youtube') { // YouTube handling - // Note: getVideoDetails doesn't exist in the library, so we use a simplified approach - const title = '' - const description = '' + // Fetch basic metadata from YouTube page + let title = '' + let description = '' + + try { + const response = await fetch(`https://www.youtube.com/watch?v=${videoInfo.id}`) + if (response.ok) { + const html = await response.text() + // Extract title from HTML + const titleMatch = html.match(/([^<]+)<\/title>/) + if (titleMatch) { + title = titleMatch[1].replace(' - YouTube', '').trim() + } + // Extract description from meta tag + const descMatch = html.match(/<meta name="description" content="([^"]+)"/) + if (descMatch) { + description = descMatch[1].trim() + } + } + } catch (error) { + console.warn('Failed to fetch YouTube metadata:', error) + } // 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[])) @@ -178,11 +198,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return ok(res, response) } else if (videoInfo.source === 'vimeo') { // Vimeo handling - const { title, description } = await getVimeoMetadata(videoInfo.id) + const { title, description, thumbnail_url } = await getVimeoMetadata(videoInfo.id) const response = { title, description, + thumbnail_url, captions: [], // Vimeo doesn't provide captions through oEmbed API transcript: '', // No transcript available lang: 'en', // Default language diff --git a/api/youtube-meta.ts b/api/youtube-meta.ts index 0de85015..ba77720a 100644 --- a/api/youtube-meta.ts +++ b/api/youtube-meta.ts @@ -63,10 +63,28 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } try { - // Since getVideoDetails doesn't exist, we'll use a simple approach - // In a real implementation, you might want to use YouTube's API or other methods - const title = '' // Will be populated from captions or other sources - const description = '' + // Fetch basic metadata from YouTube page + let title = '' + let description = '' + + try { + const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`) + if (response.ok) { + const html = await response.text() + // Extract title from HTML + const titleMatch = html.match(/<title>([^<]+)<\/title>/) + if (titleMatch) { + title = titleMatch[1].replace(' - YouTube', '').trim() + } + // Extract description from meta tag + const descMatch = html.match(/<meta name="description" content="([^"]+)"/) + if (descMatch) { + description = descMatch[1].trim() + } + } + } catch (error) { + console.warn('Failed to fetch YouTube metadata:', error) + } // 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[])) diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index bb563d99..d5de67a0 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -226,9 +226,15 @@ const Bookmarks: React.FC<BookmarksProps> = ({ settings }) + // Determine which loader should be active based on route + // Only one loader should run at a time to prevent state conflicts + const shouldLoadArticle = !!naddr && !externalUrl && !eventId + const shouldLoadExternal = !!externalUrl && !naddr && !eventId + const shouldLoadEvent = !!eventId && !naddr && !externalUrl + // Load nostr-native article if naddr is in URL useArticleLoader({ - naddr, + naddr: shouldLoadArticle ? naddr : undefined, relayPool, eventStore, setSelectedUrl, @@ -245,7 +251,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ // Load external URL if /r/* route is used useExternalUrlLoader({ - url: externalUrl, + url: shouldLoadExternal ? externalUrl : undefined, relayPool, eventStore, setSelectedUrl, @@ -260,7 +266,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ // Load event if /e/:eventId route is used useEventLoader({ - eventId, + eventId: shouldLoadEvent ? eventId : undefined, relayPool, eventStore, setSelectedUrl, diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 8ccd1b36..ea353a62 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react' -import ReactPlayer from 'react-player' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -34,9 +33,7 @@ import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService' import { archiveController } from '../services/archiveController' import AuthorCard from './AuthorCard' import { faBooks } from '../icons/customIcons' -import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService' -import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers' -import { buildNativeVideoUrl } from '../utils/videoHelpers' +import { shouldTrackReadingProgress } from '../utils/helpers' import { useReadingPosition } from '../hooks/useReadingPosition' import { ReadingProgressIndicator } from './ReadingProgressIndicator' import { EventFactory } from 'applesauce-factory' @@ -111,15 +108,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ 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 [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false) - const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false) const [externalMenuOpenUpward, setExternalMenuOpenUpward] = 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) const { finalHtml, relevantHighlights } = useHighlightedContent({ @@ -343,21 +336,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ if (articleMenuRef.current && !articleMenuRef.current.contains(target)) { setShowArticleMenu(false) } - if (videoMenuRef.current && !videoMenuRef.current.contains(target)) { - setShowVideoMenu(false) - } if (externalMenuRef.current && !externalMenuRef.current.contains(target)) { setShowExternalMenu(false) } } - if (showArticleMenu || showVideoMenu || showExternalMenu) { + if (showArticleMenu || showExternalMenu) { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } } - }, [showArticleMenu, showVideoMenu, showExternalMenu]) + }, [showArticleMenu, showExternalMenu]) // Check available space and position menu upward if needed useEffect(() => { @@ -380,13 +370,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ if (showArticleMenu) { checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward) } - if (showVideoMenu) { - checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward) - } if (showExternalMenu) { checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward) } - }, [showArticleMenu, showVideoMenu, showExternalMenu]) + }, [showArticleMenu, showExternalMenu]) const readingStats = useMemo(() => { const content = markdown || html || '' @@ -418,34 +405,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ // Determine if we're on a nostr-native article (/a/) or external URL (/r/) const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:') - const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type) - // Track external video duration (in seconds) for display in header - const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null) - // Load YouTube metadata/captions when applicable - useEffect(() => { - (async () => { - try { - if (!selectedUrl) return setYtMeta(null) - const id = extractYouTubeId(selectedUrl) - if (!id) return setYtMeta(null) - const locale = navigator?.language?.split('-')[0] || 'en' - const data = await getYouTubeMeta(id, locale) - if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript }) - } catch { - setYtMeta(null) - } - })() - }, [selectedUrl]) - const formatDuration = (totalSeconds: number): string => { - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = Math.floor(totalSeconds % 60) - const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) - const ss = String(seconds).padStart(2, '0') - return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` - } // Get article links for menu @@ -483,7 +444,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ setShowArticleMenu(!showArticleMenu) } - const toggleVideoMenu = () => setShowVideoMenu(v => !v) const handleOpenPortal = () => { if (articleLinks) { @@ -571,46 +531,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ setShowArticleMenu(false) } - // Video actions - const handleOpenVideoExternal = () => { - if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer') - setShowVideoMenu(false) - } - - const handleOpenVideoNative = () => { - if (!selectedUrl) return - const native = buildNativeVideoUrl(selectedUrl) - if (native) { - window.location.href = native - } else { - window.location.href = selectedUrl - } - setShowVideoMenu(false) - } - - const handleCopyVideoUrl = async () => { - try { - if (selectedUrl) await navigator.clipboard.writeText(selectedUrl) - } catch (e) { - console.warn('Clipboard copy failed', e) - } finally { - setShowVideoMenu(false) - } - } - - const handleShareVideoUrl = 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 || 'Video', url: selectedUrl }) - } else if (selectedUrl) { - await navigator.clipboard.writeText(selectedUrl) - } - } catch (e) { - console.warn('Share failed', e) - } finally { - setShowVideoMenu(false) - } - } // External article actions const toggleExternalMenu = () => setShowExternalMenu(v => !v) @@ -808,13 +728,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ ) } - if (loading) { - return ( - <div className="reader" aria-busy="true"> - <ContentSkeleton /> - </div> - ) - } const highlightRgb = hexToRgb(highlightColor) @@ -854,11 +767,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ )} <ReaderHeader - title={ytMeta?.title || title} + title={title} image={image} summary={summary} published={published} - readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)} + readingTimeText={readingStats ? readingStats.text : null} hasHighlights={hasHighlights} highlightCount={relevantHighlights.length} settings={settings} @@ -871,86 +784,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ <TTSControls text={articleText} defaultLang={navigator?.language} settings={settings} /> </div> )} - {isExternalVideo ? ( - <> - <div className="reader-video"> - <ReactPlayer - url={selectedUrl as string} - controls - width="100%" - height="auto" - style={{ - width: '100%', - height: 'auto', - aspectRatio: '16/9' - }} - onDuration={(d) => setVideoDurationSec(Math.floor(d))} - /> - </div> - {ytMeta?.description && ( - <div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}> - {ytMeta.description} - </div> - )} - {ytMeta?.transcript && ( - <div style={{ padding: '0 0.75rem 1rem 0.75rem' }}> - <h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3> - <div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}> - {ytMeta.transcript} - </div> - </div> - )} - <div className="article-menu-container"> - <div className="article-menu-wrapper" ref={videoMenuRef}> - <button - className="article-menu-btn" - onClick={toggleVideoMenu} - title="More options" - > - <FontAwesomeIcon icon={faEllipsisH} /> - </button> - {showVideoMenu && ( - <div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}> - <button className="article-menu-item" onClick={handleOpenVideoExternal}> - <FontAwesomeIcon icon={faExternalLinkAlt} /> - <span>Open Link</span> - </button> - <button className="article-menu-item" onClick={handleOpenVideoNative}> - <FontAwesomeIcon icon={faMobileAlt} /> - <span>Open in Native App</span> - </button> - <button className="article-menu-item" onClick={handleCopyVideoUrl}> - <FontAwesomeIcon icon={faCopy} /> - <span>Copy URL</span> - </button> - <button className="article-menu-item" onClick={handleShareVideoUrl}> - <FontAwesomeIcon icon={faShare} /> - <span>Share</span> - </button> - </div> - )} - </div> - </div> - {activeAccount && ( - <div className="mark-as-read-container"> - <button - className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`} - onClick={handleMarkAsRead} - disabled={isCheckingReadStatus} - title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'} - style={isMarkedAsRead ? { opacity: 0.85 } : undefined} - > - <FontAwesomeIcon - icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks} - spin={isCheckingReadStatus} - /> - <span> - {isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'} - </span> - </button> - </div> - )} - </> + {loading || !markdown && !html ? ( + <div className="reader" aria-busy="true"> + <ContentSkeleton /> + </div> ) : markdown || html ? ( <> {markdown ? ( @@ -959,16 +796,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ key={`content:${contentKey}`} ref={contentRef} html={finalHtml} - renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo} + renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true} className="reader-markdown" onMouseUp={handleSelectionEnd} onTouchEnd={handleSelectionEnd} /> ) : ( <div className="reader-markdown"> - <div className="loading-spinner"> - <FontAwesomeIcon icon={faSpinner} spin size="sm" /> - </div> + <ContentSkeleton /> </div> ) ) : ( @@ -976,7 +811,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ key={`content:${contentKey}`} ref={contentRef} html={finalHtml || html || ''} - renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo} + renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true} className="reader-html" onMouseUp={handleSelectionEnd} onTouchEnd={handleSelectionEnd} @@ -984,7 +819,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ )} {/* Article menu for external URLs */} - {!isNostrArticle && !isExternalVideo && selectedUrl && ( + {!isNostrArticle && selectedUrl && ( <div className="article-menu-container"> <div className="article-menu-wrapper" ref={externalMenuRef}> <button @@ -1135,11 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({ </div> )} </> - ) : ( - <div className="reader empty"> - <p>No readable content found for this URL.</p> - </div> - )} + ) : null} </div> </> ) diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index 80d9824f..fa68e879 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -65,70 +65,70 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou return ( <> <div className="sidebar-header-bar"> - {activeAccount && ( - <div className="profile-menu-wrapper" ref={menuRef}> - <button - className="profile-avatar-button" - title={getUserDisplayName()} - onClick={() => setShowProfileMenu(!showProfileMenu)} - aria-label={`Profile: ${getUserDisplayName()}`} - > - {profileImage ? ( - <img src={profileImage} alt={getUserDisplayName()} /> - ) : ( - <FontAwesomeIcon icon={faUserCircle} /> + <div className="sidebar-header-left"> + {activeAccount && ( + <div className="profile-menu-wrapper" ref={menuRef}> + <button + className="profile-avatar-button" + title={getUserDisplayName()} + onClick={() => setShowProfileMenu(!showProfileMenu)} + aria-label={`Profile: ${getUserDisplayName()}`} + > + {profileImage ? ( + <img src={profileImage} alt={getUserDisplayName()} /> + ) : ( + <FontAwesomeIcon icon={faUserCircle} /> + )} + </button> + {showProfileMenu && ( + <div className="profile-dropdown-menu"> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))} + > + <FontAwesomeIcon icon={faHighlighter} /> + <span>My Highlights</span> + </button> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))} + > + <FontAwesomeIcon icon={faBookmark} /> + <span>My Bookmarks</span> + </button> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(() => navigate('/my/reads'))} + > + <FontAwesomeIcon icon={faBooks} /> + <span>My Reads</span> + </button> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(() => navigate('/my/links'))} + > + <FontAwesomeIcon icon={faLink} /> + <span>My Links</span> + </button> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(() => navigate('/my/writings'))} + > + <FontAwesomeIcon icon={faPenToSquare} /> + <span>My Writings</span> + </button> + <div className="profile-menu-separator"></div> + <button + className="profile-menu-item" + onClick={() => handleMenuItemClick(onLogout)} + > + <FontAwesomeIcon icon={faRightFromBracket} /> + <span>Logout</span> + </button> + </div> )} - </button> - {showProfileMenu && ( - <div className="profile-dropdown-menu"> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(() => navigate('/my/highlights'))} - > - <FontAwesomeIcon icon={faHighlighter} /> - <span>My Highlights</span> - </button> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(() => navigate('/my/bookmarks'))} - > - <FontAwesomeIcon icon={faBookmark} /> - <span>My Bookmarks</span> - </button> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(() => navigate('/my/reads'))} - > - <FontAwesomeIcon icon={faBooks} /> - <span>My Reads</span> - </button> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(() => navigate('/my/links'))} - > - <FontAwesomeIcon icon={faLink} /> - <span>My Links</span> - </button> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(() => navigate('/my/writings'))} - > - <FontAwesomeIcon icon={faPenToSquare} /> - <span>My Writings</span> - </button> - <div className="profile-menu-separator"></div> - <button - className="profile-menu-item" - onClick={() => handleMenuItemClick(onLogout)} - > - <FontAwesomeIcon icon={faRightFromBracket} /> - <span>Logout</span> - </button> - </div> - )} - </div> - )} - <div className="sidebar-header-right"> + </div> + )} <IconButton icon={faHome} onClick={() => { @@ -141,6 +141,8 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou ariaLabel="Home" variant="ghost" /> + </div> + <div className="sidebar-header-right"> <IconButton icon={faPersonHiking} onClick={() => { diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index 1b645f01..ffe529e9 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -5,6 +5,7 @@ import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { BookmarkList } from './BookmarkList' import ContentPanel from './ContentPanel' +import VideoView from './VideoView' import { HighlightsPanel } from './HighlightsPanel' import Settings from './Settings' import Toast from './Toast' @@ -19,6 +20,7 @@ import { HighlightVisibility } from './HighlightsPanel' import { HighlightButtonRef } from './HighlightButton' import { BookmarkReference } from '../utils/contentLoader' import { useIsMobile } from '../hooks/useMediaQuery' +import { classifyUrl } from '../utils/helpers' import { useScrollDirection } from '../hooks/useScrollDirection' import { IAccount } from 'applesauce-accounts' import { NostrEvent } from 'nostr-tools' @@ -373,42 +375,68 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => { <> {props.support} </> - ) : ( - <ContentPanel - loading={props.readerLoading} - title={props.readerContent?.title} - html={props.readerContent?.html} - markdown={props.readerContent?.markdown} - image={props.readerContent?.image} - summary={props.readerContent?.summary} - published={props.readerContent?.published} - selectedUrl={props.selectedUrl} - highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:') - ? props.highlights // article-specific highlights only - : props.classifiedHighlights} - showHighlights={props.showHighlights} - highlightStyle={props.settings.highlightStyle || 'marker'} - highlightColor={props.settings.highlightColor || '#ffff00'} - onHighlightClick={props.onHighlightClick} - selectedHighlightId={props.selectedHighlightId} - highlightVisibility={props.highlightVisibility} - onTextSelection={props.onTextSelection} - onClearSelection={props.onClearSelection} - currentUserPubkey={props.currentUserPubkey} - followedPubkeys={props.followedPubkeys} - settings={props.settings} - relayPool={props.relayPool} - activeAccount={props.activeAccount} - currentArticle={props.currentArticle} - isSidebarCollapsed={props.isCollapsed} - isHighlightsCollapsed={props.isHighlightsCollapsed} - onOpenHighlights={() => { - if (props.isHighlightsCollapsed) { - props.onToggleHighlightsPanel() - } - }} - /> - )} + ) : (() => { + // Determine if this is a video URL + const isNostrArticle = props.selectedUrl && props.selectedUrl.startsWith('nostr:') + const isExternalVideo = !isNostrArticle && !!props.selectedUrl && ['youtube', 'video'].includes(classifyUrl(props.selectedUrl).type) + + if (isExternalVideo) { + return ( + <VideoView + videoUrl={props.selectedUrl!} + title={props.readerContent?.title} + image={props.readerContent?.image} + summary={props.readerContent?.summary} + published={props.readerContent?.published} + settings={props.settings} + relayPool={props.relayPool} + activeAccount={props.activeAccount} + onOpenHighlights={() => { + if (props.isHighlightsCollapsed) { + props.onToggleHighlightsPanel() + } + }} + /> + ) + } + + return ( + <ContentPanel + loading={props.readerLoading} + title={props.readerContent?.title} + html={props.readerContent?.html} + markdown={props.readerContent?.markdown} + image={props.readerContent?.image} + summary={props.readerContent?.summary} + published={props.readerContent?.published} + selectedUrl={props.selectedUrl} + highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:') + ? props.highlights // article-specific highlights only + : props.classifiedHighlights} + showHighlights={props.showHighlights} + highlightStyle={props.settings.highlightStyle || 'marker'} + highlightColor={props.settings.highlightColor || '#ffff00'} + onHighlightClick={props.onHighlightClick} + selectedHighlightId={props.selectedHighlightId} + highlightVisibility={props.highlightVisibility} + onTextSelection={props.onTextSelection} + onClearSelection={props.onClearSelection} + currentUserPubkey={props.currentUserPubkey} + followedPubkeys={props.followedPubkeys} + settings={props.settings} + relayPool={props.relayPool} + activeAccount={props.activeAccount} + currentArticle={props.currentArticle} + isSidebarCollapsed={props.isCollapsed} + isHighlightsCollapsed={props.isHighlightsCollapsed} + onOpenHighlights={() => { + if (props.isHighlightsCollapsed) { + props.onToggleHighlightsPanel() + } + }} + /> + ) + })()} </div> <div ref={highlightsRef} diff --git a/src/components/VideoView.tsx b/src/components/VideoView.tsx new file mode 100644 index 00000000..920c599d --- /dev/null +++ b/src/components/VideoView.tsx @@ -0,0 +1,320 @@ +import React, { useState, useEffect, useRef } from 'react' +import ReactPlayer from 'react-player' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faCheckCircle } from '@fortawesome/free-solid-svg-icons' +import { RelayPool } from 'applesauce-relay' +import { IAccount } from 'applesauce-accounts' +import { UserSettings } from '../services/settingsService' +import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService' +import { buildNativeVideoUrl } from '../utils/videoHelpers' +import { getYouTubeThumbnail } from '../utils/imagePreview' + +// Helper function to get Vimeo thumbnail +const getVimeoThumbnail = (url: string): string | null => { + const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) + if (!vimeoMatch) return null + + const videoId = vimeoMatch[1] + return `https://vumbnail.com/${videoId}.jpg` +} +import { + createWebsiteReaction, + hasMarkedWebsiteAsRead +} from '../services/reactionService' +import { unarchiveWebsite } from '../services/unarchiveService' +import ReaderHeader from './ReaderHeader' + +interface VideoViewProps { + videoUrl: string + title?: string + image?: string + summary?: string + published?: number + settings?: UserSettings + relayPool?: RelayPool | null + activeAccount?: IAccount | null + onOpenHighlights?: () => void +} + +const VideoView: React.FC<VideoViewProps> = ({ + videoUrl, + title, + image, + summary, + published, + settings, + relayPool, + activeAccount, + onOpenHighlights +}) => { + const [isMarkedAsWatched, setIsMarkedAsWatched] = useState(false) + const [isCheckingWatchedStatus, setIsCheckingWatchedStatus] = useState(false) + const [showCheckAnimation, setShowCheckAnimation] = useState(false) + const [showVideoMenu, setShowVideoMenu] = useState(false) + const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false) + const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null) + const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null) + const videoMenuRef = useRef<HTMLDivElement>(null) + + // Load YouTube metadata when applicable + useEffect(() => { + (async () => { + try { + if (!videoUrl) return setYtMeta(null) + const id = extractYouTubeId(videoUrl) + if (!id) return setYtMeta(null) + const locale = navigator?.language?.split('-')[0] || 'en' + const data = await getYouTubeMeta(id, locale) + if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript }) + } catch { + setYtMeta(null) + } + })() + }, [videoUrl]) + + // Check if video is marked as watched + useEffect(() => { + const checkWatchedStatus = async () => { + if (!activeAccount || !videoUrl) return + + setIsCheckingWatchedStatus(true) + try { + const isWatched = relayPool ? await hasMarkedWebsiteAsRead(videoUrl, activeAccount.pubkey, relayPool) : false + setIsMarkedAsWatched(isWatched) + } catch (error) { + console.warn('Failed to check watched status:', error) + } finally { + setIsCheckingWatchedStatus(false) + } + } + + checkWatchedStatus() + }, [activeAccount, videoUrl, relayPool]) + + // Handle click outside to close menu + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + if (videoMenuRef.current && !videoMenuRef.current.contains(target)) { + setShowVideoMenu(false) + } + } + + if (showVideoMenu) { + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + } + }, [showVideoMenu]) + + // Check menu position for upward opening + useEffect(() => { + const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (upward: boolean) => void) => { + if (!menuRef.current) return + + const rect = menuRef.current.getBoundingClientRect() + const viewportHeight = window.innerHeight + const spaceBelow = viewportHeight - rect.bottom + const spaceAbove = rect.top + + // Open upward if there's more space above and less space below + setOpenUpward(spaceAbove > spaceBelow && spaceBelow < 200) + } + + if (showVideoMenu) { + checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward) + } + }, [showVideoMenu]) + + const formatDuration = (totalSeconds: number): string => { + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = Math.floor(totalSeconds % 60) + const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) + const ss = String(seconds).padStart(2, '0') + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` + } + + const handleMarkAsWatched = async () => { + if (!activeAccount || !videoUrl || isCheckingWatchedStatus) return + + setIsCheckingWatchedStatus(true) + setShowCheckAnimation(true) + + try { + if (isMarkedAsWatched) { + // Unmark as watched + if (relayPool) { + await unarchiveWebsite(videoUrl, activeAccount, relayPool) + } + setIsMarkedAsWatched(false) + } else { + // Mark as watched + if (relayPool) { + await createWebsiteReaction(videoUrl, activeAccount, relayPool) + } + setIsMarkedAsWatched(true) + } + } catch (error) { + console.warn('Failed to update watched status:', error) + } finally { + setIsCheckingWatchedStatus(false) + setTimeout(() => setShowCheckAnimation(false), 1000) + } + } + + const toggleVideoMenu = () => setShowVideoMenu(v => !v) + + const handleOpenVideoExternal = () => { + window.open(videoUrl, '_blank', 'noopener,noreferrer') + setShowVideoMenu(false) + } + + const handleOpenVideoNative = () => { + const native = buildNativeVideoUrl(videoUrl) + if (native) { + window.location.href = native + } else { + window.location.href = videoUrl + } + setShowVideoMenu(false) + } + + const handleCopyVideoUrl = async () => { + try { + await navigator.clipboard.writeText(videoUrl) + } catch (e) { + console.warn('Clipboard copy failed', e) + } finally { + setShowVideoMenu(false) + } + } + + const handleShareVideoUrl = async () => { + try { + if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) { + await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ + title: ytMeta?.title || title || 'Video', + url: videoUrl + }) + } else { + await navigator.clipboard.writeText(videoUrl) + } + } catch (e) { + console.warn('Share failed', e) + } finally { + setShowVideoMenu(false) + } + } + + const displayTitle = ytMeta?.title || title + const displaySummary = ytMeta?.description || summary + const durationText = videoDurationSec !== null ? formatDuration(videoDurationSec) : null + + // Get video thumbnail for cover image + const youtubeThumbnail = getYouTubeThumbnail(videoUrl) + const vimeoThumbnail = getVimeoThumbnail(videoUrl) + const videoThumbnail = youtubeThumbnail || vimeoThumbnail + const displayImage = videoThumbnail || image + + return ( + <> + <ReaderHeader + title={displayTitle} + image={displayImage} + summary={displaySummary} + published={published} + readingTimeText={durationText} + hasHighlights={false} + highlightCount={0} + settings={settings} + highlights={[]} + highlightVisibility={{ nostrverse: true, friends: true, mine: true }} + onHighlightCountClick={onOpenHighlights} + /> + + <div className="reader-video"> + <ReactPlayer + url={videoUrl} + controls + width="100%" + height="auto" + style={{ + width: '100%', + height: 'auto', + aspectRatio: '16/9' + }} + onDuration={(d) => setVideoDurationSec(Math.floor(d))} + /> + </div> + + {displaySummary && ( + <div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}> + {displaySummary} + </div> + )} + + {ytMeta?.transcript && ( + <div style={{ padding: '0 0.75rem 1rem 0.75rem' }}> + <h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3> + <div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}> + {ytMeta.transcript} + </div> + </div> + )} + + <div className="article-menu-container"> + <div className="article-menu-wrapper" ref={videoMenuRef}> + <button + className="article-menu-btn" + onClick={toggleVideoMenu} + title="More options" + > + <FontAwesomeIcon icon={faEllipsisH} /> + </button> + {showVideoMenu && ( + <div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}> + <button className="article-menu-item" onClick={handleOpenVideoExternal}> + <FontAwesomeIcon icon={faExternalLinkAlt} /> + <span>Open Link</span> + </button> + <button className="article-menu-item" onClick={handleOpenVideoNative}> + <FontAwesomeIcon icon={faMobileAlt} /> + <span>Open in Native App</span> + </button> + <button className="article-menu-item" onClick={handleCopyVideoUrl}> + <FontAwesomeIcon icon={faCopy} /> + <span>Copy URL</span> + </button> + <button className="article-menu-item" onClick={handleShareVideoUrl}> + <FontAwesomeIcon icon={faShare} /> + <span>Share</span> + </button> + </div> + )} + </div> + </div> + + {activeAccount && ( + <div className="mark-as-read-container"> + <button + className={`mark-as-read-btn ${isMarkedAsWatched ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`} + onClick={handleMarkAsWatched} + disabled={isCheckingWatchedStatus} + title={isMarkedAsWatched ? 'Already Marked as Watched' : 'Mark as Watched'} + style={isMarkedAsWatched ? { opacity: 0.85 } : undefined} + > + <FontAwesomeIcon + icon={faCheckCircle} + className={isMarkedAsWatched ? 'check-icon' : 'check-icon-empty'} + /> + <span>{isMarkedAsWatched ? 'Watched' : 'Mark as Watched'}</span> + </button> + </div> + )} + </> + ) +} + +export default VideoView diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 68b9ffa3..5f785a6c 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -76,6 +76,10 @@ export function useArticleLoader({ setSelectedUrl(`nostr:${naddr}`) setIsCollapsed(true) + // Don't clear highlights yet - let the smart filtering logic handle it + // when we know the article coordinate + setHighlightsLoading(false) // Don't show loading yet + // If we have preview data from navigation, show it immediately (no skeleton!) if (previewData) { setReaderContent({ @@ -237,7 +241,13 @@ export function useArticleLoader({ if (coord && eventId) { setHighlightsLoading(true) - setHighlights([]) + // Clear highlights that don't belong to this article coordinate + setHighlights((prev) => { + return prev.filter(h => { + // Keep highlights that match this article coordinate or event ID + return h.eventReference === coord || h.eventReference === eventId + }) + }) await fetchHighlightsForArticle( relayPool, coord, @@ -251,7 +261,9 @@ export function useArticleLoader({ return next.sort((a, b) => b.created_at - a.created_at) }) }, - settingsRef.current + settingsRef.current, + false, // force + eventStore || undefined ) } else { // No article event to fetch highlights for - clear and don't show loading diff --git a/src/hooks/useFilteredHighlights.ts b/src/hooks/useFilteredHighlights.ts index c069a691..1f068bc1 100644 --- a/src/hooks/useFilteredHighlights.ts +++ b/src/hooks/useFilteredHighlights.ts @@ -3,6 +3,7 @@ import { Highlight } from '../types/highlights' import { HighlightVisibility } from '../components/HighlightsPanel' import { normalizeUrl } from '../utils/urlHelpers' import { classifyHighlights } from '../utils/highlightClassification' +import { nip19 } from 'nostr-tools' interface UseFilteredHighlightsParams { highlights: Highlight[] @@ -24,8 +25,29 @@ export const useFilteredHighlights = ({ let urlFiltered = highlights - // For Nostr articles, we already fetched highlights specifically for this article - if (!selectedUrl.startsWith('nostr:')) { + // Filter highlights based on URL type + if (selectedUrl.startsWith('nostr:')) { + // For Nostr articles, extract the article coordinate and filter by eventReference + try { + const decoded = nip19.decode(selectedUrl.replace('nostr:', '')) + if (decoded.type === 'naddr') { + const ptr = decoded.data as { kind: number; pubkey: string; identifier: string } + const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}` + + urlFiltered = highlights.filter(h => { + // Keep highlights that match this article coordinate + return h.eventReference === articleCoordinate + }) + } else { + // Not a valid naddr, clear all highlights + urlFiltered = [] + } + } catch { + // Invalid naddr, clear all highlights + urlFiltered = [] + } + } else { + // For web URLs, filter by URL matching const normalizedSelected = normalizeUrl(selectedUrl) urlFiltered = highlights.filter(h => { diff --git a/src/styles/layout/sidebar.css b/src/styles/layout/sidebar.css index 1631e0c7..c5da9b7a 100644 --- a/src/styles/layout/sidebar.css +++ b/src/styles/layout/sidebar.css @@ -54,6 +54,13 @@ } } +.sidebar-header-left { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + .sidebar-header-right { display: flex; align-items: center; diff --git a/src/utils/urlHelpers.ts b/src/utils/urlHelpers.ts index 8dbe8c81..f60789b4 100644 --- a/src/utils/urlHelpers.ts +++ b/src/utils/urlHelpers.ts @@ -1,4 +1,5 @@ import { Highlight } from '../types/highlights' +import { nip19 } from 'nostr-tools' export function normalizeUrl(url: string): string { try { @@ -15,10 +16,26 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri } - // For Nostr articles, we already fetched highlights specifically for this article - // So we don't need to filter them - they're all relevant + // For Nostr articles, filter by article coordinate if (selectedUrl.startsWith('nostr:')) { - return highlights + try { + const decoded = nip19.decode(selectedUrl.replace('nostr:', '')) + if (decoded.type === 'naddr') { + const ptr = decoded.data as { kind: number; pubkey: string; identifier: string } + const articleCoordinate = `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}` + + return highlights.filter(h => { + // Keep highlights that match this article coordinate + return h.eventReference === articleCoordinate + }) + } else { + // Not a valid naddr, return empty array + return [] + } + } catch { + // Invalid naddr, return empty array + return [] + } } // For web URLs, filter by URL matching