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(/ 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>/)
+ if (titleMatch) {
+ title = titleMatch[1].replace(' - YouTube', '').trim()
+ }
+ // Extract description from meta tag
+ const descMatch = html.match(/ 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 = ({
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 = ({
// 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 = ({
// 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 = ({
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(null)
- const videoMenuRef = useRef(null)
const externalMenuRef = useRef(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 = ({
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 = ({
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 = ({
// 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(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 = ({
setShowArticleMenu(!showArticleMenu)
}
- const toggleVideoMenu = () => setShowVideoMenu(v => !v)
const handleOpenPortal = () => {
if (articleLinks) {
@@ -571,46 +531,6 @@ const ContentPanel: React.FC = ({
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 }).share) {
- await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).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 = ({
)
}
- if (loading) {
- return (
-
-
-
- )
- }
const highlightRgb = hexToRgb(highlightColor)
@@ -854,11 +767,11 @@ const ContentPanel: React.FC = ({
)}
= ({
)}
- {isExternalVideo ? (
- <>
-
- setVideoDurationSec(Math.floor(d))}
- />
-
- {ytMeta?.description && (
-
- {ytMeta.description}
-
- )}
- {ytMeta?.transcript && (
-
-
Transcript
-
- {ytMeta.transcript}
-
-
- )}
-
-
-
- {showVideoMenu && (
-
-
-
-
-
-
- )}
-
-
- {activeAccount && (
-
-
-
- )}
- >
+ {loading || !markdown && !html ? (
+
+
+
) : markdown || html ? (
<>
{markdown ? (
@@ -959,16 +796,14 @@ const ContentPanel: React.FC
= ({
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
- renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
+ renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true}
className="reader-markdown"
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
)
) : (
@@ -976,7 +811,7 @@ const ContentPanel: React.FC = ({
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 = ({
)}
{/* Article menu for external URLs */}
- {!isNostrArticle && !isExternalVideo && selectedUrl && (
+ {!isNostrArticle && selectedUrl && (
)}
>
- ) : (
-
-
No readable content found for this URL.
-
- )}
+ ) : null}
>
)
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 = ({ onToggleCollapse, onLogou
return (
<>
- {activeAccount && (
-
-