mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 13:04:59 +01:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914557a61d | ||
|
|
3df2f248ff | ||
|
|
d2770d58e2 | ||
|
|
933182567d | ||
|
|
f9fa2f05f0 | ||
|
|
919bb8151f | ||
|
|
6f82674c9b | ||
|
|
8caf9988fc | ||
|
|
036ee20d98 | ||
|
|
b86545dcc8 | ||
|
|
8bdccd9c9e | ||
|
|
9a14185fa5 | ||
|
|
53a6053464 | ||
|
|
e27d7ee26c | ||
|
|
98203e6b6f | ||
|
|
8469740141 | ||
|
|
8fff2bce52 | ||
|
|
30b98fc744 | ||
|
|
7a190b7d35 | ||
|
|
e3149c40c7 | ||
|
|
91743518bd | ||
|
|
fd2e4079ab | ||
|
|
ec423cad80 | ||
|
|
8f8441b0e0 | ||
|
|
3c20d45dba | ||
|
|
75c4e20dc9 | ||
|
|
9d27595d31 | ||
|
|
b7d90a790b | ||
|
|
c49d850f74 | ||
|
|
4c11c5fc54 | ||
|
|
44befab6d3 | ||
|
|
02a2f4b85e | ||
|
|
43d54b5734 | ||
|
|
b7896be507 | ||
|
|
eeb40306da | ||
|
|
749b47ac5c | ||
|
|
42f59f2b19 | ||
|
|
2bf6e742f1 | ||
|
|
2a2049e678 | ||
|
|
146aa85e76 | ||
|
|
a26c7497b5 | ||
|
|
da67135f5e | ||
|
|
aebb6d1762 | ||
|
|
8f5cf6a0b4 | ||
|
|
875017db96 | ||
|
|
c0f34b684d | ||
|
|
613956bbaf | ||
|
|
041ba5c05b | ||
|
|
05c21cfd6d | ||
|
|
4898f99ae1 | ||
|
|
be920e8c44 | ||
|
|
0fa5ac536b | ||
|
|
cef359af29 | ||
|
|
2de72b73c1 | ||
|
|
a794331c1a | ||
|
|
e09be543bc | ||
|
|
88085c48d2 | ||
|
|
e32010771b | ||
|
|
03e7484e71 | ||
|
|
d9fd4ec286 | ||
|
|
8f14f0347c |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- Image placeholders on blog post cards in `/explore`
|
||||
- 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
|
||||
- CompactButton component for highlight cards
|
||||
- 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
|
||||
- Use user's custom highlight color for Highlights tab
|
||||
- Render library articles using BlogPostCard component for consistency
|
||||
- Use faBooks icon for Mark as Read button
|
||||
- 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
|
||||
- Correct fetchBookmarks usage with callback pattern in Me component
|
||||
- Add padding to prevent quote text from overlapping timestamp
|
||||
- Improve spacing and alignment of highlight card elements
|
||||
- Align corner elements symmetrically with proper margins
|
||||
- Group relay icon and author in footer-left for consistent alignment
|
||||
- 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
|
||||
- Tighten vertical spacing on highlight cards
|
||||
- Left-align text inside author card
|
||||
- Constrain `/me` page content width to match author card (600px)
|
||||
- Improve tab border styling for dark theme
|
||||
- Make relay indicator match CompactButton (same look as menu)
|
||||
- Align relay indicator within footer with symmetric spacing
|
||||
- Make header and footer full-width with borders and corners
|
||||
|
||||
## [0.5.4] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
@@ -758,7 +823,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.2...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
|
||||
[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
|
||||
[0.5.0]: https://github.com/dergigi/boris/compare/v0.4.3...v0.5.0
|
||||
|
||||
197
api/video-meta.ts
Normal file
197
api/video-meta.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { getSubtitles, getVideoDetails } from '@treeee/youtube-caption-extractor'
|
||||
|
||||
type Caption = { start: number; dur: number; text: string }
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
|
||||
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
|
||||
// YouTube patterns
|
||||
const youtubePatterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/v\/([^&\n?#]+)/
|
||||
]
|
||||
|
||||
for (const pattern of youtubePatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'youtube' }
|
||||
}
|
||||
}
|
||||
|
||||
// Vimeo patterns
|
||||
const vimeoPatterns = [
|
||||
/vimeo\.com\/(\d+)/,
|
||||
/player\.vimeo\.com\/video\/(\d+)/
|
||||
]
|
||||
|
||||
for (const pattern of vimeoPatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'vimeo' }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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 })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
return { caps, lang, isAuto: !manualFirst }
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const url = (req.query.url as string | undefined)?.trim()
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
|
||||
if (!url && !videoId) {
|
||||
return bad(res, 400, 'Missing url or videoId parameter')
|
||||
}
|
||||
|
||||
// Extract video info from URL or use provided videoId
|
||||
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
|
||||
|
||||
if (url) {
|
||||
const extracted = extractVideoId(url)
|
||||
if (!extracted) {
|
||||
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
|
||||
}
|
||||
videoInfo = extracted
|
||||
} else {
|
||||
// If only videoId is provided, assume YouTube for backward compatibility
|
||||
videoInfo = { id: videoId!, source: 'youtube' }
|
||||
}
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
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 || ''
|
||||
|
||||
// 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[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoInfo.id, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoInfo.id, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else {
|
||||
return bad(res, 400, 'Unsupported video source')
|
||||
}
|
||||
} catch (e) {
|
||||
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
|
||||
}
|
||||
}
|
||||
93
api/vimeo-meta.ts
Normal file
93
api/vimeo-meta.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string) {
|
||||
return `vimeo|${videoId}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
// Validate that videoId is a number
|
||||
if (!/^\d+$/.test(videoId)) {
|
||||
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
|
||||
}
|
||||
|
||||
const cacheKey = buildKey(videoId)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, description } = await getVimeoMetadata(videoId)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch Vimeo metadata')
|
||||
}
|
||||
}
|
||||
101
api/youtube-meta.ts
Normal file
101
api/youtube-meta.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
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
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
|
||||
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
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 })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
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 = ''
|
||||
|
||||
// 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[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoId, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoId, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch YouTube metadata')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2090
package-lock.json
generated
2090
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.7",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -14,6 +14,8 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
"@vercel/node": "^5.3.26",
|
||||
"applesauce-accounts": "^4.0.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
"applesauce-core": "^4.0.0",
|
||||
@@ -23,11 +25,15 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -72,6 +72,28 @@ function AppRoutes({
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
@@ -28,15 +28,19 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
className="blog-post-card"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<div className="blog-post-card-image">
|
||||
{post.image ? (
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="blog-post-image-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
|
||||
@@ -110,8 +110,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -127,8 +125,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
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'
|
||||
|
||||
@@ -18,7 +19,6 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
@@ -35,7 +35,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -44,17 +43,49 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
|
||||
// Fetch OG image if we don't have any other image
|
||||
React.useEffect(() => {
|
||||
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
|
||||
fetchOgImage(firstUrl).then(setOgImage)
|
||||
}
|
||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
@@ -85,6 +116,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
@@ -96,23 +128,22 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
const classification = classifyUrl(url)
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={() => onSelectUrl?.(url)}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel={classification.buttonText}
|
||||
title={classification.buttonText}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -120,7 +151,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 1 && (
|
||||
<button
|
||||
className="expand-toggle-urls"
|
||||
onClick={() => setUrlsExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
>
|
||||
@@ -149,7 +180,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
@@ -165,15 +196,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
className="author-link-minimal"
|
||||
title="Open author in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</div>
|
||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-ico
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,10 +13,9 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -24,14 +24,17 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
articleSummary
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined, settings)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -55,6 +58,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
@@ -76,22 +86,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
</button>
|
||||
)}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ interface LargeViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
@@ -32,7 +31,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
@@ -44,12 +42,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
} else {
|
||||
@@ -84,6 +98,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="author-link-minimal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
@@ -95,17 +110,13 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
||||
<button className="large-read-button" onClick={handleReadNow}>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showMe = location.pathname === '/me'
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
|
||||
// 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'
|
||||
|
||||
// Track previous location for going back from settings/me/explore
|
||||
useEffect(() => {
|
||||
@@ -263,7 +269,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -23,6 +30,9 @@ import {
|
||||
} from '../services/reactionService'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -58,7 +68,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
highlights = [],
|
||||
showHighlights = true,
|
||||
@@ -79,6 +88,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
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 articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = 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({
|
||||
@@ -101,6 +115,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -112,6 +146,120 @@ 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
|
||||
const getArticleLinks = () => {
|
||||
if (!currentArticle) return null
|
||||
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3)
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: currentArticle.pubkey,
|
||||
identifier: dTag,
|
||||
relays: relayHints
|
||||
})
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(naddr),
|
||||
native: `nostr:${naddr}`
|
||||
}
|
||||
}
|
||||
|
||||
const articleLinks = getArticleLinks()
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = () => {
|
||||
if (articleLinks) {
|
||||
window.location.href = articleLinks.native
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
@@ -216,25 +364,116 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{processedMarkdown || markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
title={ytMeta?.title || title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
summary={undefined}
|
||||
published={published}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
{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">
|
||||
<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={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
@@ -262,6 +501,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && articleLinks && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showArticleMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
@@ -272,7 +545,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheck : faBooks}
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
@@ -123,7 +123,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const getHighlightLink = () => {
|
||||
const getHighlightLinks = () => {
|
||||
// Encode the highlight event itself (kind 9802) as a nevent
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
@@ -136,10 +136,14 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
author: highlight.pubkey,
|
||||
kind: 9802
|
||||
})
|
||||
return getNostrUrl(nevent)
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(nevent),
|
||||
native: `nostr:${nevent}`
|
||||
}
|
||||
}
|
||||
|
||||
const highlightLink = getHighlightLink()
|
||||
const highlightLinks = getHighlightLinks()
|
||||
|
||||
// Handle rebroadcast to all relays
|
||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||
@@ -283,9 +287,15 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setShowMenu(!showMenu)
|
||||
}
|
||||
|
||||
const handleOpenExternal = (e: React.MouseEvent) => {
|
||||
const handleOpenPortal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(highlightLink, '_blank', 'noopener,noreferrer')
|
||||
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
@@ -364,11 +374,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
<div className="highlight-menu">
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenExternal}
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</span>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="highlight-menu-item highlight-menu-item-danger"
|
||||
|
||||
@@ -1,34 +1,50 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
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 { Bookmark } from '../types/bookmarks'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [activeTab, setActiveTab] = useState<TabType>('highlights')
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -42,6 +58,14 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
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)
|
||||
}
|
||||
|
||||
// Fetch highlights and read articles
|
||||
const [userHighlights, userReadArticles] = await Promise.all([
|
||||
fetchHighlights(relayPool, activeAccount.pubkey),
|
||||
@@ -52,12 +76,19 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, setBookmarks)
|
||||
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(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
@@ -70,7 +101,14 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted
|
||||
if (activeAccount) {
|
||||
updateCachedHighlights(activeAccount.pubkey, updated)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
@@ -83,7 +121,51 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
let hasUrl = false
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
if (ib.kind === 30023) return true
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For regular URLs, navigate to the reader route
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.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
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
@@ -125,20 +207,53 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
return bookmarks.length === 0 ? (
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<div key={bookmark.id} className="bookmark-item">
|
||||
<a href={bookmark.url} target="_blank" rel="noopener noreferrer">
|
||||
<h3>{bookmark.title || 'Untitled'}</h3>
|
||||
{bookmark.content && <p>{bookmark.content.slice(0, 150)}...</p>}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => setViewMode('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => setViewMode('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -169,30 +284,39 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
<div className="explore-header">
|
||||
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => setActiveTab('highlights')}
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
Highlights ({highlights.length})
|
||||
<span className="tab-label">Highlights</span>
|
||||
<span className="tab-count">({highlights.length})</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => setActiveTab('reading-list')}
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
Reading List ({bookmarks.length})
|
||||
<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={() => setActiveTab('archive')}
|
||||
onClick={() => navigate('/me/archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
Archive ({readArticles.length})
|
||||
<span className="tab-label">Archive</span>
|
||||
<span className="tab-count">({readArticles.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
// Poll for relay status updates
|
||||
|
||||
@@ -617,20 +617,69 @@
|
||||
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
|
||||
.reader-markdown pre,
|
||||
.reader-markdown code {
|
||||
background: #111;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.9em;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.reader-markdown pre {
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
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 {
|
||||
padding: 0.1rem 0.3rem;
|
||||
.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 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 code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mark as Read button */
|
||||
@@ -640,7 +689,6 @@
|
||||
align-items: center;
|
||||
padding: 2rem 1rem;
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.mark-as-read-btn {
|
||||
|
||||
54
src/services/meCache.ts
Normal file
54
src/services/meCache.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
readArticles: BlogPostPreview[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const meCache = new Map<string, MeCache>() // key: pubkey
|
||||
|
||||
export function getCachedMeData(pubkey: string): MeCache | null {
|
||||
const entry = meCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry
|
||||
}
|
||||
|
||||
export function setCachedMeData(
|
||||
pubkey: string,
|
||||
highlights: Highlight[],
|
||||
bookmarks: Bookmark[],
|
||||
readArticles: BlogPostPreview[]
|
||||
): void {
|
||||
meCache.set(pubkey, {
|
||||
highlights,
|
||||
bookmarks,
|
||||
readArticles,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, highlights, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, bookmarks, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
77
src/services/youtubeMetaService.ts
Normal file
77
src/services/youtubeMetaService.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type Caption = { start: number; dur: number; text: string }
|
||||
export type YouTubeMeta = {
|
||||
title: string
|
||||
description?: string
|
||||
captions: Caption[]
|
||||
transcript?: string
|
||||
lang: string
|
||||
isAuto?: boolean
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
type CachedMeta = {
|
||||
data: YouTubeMeta
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
function cacheKey(videoId: string, lang: string) {
|
||||
return `yt_meta_${videoId}_${lang}`
|
||||
}
|
||||
|
||||
function load(videoId: string, lang: string): YouTubeMeta | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(cacheKey(videoId, lang))
|
||||
if (!raw) return null
|
||||
const { data, timestamp } = JSON.parse(raw) as CachedMeta
|
||||
if (Date.now() - timestamp > TTL_MS) {
|
||||
localStorage.removeItem(cacheKey(videoId, lang))
|
||||
return null
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(videoId: string, lang: string, data: YouTubeMeta) {
|
||||
try {
|
||||
const value: CachedMeta = { data, timestamp: Date.now() }
|
||||
localStorage.setItem(cacheKey(videoId, lang), JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function extractYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1)
|
||||
}
|
||||
if (u.searchParams.get('v')) return u.searchParams.get('v')
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
// /shorts/:id or /embed/:id
|
||||
if ((parts[0] === 'shorts' || parts[0] === 'embed') && parts[1]) return parts[1]
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getYouTubeMeta(videoId: string, lang = 'en'): Promise<YouTubeMeta | null> {
|
||||
const cached = load(videoId, lang)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`/api/youtube-meta?videoId=${encodeURIComponent(videoId)}&lang=${encodeURIComponent(lang)}`, {
|
||||
headers: {
|
||||
'x-ui-locale': lang
|
||||
}
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as YouTubeMeta
|
||||
save(videoId, lang, data)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-max-width-video: 1200px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
|
||||
/* Mobile breakpoints */
|
||||
|
||||
@@ -19,13 +19,15 @@
|
||||
.bookmarks-grid.bookmarks-large { gap: 1rem; }
|
||||
}
|
||||
|
||||
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid transparent; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: transparent; background: #2a2a2a; }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.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 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; }
|
||||
@@ -48,13 +50,13 @@
|
||||
.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: #28a745; 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: #218838; }
|
||||
.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; }
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -63,8 +65,8 @@
|
||||
.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-author { flex: 1; }
|
||||
.large-read-button { background: #28a745; 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: #218838; }
|
||||
.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; }
|
||||
|
||||
/* Blog cards (Explore) */
|
||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
||||
@@ -77,9 +79,10 @@
|
||||
.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; }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: #0f0f0f; 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-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; }
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: #28a745; color: white; border-color: #28a745; }
|
||||
.icon-button.success:hover { filter: brightness(1.05); }
|
||||
.icon-button.success { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.success:hover { filter: brightness(1.1); }
|
||||
|
||||
.icon-button.ghost { background: #2a2a2a; }
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
border-bottom-color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
/* Reading List tab uses blue color to match bookmarks icon */
|
||||
.me-tab[data-tab="reading-list"].active {
|
||||
color: #646cff;
|
||||
border-bottom-color: #646cff;
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -63,6 +69,24 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Ensure all reading list elements are left-aligned */
|
||||
.bookmarks-list .individual-bookmark,
|
||||
.bookmarks-list .individual-bookmark * {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Enhanced border styling for reading list cards */
|
||||
.bookmarks-list .individual-bookmark {
|
||||
border: 1px solid #444 !important;
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.bookmarks-list .individual-bookmark:hover {
|
||||
border-color: #555 !important;
|
||||
background: #252525 !important;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
@@ -100,7 +124,7 @@
|
||||
@media (max-width: 768px) {
|
||||
/* Add top breathing room so floating sidebar buttons don't overlap header */
|
||||
.explore-container .explore-header {
|
||||
margin-top: 2.25rem;
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
.me-tabs {
|
||||
@@ -119,6 +143,11 @@
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hide counts on mobile to save space */
|
||||
.me-tab .tab-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-tab-content {
|
||||
padding: 1.25rem 0.75rem;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,17 @@
|
||||
.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; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-card-container { padding: 1.5rem 1rem; }
|
||||
.author-card { padding: 1rem; }
|
||||
.author-card-container {
|
||||
padding: 1.5rem 1rem;
|
||||
margin: 0 1rem; /* Add horizontal margin to prevent bleeding */
|
||||
max-width: calc(100vw - 2rem); /* Ensure it doesn't exceed screen width */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.author-card {
|
||||
padding: 1rem;
|
||||
max-width: 100%; /* Ensure card doesn't exceed container */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.author-card-avatar { width: 48px; height: 48px; }
|
||||
.author-card-avatar svg { font-size: 2rem; }
|
||||
.author-card-name { font-size: 0.95rem; }
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
/* Reader view */
|
||||
.reader { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 0.75rem; text-align: left; overflow: hidden; contain: layout style; }
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Video container - responsive wrapper following react-player docs */
|
||||
.reader-video {
|
||||
position: relative;
|
||||
width: 80vw; /* 80% of viewport width */
|
||||
min-width: 400px; /* Minimum width */
|
||||
max-width: 1000px; /* Maximum width */
|
||||
aspect-ratio: 16/9;
|
||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
||||
background: #000;
|
||||
}
|
||||
.reader.empty { color: #888; }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: #888; }
|
||||
.loading-spinner svg { font-size: 1.2rem; }
|
||||
@@ -26,11 +46,27 @@
|
||||
.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; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown pre, .reader-markdown code { background: #111; border: 1px solid #333; border-radius: 6px; }
|
||||
.reader-markdown pre { padding: 0.75rem; overflow: auto; }
|
||||
.reader-markdown code { padding: 0.1rem 0.3rem; }
|
||||
.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 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 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 code { background: transparent; border: none; padding: 0; display: block; }
|
||||
/* 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-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: 2rem; border-top: 1px solid #333; }
|
||||
.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:active:not(:disabled) { transform: translateY(0); }
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
.pane.main {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: var(--main-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -10,34 +10,39 @@ export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
||||
|
||||
export interface UrlClassification {
|
||||
type: UrlType
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
if (!url) {
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
return { type: 'article' }
|
||||
}
|
||||
const urlLower = url.toLowerCase()
|
||||
|
||||
// Check for YouTube
|
||||
if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be')) {
|
||||
return { type: 'youtube', buttonText: 'WATCH NOW' }
|
||||
return { type: 'youtube' }
|
||||
}
|
||||
|
||||
// Check for popular video hosts
|
||||
const videoHosts = ['vimeo.com', 'dailymotion.com', 'dai.ly', 'video.twimg.com']
|
||||
if (videoHosts.some(host => urlLower.includes(host))) {
|
||||
return { type: 'video' }
|
||||
}
|
||||
|
||||
// Check for video extensions
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v']
|
||||
if (videoExtensions.some(ext => urlLower.includes(ext))) {
|
||||
return { type: 'video', buttonText: 'WATCH NOW' }
|
||||
return { type: 'video' }
|
||||
}
|
||||
|
||||
// Check for image extensions
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']
|
||||
if (imageExtensions.some(ext => urlLower.includes(ext))) {
|
||||
return { type: 'image', buttonText: 'VIEW NOW' }
|
||||
return { type: 'image' }
|
||||
}
|
||||
|
||||
// Default to article
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
return { type: 'article' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
36
src/utils/videoHelpers.ts
Normal file
36
src/utils/videoHelpers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build native app deep link URL for video platforms
|
||||
* Returns null if the platform doesn't have a known native app URL scheme
|
||||
*/
|
||||
export function buildNativeVideoUrl(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname
|
||||
|
||||
if (host.includes('youtube.com')) {
|
||||
const id = u.searchParams.get('v')
|
||||
return id ? `youtube://watch?v=${id}` : `youtube://${u.pathname}${u.search}`
|
||||
}
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
const id = u.pathname.replace('/', '')
|
||||
return id ? `youtube://watch?v=${id}` : 'youtube://'
|
||||
}
|
||||
|
||||
if (host.includes('vimeo.com')) {
|
||||
const id = u.pathname.split('/').filter(Boolean)[0]
|
||||
return id ? `vimeo://app.vimeo.com/videos/${id}` : 'vimeo://'
|
||||
}
|
||||
|
||||
if (host.includes('dailymotion.com') || host === 'dai.ly') {
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
const id = host === 'dai.ly' ? parts[0] : (parts[1] || '')
|
||||
return id ? `dailymotion://video/${id}` : 'dailymotion://'
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user