diff --git a/src/components/ArticleSourceCard.tsx b/src/components/ArticleSourceCard.tsx new file mode 100644 index 00000000..f4e2ec1b --- /dev/null +++ b/src/components/ArticleSourceCard.tsx @@ -0,0 +1,59 @@ +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 8a4fca4c..a06efd4d 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' @@ -8,6 +8,7 @@ import { Models } from 'applesauce-core' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' +import ArticleSourceCard from './ArticleSourceCard' interface MeProps { relayPool: RelayPool @@ -18,6 +19,7 @@ const Me: React.FC = ({ relayPool }) => { const [highlights, setHighlights] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [selectedUrl, setSelectedUrl] = useState(null) const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null) @@ -68,6 +70,36 @@ const Me: React.FC = ({ relayPool }) => { setHighlights(prev => prev.filter(h => h.id !== highlightId)) } + // Group highlights by their URL reference + const groupedHighlights = useMemo(() => { + const grouped = new Map() + + highlights.forEach(highlight => { + const url = highlight.urlReference || 'unknown' + if (!grouped.has(url)) { + grouped.set(url, []) + } + grouped.get(url)!.push(highlight) + }) + + // Sort by number of highlights (descending) + return Array.from(grouped.entries()) + .sort((a, b) => b[1].length - a[1].length) + }, [highlights]) + + // Auto-select first article if nothing is selected + useEffect(() => { + if (!selectedUrl && groupedHighlights.length > 0) { + setSelectedUrl(groupedHighlights[0][0]) + } + }, [groupedHighlights, selectedUrl]) + + // Get highlights for selected article + const selectedHighlights = useMemo(() => { + if (!selectedUrl) return [] + return highlights.filter(h => (h.urlReference || 'unknown') === selectedUrl) + }, [highlights, selectedUrl]) + if (loading) { return (
@@ -90,25 +122,57 @@ const Me: React.FC = ({ relayPool }) => { } return ( -
-
+
+

{getUserDisplayName()}

-

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

+ {highlights.length} highlight{highlights.length !== 1 ? 's' : ''} + {' '}•{' '} {groupedHighlights.length} source{groupedHighlights.length !== 1 ? 's' : ''}

-
- {highlights.map((highlight) => ( - - ))} +
+
+

Sources

+
+ {groupedHighlights.map(([url, hlts]) => ( + setSelectedUrl(url)} + /> + ))} +
+
+
+

+ Highlights + {selectedHighlights.length > 0 && ( + ({selectedHighlights.length}) + )} +

+
+ {selectedHighlights.length > 0 ? ( + selectedHighlights.map((highlight) => ( + + )) + ) : ( +
+ +

Select a source to view highlights

+
+ )} +
+
) diff --git a/src/index.css b/src/index.css index 704986af..6180fa3c 100644 --- a/src/index.css +++ b/src/index.css @@ -11,6 +11,7 @@ @import './styles/components/forms.css'; @import './styles/components/reader.css'; @import './styles/components/settings.css'; +@import './styles/components/me.css'; @import './styles/utils/animations.css'; @import './styles/utils/utilities.css'; diff --git a/src/styles/components/me.css b/src/styles/components/me.css new file mode 100644 index 00000000..5942ed50 --- /dev/null +++ b/src/styles/components/me.css @@ -0,0 +1,202 @@ +/* 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; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.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 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: #aaa; +} + +.article-source-meta svg { + color: #646cff; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .me-container { + padding: 1rem; + } + + .me-header h1 { + font-size: 1.5rem; + } + + .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); + } +} +