diff --git a/src/components/ArticleSourceCard.tsx b/src/components/ArticleSourceCard.tsx deleted file mode 100644 index f4e2ec1b..00000000 --- a/src/components/ArticleSourceCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faLink, faHighlighter, faFile } from '@fortawesome/free-solid-svg-icons' - -interface ArticleSourceCardProps { - url: string - highlightCount: number - isSelected: boolean - onClick: () => void - title?: string -} - -const ArticleSourceCard: React.FC = ({ - url, - highlightCount, - isSelected, - onClick, - title -}) => { - // Extract domain from URL for display - const getDomain = (urlString: string) => { - try { - if (urlString.startsWith('nostr:')) { - return 'Nostr Article' - } - const urlObj = new URL(urlString) - return urlObj.hostname.replace('www.', '') - } catch { - return 'Unknown Source' - } - } - - // Get display title - const displayTitle = title || url - const domain = getDomain(url) - const isNostrArticle = url.startsWith('nostr:') - - return ( -
-
- -
-
-

{displayTitle}

-

{domain}

-
- - {highlightCount} highlight{highlightCount !== 1 ? 's' : ''} -
-
-
- ) -} - -export default ArticleSourceCard - diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 788c9e32..bc5601a2 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,27 +1,37 @@ import React, { useState, useEffect } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faSpinner, faExclamationCircle, faHighlighter } from '@fortawesome/free-solid-svg-icons' +import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faBook } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { RelayPool } from 'applesauce-relay' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' +import { fetchBookmarks } from '../services/bookmarkService' +import { fetchReadArticles, ReadArticle } from '../services/libraryService' +import { Bookmark } from '../types/bookmarks' import AuthorCard from './AuthorCard' +import { useSettings } from '../hooks/useSettings' interface MeProps { relayPool: RelayPool } +type TabType = 'highlights' | 'reading-list' | 'library' + const Me: React.FC = ({ relayPool }) => { const activeAccount = Hooks.useActiveAccount() + const settings = useSettings() + const [activeTab, setActiveTab] = useState('highlights') const [highlights, setHighlights] = useState([]) + const [bookmarks, setBookmarks] = useState([]) + const [readArticles, setReadArticles] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { - const loadHighlights = async () => { + const loadData = async () => { if (!activeAccount) { - setError('Please log in to view your highlights') + setError('Please log in to view your data') setLoading(false) return } @@ -30,30 +40,28 @@ const Me: React.FC = ({ relayPool }) => { setLoading(true) setError(null) - // Fetch highlights created by the user - const userHighlights = await fetchHighlights( - relayPool, - activeAccount.pubkey - ) - - if (userHighlights.length === 0) { - setError('No highlights yet. Start highlighting content to see them here!') - } + // Fetch all data in parallel + const [userHighlights, userBookmarks, userReadArticles] = await Promise.all([ + fetchHighlights(relayPool, activeAccount.pubkey), + fetchBookmarks(relayPool, activeAccount, settings).catch(() => ({ bookmarks: [] })), + fetchReadArticles(relayPool, activeAccount.pubkey) + ]) setHighlights(userHighlights) + setBookmarks(Array.isArray(userBookmarks) ? userBookmarks : userBookmarks?.bookmarks || []) + setReadArticles(userReadArticles) } catch (err) { - console.error('Failed to load highlights:', err) - setError('Failed to load highlights. Please try again.') + console.error('Failed to load data:', err) + setError('Failed to load data. Please try again.') } finally { setLoading(false) } } - loadHighlights() - }, [relayPool, activeAccount]) + loadData() + }, [relayPool, activeAccount, settings]) const handleHighlightDelete = (highlightId: string) => { - // Remove highlight from local state setHighlights(prev => prev.filter(h => h.id !== highlightId)) } @@ -78,23 +86,107 @@ const Me: React.FC = ({ relayPool }) => { ) } + const renderTabContent = () => { + switch (activeTab) { + case 'highlights': + return highlights.length === 0 ? ( +
+

No highlights yet. Start highlighting content to see them here!

+
+ ) : ( +
+ {highlights.map((highlight) => ( + + ))} +
+ ) + + case 'reading-list': + return bookmarks.length === 0 ? ( +
+

No bookmarks yet. Bookmark articles to see them here!

+
+ ) : ( + + ) + + case 'library': + return readArticles.length === 0 ? ( +
+

No read articles yet. Mark articles as read to see them here!

+
+ ) : ( +
+ {readArticles.map((article) => ( +
+

+ {article.url ? ( + + {article.url} + + ) : ( + `Event: ${article.eventId?.slice(0, 12)}...` + )} +

+ + Marked as read: {new Date(article.markedAt * 1000).toLocaleDateString()} + +
+ ))} +
+ ) + + default: + return null + } + } + return (
{activeAccount && } -

- {highlights.length} highlight{highlights.length !== 1 ? 's' : ''} -

+ +
+ + + +
-
- {highlights.map((highlight) => ( - - ))} + +
+ {renderTabContent()}
) diff --git a/src/services/libraryService.ts b/src/services/libraryService.ts new file mode 100644 index 00000000..6e60b6fe --- /dev/null +++ b/src/services/libraryService.ts @@ -0,0 +1,134 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { RELAYS } from '../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { MARK_AS_READ_EMOJI } from './reactionService' + +export interface ReadArticle { + id: string + url?: string + eventId?: string + eventAuthor?: string + eventKind?: number + markedAt: number + reactionId: string +} + +/** + * Fetches all articles that the user has marked as read + * Returns both nostr-native articles (kind:7) and external URLs (kind:17) + */ +export async function fetchReadArticles( + relayPool: RelayPool, + userPubkey: string +): Promise { + try { + const orderedRelays = prioritizeLocalRelays(RELAYS) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) + + // Fetch kind:7 reactions (nostr-native articles) + const kind7Local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [7], authors: [userPubkey] }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + + const kind7Remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [7], authors: [userPubkey] }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + + const kind7Events: NostrEvent[] = await lastValueFrom( + merge(kind7Local$, kind7Remote$).pipe(toArray()) + ) + + // Fetch kind:17 reactions (external URLs) + const kind17Local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [17], authors: [userPubkey] }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + + const kind17Remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [17], authors: [userPubkey] }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + + const kind17Events: NostrEvent[] = await lastValueFrom( + merge(kind17Local$, kind17Remote$).pipe(toArray()) + ) + + const readArticles: ReadArticle[] = [] + + // Process kind:7 reactions (nostr-native articles) + for (const event of kind7Events) { + if (event.content === MARK_AS_READ_EMOJI) { + const eTag = event.tags.find((t) => t[0] === 'e') + const pTag = event.tags.find((t) => t[0] === 'p') + const kTag = event.tags.find((t) => t[0] === 'k') + + if (eTag && eTag[1]) { + readArticles.push({ + id: eTag[1], + eventId: eTag[1], + eventAuthor: pTag?.[1], + eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined, + markedAt: event.created_at, + reactionId: event.id + }) + } + } + } + + // Process kind:17 reactions (external URLs) + for (const event of kind17Events) { + if (event.content === MARK_AS_READ_EMOJI) { + const rTag = event.tags.find((t) => t[0] === 'r') + + if (rTag && rTag[1]) { + readArticles.push({ + id: rTag[1], + url: rTag[1], + markedAt: event.created_at, + reactionId: event.id + }) + } + } + } + + // Sort by markedAt (most recent first) and dedupe + const deduped = new Map() + readArticles + .sort((a, b) => b.markedAt - a.markedAt) + .forEach((article) => { + if (!deduped.has(article.id)) { + deduped.set(article.id, article) + } + }) + + return Array.from(deduped.values()) + } catch (error) { + console.error('Failed to fetch read articles:', error) + return [] + } +} + diff --git a/src/styles/components/me.css b/src/styles/components/me.css index 5942ed50..9bbd6269 100644 --- a/src/styles/components/me.css +++ b/src/styles/components/me.css @@ -1,202 +1,137 @@ -/* Me page layout */ -.me-container { - padding: 2rem; - max-width: 1400px; - margin: 0 auto; - min-height: 100vh; -} - -.me-header { - text-align: center; - margin-bottom: 2rem; -} - -.me-header h1 { - font-size: 2rem; - margin: 0 0 0.5rem 0; - color: #646cff; +/* Me page tabs */ +.me-tabs { display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; + gap: 0.5rem; + margin-top: 1rem; + border-bottom: 2px solid var(--border-color, #e0e0e0); + overflow-x: auto; } -.me-subtitle { - font-size: 1rem; - color: rgba(255, 255, 255, 0.6); - margin: 0; -} - -/* Two-pane layout */ -.me-two-pane { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; - height: calc(100vh - 200px); - min-height: 500px; -} - -.me-sources-pane, -.me-highlights-pane { - display: flex; - flex-direction: column; - background: #1a1a1a; - border: 1px solid #333; - border-radius: 12px; - overflow: hidden; -} - -.me-pane-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0; - padding: 1rem 1.5rem; - border-bottom: 1px solid #333; - color: #fff; - background: #1e1e1e; -} - -.me-pane-count { - color: #888; - font-size: 1rem; - font-weight: 400; -} - -.me-sources-list, -.me-highlights-list { - flex: 1; - overflow-y: auto; - padding: 1rem; -} - -.me-sources-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.me-highlights-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.me-empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - color: #888; - gap: 1rem; -} - -.me-empty-state svg { - color: #555; -} - -/* Article source card */ -.article-source-card { - background: #1e1e1e; - border: 2px solid #333; - border-radius: 8px; - padding: 1rem; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - gap: 1rem; - align-items: flex-start; -} - -.article-source-card:hover { - border-color: #646cff; - background: #252525; - transform: translateX(4px); -} - -.article-source-card.selected { - border-color: #646cff; - background: #252525; - box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2); -} - -.article-source-icon { - font-size: 1.5rem; - color: #646cff; - flex-shrink: 0; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(100, 108, 255, 0.1); - border-radius: 8px; -} - -.article-source-content { - flex: 1; - min-width: 0; -} - -.article-source-title { - font-size: 0.95rem; - font-weight: 600; - margin: 0 0 0.25rem 0; - color: #fff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.article-source-domain { - font-size: 0.8rem; - color: #888; - margin: 0 0 0.5rem 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.article-source-meta { +.me-tab { display: flex; align-items: center; gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary, #666); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.me-tab:hover { + color: var(--text-primary, #000); + background: var(--hover-bg, rgba(0, 0, 0, 0.03)); +} + +.me-tab.active { + color: var(--primary-color, #8b5cf6); + border-bottom-color: var(--primary-color, #8b5cf6); +} + +.me-tab svg { + font-size: 1rem; +} + +.me-tab-content { + padding: 1.5rem 0; +} + +/* Bookmarks list */ +.bookmarks-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.bookmark-item { + padding: 1rem; + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + transition: all 0.2s ease; +} + +.bookmark-item:hover { + border-color: var(--primary-color, #8b5cf6); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.bookmark-item a { + text-decoration: none; + color: inherit; +} + +.bookmark-item h3 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + color: var(--text-primary, #000); +} + +.bookmark-item p { + margin: 0; + font-size: 0.9rem; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +/* Library list */ +.library-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.library-item { + padding: 1rem; + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + transition: all 0.2s ease; +} + +.library-item:hover { + border-color: var(--primary-color, #8b5cf6); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.library-item p { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.library-item a { + color: var(--primary-color, #8b5cf6); + text-decoration: none; + word-break: break-all; +} + +.library-item a:hover { + text-decoration: underline; +} + +.library-item small { font-size: 0.85rem; - color: #aaa; + color: var(--text-secondary, #666); } -.article-source-meta svg { - color: #646cff; -} - -/* Mobile responsive */ +/* Mobile responsiveness */ @media (max-width: 768px) { - .me-container { - padding: 1rem; + .me-tabs { + gap: 0.25rem; } - - .me-header h1 { - font-size: 1.5rem; + + .me-tab { + padding: 0.6rem 0.9rem; + font-size: 0.85rem; } - - .me-two-pane { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr; - height: auto; - min-height: auto; - gap: 1rem; - } - - .me-sources-pane { - max-height: 300px; - } - - .me-highlights-pane { - min-height: 400px; - } - - .article-source-card:hover { - transform: translateX(2px); + + .me-tab svg { + font-size: 0.9rem; } }