diff --git a/.gitignore b/.gitignore index 40037f4e..44af6a48 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist *.log .DS_Store -# Applesauce Reference +# Reference Projects applesauce +primal-web-app diff --git a/package-lock.json b/package-lock.json index ba5f7236..b8ad7346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.6.9", + "version": "0.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.6.9", + "version": "0.6.13", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -22,6 +22,7 @@ "applesauce-react": "^4.0.0", "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", + "fast-average-color": "^9.5.0", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", @@ -6086,6 +6087,15 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-average-color": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz", + "integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 524c74bd..1b390f08 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "applesauce-react": "^4.0.0", "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", + "fast-average-color": "^9.5.0", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", diff --git a/src/components/BookmarkItem.tsx b/src/components/BookmarkItem.tsx index bd97cbdd..f842879a 100644 --- a/src/components/BookmarkItem.tsx +++ b/src/components/BookmarkItem.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react' -import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons' +import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons' +import { faGlobe } from '@fortawesome/free-solid-svg-icons' +import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' import { npubEncode, neventEncode } from 'nostr-tools/nip19' @@ -66,18 +68,40 @@ export const BookmarkItem: React.FC = ({ bookmark, index, onS return short(bookmark.pubkey) // fallback to short pubkey } - // use helper from kindIcon.ts + // Get content type icon based on bookmark kind and URL classification + const getContentTypeIcon = (): IconDefinition => { + if (isArticle) return faNewspaper + + // For web bookmarks, classify the URL to determine icon + if (isWebBookmark && firstUrlClassification) { + switch (firstUrlClassification.type) { + case 'youtube': + case 'video': + return faCirclePlay + case 'image': + return faCamera + case 'article': + return faNewspaper + default: + return faGlobe + } + } + + if (!hasUrls) return faStickyNote // Just a text note + if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay + return faFileLines + } const getIconForUrlType = (url: string) => { const classification = classifyUrl(url) switch (classification.type) { case 'youtube': case 'video': - return faPlay + return faCirclePlay case 'image': - return faEye + return faCamera default: - return faBookOpen + return faFileLines } } @@ -113,11 +137,13 @@ export const BookmarkItem: React.FC = ({ bookmark, index, onS getAuthorDisplayName, handleReadNow, articleImage, - articleSummary + articleSummary, + contentTypeIcon: getContentTypeIcon() } if (viewMode === 'compact') { - return + const { articleImage: _articleImage, ...compactProps } = sharedProps + return } if (viewMode === 'large') { @@ -125,5 +151,5 @@ export const BookmarkItem: React.FC = ({ bookmark, index, onS return } - return + return } diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 6b7a6244..13343259 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -1,17 +1,24 @@ -import React, { useRef } from 'react' +import React, { useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons' +import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons' import { formatDistanceToNow } from 'date-fns' import { RelayPool } from 'applesauce-relay' import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { BookmarkItem } from './BookmarkItem' import SidebarHeader from './SidebarHeader' import IconButton from './IconButton' +import CompactButton from './CompactButton' import { ViewMode } from './Bookmarks' -import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { BookmarkSkeleton } from './Skeletons' +import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils' +import { UserSettings } from '../services/settingsService' +import AddBookmarkModal from './AddBookmarkModal' +import { createWebBookmark } from '../services/webBookmarkService' +import { RELAYS } from '../config/relays' +import { Hooks } from 'applesauce-react' interface BookmarkListProps { bookmarks: Bookmark[] @@ -29,6 +36,7 @@ interface BookmarkListProps { loading?: boolean relayPool: RelayPool | null isMobile?: boolean + settings?: UserSettings } export const BookmarkList: React.FC = ({ @@ -46,9 +54,22 @@ export const BookmarkList: React.FC = ({ lastFetchTime, loading = false, relayPool, - isMobile = false + isMobile = false, + settings }) => { + const navigate = useNavigate() const bookmarksListRef = useRef(null) + const friendsColor = settings?.highlightColorFriends || '#f97316' + const [showAddModal, setShowAddModal] = useState(false) + const activeAccount = Hooks.useActiveAccount() + + const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => { + if (!activeAccount || !relayPool) { + throw new Error('Please login to create bookmarks') + } + + await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS) + } // Pull-to-refresh for bookmarks const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({ @@ -62,36 +83,31 @@ export const BookmarkList: React.FC = ({ isDisabled: !onRefresh }) - // Helper to check if a bookmark has either content or a URL - const hasContentOrUrl = (ib: IndividualBookmark) => { - // Check if has content (text) - const hasContent = ib.content && ib.content.trim().length > 0 - - // Check if has URL - let hasUrl = false - - // For web bookmarks (kind:39701), URL is in the 'd' tag - if (ib.kind === 39701) { - const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1] - hasUrl = !!dTag && dTag.trim().length > 0 - } else { - // For other bookmarks, extract URLs from content - const urls = extractUrlsFromContent(ib.content || '') - hasUrl = urls.length > 0 - } - - // Always show articles (kind:30023) as they have special handling - if (ib.kind === 30023) return true - - // Otherwise, must have either content or URL - return hasContent || hasUrl - } - // Merge and flatten all individual bookmarks from all lists - // Re-sort after flattening to ensure newest first across all lists 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))) + .filter(hasContent) + + // Separate bookmarks with setName (kind 30003) from regular bookmarks + const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks) + const bookmarkSets = getBookmarkSets(allIndividualBookmarks) + + // Group non-set bookmarks as before + const groups = groupIndividualBookmarks(bookmarksWithoutSet) + const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ + { key: 'private', title: 'Private bookmarks', items: groups.privateItems }, + { key: 'public', title: 'Public bookmarks', items: groups.publicItems }, + { key: 'web', title: 'Web bookmarks', items: groups.web }, + { key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst } + ] + + // Add bookmark sets as additional sections + bookmarkSets.forEach(set => { + sections.push({ + key: `set-${set.name}`, + title: set.title || set.name, + items: set.bookmarks + }) + }) if (isCollapsed) { // Check if the selected URL is in bookmarks @@ -121,7 +137,6 @@ export const BookmarkList: React.FC = ({ onToggleCollapse={onToggleCollapse} onLogout={onLogout} onOpenSettings={onOpenSettings} - relayPool={relayPool} isMobile={isMobile} /> @@ -150,53 +165,87 @@ export const BookmarkList: React.FC = ({ isRefreshing={isPulling || isRefreshing || false} pullPosition={pullPosition} /> -
- {allIndividualBookmarks.map((individualBookmark, index) => - - )} -
+ {sections.filter(s => s.items.length > 0).map(section => ( +
+
+

{section.title}

+ {section.key === 'web' && activeAccount && ( + setShowAddModal(true)} + title="Add web bookmark" + ariaLabel="Add web bookmark" + className="bookmark-section-action" + /> + )} +
+
+ {section.items.map((individualBookmark, index) => ( + + ))} +
+
+ ))} )}
- {onRefresh && ( +
navigate('/support')} + title="Support Boris" + ariaLabel="Support" variant="ghost" - disabled={isRefreshing} - spin={isRefreshing} + style={{ color: friendsColor }} /> - )} - onViewModeChange('compact')} - title="Compact list view" - ariaLabel="Compact list view" - variant={viewMode === 'compact' ? 'primary' : 'ghost'} - /> - onViewModeChange('cards')} - title="Cards view" - ariaLabel="Cards view" - variant={viewMode === 'cards' ? 'primary' : 'ghost'} - /> - onViewModeChange('large')} - title="Large preview view" - ariaLabel="Large preview view" - variant={viewMode === 'large' ? 'primary' : 'ghost'} - /> +
+
+ {onRefresh && ( + + )} + onViewModeChange('compact')} + title="Compact list view" + ariaLabel="Compact list view" + variant={viewMode === 'compact' ? 'primary' : 'ghost'} + /> + onViewModeChange('cards')} + title="Cards view" + ariaLabel="Cards view" + variant={viewMode === 'cards' ? 'primary' : 'ghost'} + /> + onViewModeChange('large')} + title="Large preview view" + ariaLabel="Large preview view" + variant={viewMode === 'large' ? 'primary' : 'ghost'} + /> +
+ {showAddModal && ( + setShowAddModal(false)} + onSave={handleSaveBookmark} + /> + )} ) } diff --git a/src/components/BookmarkViews/CardView.tsx b/src/components/BookmarkViews/CardView.tsx index 8af56045..d73dde29 100644 --- a/src/components/BookmarkViews/CardView.tsx +++ b/src/components/BookmarkViews/CardView.tsx @@ -1,13 +1,12 @@ import React, { useState } from 'react' import { Link } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons' +import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' +import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IndividualBookmark } from '../../types/bookmarks' import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' -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 { getEventUrl } from '../../config/nostrGateways' @@ -18,13 +17,13 @@ interface CardViewProps { hasUrls: boolean extractedUrls: string[] onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void - getIconForUrlType: IconGetter authorNpub: string eventNevent?: string getAuthorDisplayName: () => string handleReadNow: (e: React.MouseEvent) => void articleImage?: string articleSummary?: string + contentTypeIcon: IconDefinition } export const CardView: React.FC = ({ @@ -33,13 +32,13 @@ export const CardView: React.FC = ({ hasUrls, extractedUrls, onSelectUrl, - getIconForUrlType, authorNpub, eventNevent, getAuthorDisplayName, handleReadNow, articleImage, - articleSummary + articleSummary, + contentTypeIcon }) => { const firstUrl = hasUrls ? extractedUrls[0] : null const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null @@ -52,7 +51,6 @@ export const CardView: React.FC = ({ 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 @@ -92,18 +90,9 @@ export const CardView: React.FC = ({ )}
- {isWebBookmark ? ( - - - - - ) : bookmark.isPrivate ? ( - <> - - - - ) : ( - + + {bookmark.isPrivate && ( + )} @@ -127,23 +116,14 @@ export const CardView: React.FC = ({
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => { return ( -
- - { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }} - /> -
+ ) })} {extractedUrls.length > 1 && ( diff --git a/src/components/BookmarkViews/CompactView.tsx b/src/components/BookmarkViews/CompactView.tsx index f4d61499..46e06240 100644 --- a/src/components/BookmarkViews/CompactView.tsx +++ b/src/components/BookmarkViews/CompactView.tsx @@ -1,10 +1,10 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons' +import { faUserLock } from '@fortawesome/free-solid-svg-icons' +import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IndividualBookmark } from '../../types/bookmarks' import { formatDateCompact } from '../../utils/bookmarkUtils' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' -import { useImageCache } from '../../hooks/useImageCache' interface CompactViewProps { bookmark: IndividualBookmark @@ -12,8 +12,8 @@ interface CompactViewProps { hasUrls: boolean extractedUrls: string[] onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void - articleImage?: string articleSummary?: string + contentTypeIcon: IconDefinition } export const CompactView: React.FC = ({ @@ -22,16 +22,13 @@ export const CompactView: React.FC = ({ hasUrls, extractedUrls, onSelectUrl, - articleImage, - articleSummary + articleSummary, + contentTypeIcon }) => { 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) - const handleCompactClick = () => { if (!onSelectUrl) return @@ -55,26 +52,10 @@ export const CompactView: React.FC = ({ role={isClickable ? 'button' : undefined} tabIndex={isClickable ? 0 : undefined} > - {/* Thumbnail image */} - {cachedImage && ( -
- -
- )} - - {isWebBookmark ? ( - - - - - ) : bookmark.isPrivate ? ( - <> - - - - ) : ( - + + {bookmark.isPrivate && ( + )} {displayText && ( diff --git a/src/components/BookmarkViews/LargeView.tsx b/src/components/BookmarkViews/LargeView.tsx index 80001976..106b85a0 100644 --- a/src/components/BookmarkViews/LargeView.tsx +++ b/src/components/BookmarkViews/LargeView.tsx @@ -1,6 +1,8 @@ import React from 'react' import { Link } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faUserLock } from '@fortawesome/free-solid-svg-icons' +import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IndividualBookmark } from '../../types/bookmarks' import { formatDate } from '../../utils/bookmarkUtils' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' @@ -21,6 +23,7 @@ interface LargeViewProps { getAuthorDisplayName: () => string handleReadNow: (e: React.MouseEvent) => void articleSummary?: string + contentTypeIcon: IconDefinition } export const LargeView: React.FC = ({ @@ -35,7 +38,8 @@ export const LargeView: React.FC = ({ eventNevent, getAuthorDisplayName, handleReadNow, - articleSummary + articleSummary, + contentTypeIcon }) => { const cachedImage = useImageCache(previewImage || undefined) const isArticle = bookmark.kind === 30023 @@ -90,6 +94,12 @@ export const LargeView: React.FC = ({ )}
+ + + {bookmark.isPrivate && ( + + )} + = ({ relayPool, eventStore, settings, acti ) } return filteredBlogPosts.length === 0 ? ( -
-

No blog posts yet. Pull to refresh!

+
+
) : (
@@ -347,8 +347,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti ) } return classifiedHighlights.length === 0 ? ( -
-

No highlights yet. Pull to refresh!

+
+
) : (
diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 45876095..64bc4e6c 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -19,11 +19,11 @@ 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' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' +import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' interface MeProps { relayPool: RelayPool @@ -150,23 +150,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr return `/a/${naddr}` } - // 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 @@ -186,10 +169,16 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } } - // Merge and flatten all individual bookmarks (same logic as BookmarkList) + // Merge and flatten all individual bookmarks 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))) + .filter(hasContent) + const groups = groupIndividualBookmarks(allIndividualBookmarks) + const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ + { key: 'private', title: 'Private bookmarks', items: groups.privateItems }, + { key: 'public', title: 'Public bookmarks', items: groups.publicItems }, + { key: 'web', title: 'Web bookmarks', items: groups.web }, + { key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst } + ] // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0 @@ -208,12 +197,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } return highlights.length === 0 ? ( -
-

- {isOwnProfile - ? 'No highlights yet. Pull to refresh!' - : 'No highlights yet. Pull to refresh!'} -

+
+
) : (
@@ -241,22 +226,27 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } return allIndividualBookmarks.length === 0 ? ( -
-

No bookmarks yet. Pull to refresh!

+
+
) : (
-
- {allIndividualBookmarks.map((individualBookmark, index) => ( - - ))} -
+ {sections.filter(s => s.items.length > 0).map(section => ( +
+

{section.title}

+
+ {section.items.map((individualBookmark, index) => ( + + ))} +
+
+ ))}
= ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } return readArticles.length === 0 ? ( -
-

No read articles yet. Pull to refresh!

+
+
) : (
@@ -327,12 +317,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } return writings.length === 0 ? ( -
-

- {isOwnProfile - ? 'No articles written yet. Pull to refresh!' - : 'No articles written yet. Pull to refresh!'} -

+
+
) : (
@@ -360,12 +346,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
{viewingPubkey && } - {loading && hasData && ( -
- -
- )} -
)} {formattedDate && ( -
+
{formattedDate}
)} @@ -125,7 +132,12 @@ const ReaderHeader: React.FC = ({ {title && (
{formattedDate && ( -
+
{formattedDate}
)} diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index 28a99abe..4997621f 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -1,28 +1,22 @@ import React, { useState } from 'react' import { useNavigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons' +import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' import { Accounts } from 'applesauce-accounts' -import { RelayPool } from 'applesauce-relay' import IconButton from './IconButton' -import AddBookmarkModal from './AddBookmarkModal' -import { createWebBookmark } from '../services/webBookmarkService' -import { RELAYS } from '../config/relays' interface SidebarHeaderProps { onToggleCollapse: () => void onLogout: () => void onOpenSettings: () => void - relayPool: RelayPool | null isMobile?: boolean } -const SidebarHeader: React.FC = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => { +const SidebarHeader: React.FC = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => { const [isConnecting, setIsConnecting] = useState(false) - const [showAddModal, setShowAddModal] = useState(false) const navigate = useNavigate() const activeAccount = Hooks.useActiveAccount() const accountManager = Hooks.useAccountManager() @@ -54,14 +48,6 @@ const SidebarHeader: React.FC = ({ onToggleCollapse, onLogou return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}` } - const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => { - if (!activeAccount || !relayPool) { - throw new Error('Please login to create bookmarks') - } - - await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS) - } - const profileImage = getProfileImage() return ( @@ -117,13 +103,6 @@ const SidebarHeader: React.FC = ({ onToggleCollapse, onLogou ariaLabel="Explore" variant="ghost" /> - navigate('/support')} - title="Support" - ariaLabel="Support" - variant="ghost" - /> = ({ onToggleCollapse, onLogou ariaLabel="Settings" variant="ghost" /> - {activeAccount && ( - setShowAddModal(true)} - title="Add bookmark" - ariaLabel="Add bookmark" - variant="ghost" - /> - )} {activeAccount ? ( = ({ onToggleCollapse, onLogou )}
- {showAddModal && ( - setShowAddModal(false)} - onSave={handleSaveBookmark} - /> - )} ) } diff --git a/src/components/Support.tsx b/src/components/Support.tsx index 9c211d19..9449895e 100644 --- a/src/components/Support.tsx +++ b/src/components/Support.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons' +import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons' import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService' import { fetchProfiles } from '../services/profileService' import { UserSettings } from '../services/settingsService' @@ -207,7 +207,7 @@ const SupporterCard: React.FC = ({ supporter, isWhale }) => className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2" style={{ borderColor: 'var(--color-bg)' }} > - +
)}
diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index e11bd7f0..b3912f90 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -324,6 +324,7 @@ const ThreePaneLayout: React.FC = (props) => { loading={props.bookmarksLoading} relayPool={props.relayPool} isMobile={isMobile} + settings={props.settings} />
({ + textColor: '#ffffff' + }) + + useEffect(() => { + if (!imageUrl) { + // No image, use default white text + setColors({ + textColor: '#ffffff' + }) + return + } + + const fac = new FastAverageColor() + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const width = img.naturalWidth + const height = img.naturalHeight + + // Sample top-right corner (last 25% width, first 25% height) + const color = fac.getColor(img, { + left: Math.floor(width * 0.75), + top: 0, + width: Math.floor(width * 0.25), + height: Math.floor(height * 0.25) + }) + + console.log('Adaptive color detected:', { + hex: color.hex, + rgb: color.rgb, + isLight: color.isLight, + isDark: color.isDark + }) + + // Use library's built-in isLight check for optimal contrast + if (color.isLight) { + console.log('Light background detected, using black text') + setColors({ + textColor: '#000000' + }) + } else { + console.log('Dark background detected, using white text') + setColors({ + textColor: '#ffffff' + }) + } + } catch (error) { + // Fallback to default on error + console.error('Error analyzing image color:', error) + setColors({ + textColor: '#ffffff' + }) + } + } + + img.onerror = () => { + // Fallback to default if image fails to load + setColors({ + textColor: '#ffffff' + }) + } + + img.src = imageUrl + + return () => { + fac.destroy() + } + }, [imageUrl]) + + return colors +} + diff --git a/src/services/bookmarkEvents.ts b/src/services/bookmarkEvents.ts index 88bade3d..a8c8220f 100644 --- a/src/services/bookmarkEvents.ts +++ b/src/services/bookmarkEvents.ts @@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] { const webBookmarks = unique.filter(e => e.kind === 39701) const bookmarkLists = unique - .filter(e => e.kind === 10003 || e.kind === 30001) + .filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd')) diff --git a/src/services/bookmarkHelpers.ts b/src/services/bookmarkHelpers.ts index 612bad09..614d5ba4 100644 --- a/src/services/bookmarkHelpers.ts +++ b/src/services/bookmarkHelpers.ts @@ -16,11 +16,24 @@ export interface BookmarkData { tags?: string[][] } +export interface AddressPointer { + kind: number + pubkey: string + identifier: string + relays?: string[] +} + +export interface EventPointer { + id: string + relays?: string[] + author?: string +} + export interface ApplesauceBookmarks { - notes?: BookmarkData[] - articles?: BookmarkData[] - hashtags?: BookmarkData[] - urls?: BookmarkData[] + notes?: EventPointer[] + articles?: AddressPointer[] + hashtags?: string[] + urls?: string[] } export interface AccountWithExtension { @@ -55,25 +68,83 @@ export const processApplesauceBookmarks = ( if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) { const applesauceBookmarks = bookmarks as ApplesauceBookmarks - const allItems: BookmarkData[] = [] - if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes) - if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles) - if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags) - if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls) + const allItems: IndividualBookmark[] = [] + + // Process notes (EventPointer[]) + if (applesauceBookmarks.notes) { + applesauceBookmarks.notes.forEach((note: EventPointer) => { + allItems.push({ + id: note.id, + content: '', + created_at: Math.floor(Date.now() / 1000), + pubkey: note.author || activeAccount.pubkey, + kind: 1, // Short note kind + tags: [], + parsedContent: undefined, + type: 'event' as const, + isPrivate, + added_at: Math.floor(Date.now() / 1000) + }) + }) + } + + // Process articles (AddressPointer[]) + if (applesauceBookmarks.articles) { + applesauceBookmarks.articles.forEach((article: AddressPointer) => { + // Convert AddressPointer to coordinate format: kind:pubkey:identifier + const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}` + allItems.push({ + id: coordinate, + content: '', + created_at: Math.floor(Date.now() / 1000), + pubkey: article.pubkey, + kind: article.kind, // Usually 30023 for long-form articles + tags: [], + parsedContent: undefined, + type: 'event' as const, + isPrivate, + added_at: Math.floor(Date.now() / 1000) + }) + }) + } + + // Process hashtags (string[]) + if (applesauceBookmarks.hashtags) { + applesauceBookmarks.hashtags.forEach((hashtag: string) => { + allItems.push({ + id: `hashtag-${hashtag}`, + content: `#${hashtag}`, + created_at: Math.floor(Date.now() / 1000), + pubkey: activeAccount.pubkey, + kind: 1, + tags: [['t', hashtag]], + parsedContent: undefined, + type: 'event' as const, + isPrivate, + added_at: Math.floor(Date.now() / 1000) + }) + }) + } + + // Process URLs (string[]) + if (applesauceBookmarks.urls) { + applesauceBookmarks.urls.forEach((url: string) => { + allItems.push({ + id: `url-${url}`, + content: url, + created_at: Math.floor(Date.now() / 1000), + pubkey: activeAccount.pubkey, + kind: 1, + tags: [['r', url]], + parsedContent: undefined, + type: 'event' as const, + isPrivate, + added_at: Math.floor(Date.now() / 1000) + }) + }) + } + return allItems - .filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs - .map((bookmark: BookmarkData) => ({ - id: bookmark.id!, - content: bookmark.content || '', - created_at: bookmark.created_at || Math.floor(Date.now() / 1000), - pubkey: activeAccount.pubkey, - kind: bookmark.kind || 30001, - tags: bookmark.tags || [], - parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined, - type: 'event' as const, - isPrivate, - added_at: bookmark.created_at || Math.floor(Date.now() / 1000) - })) } const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks] diff --git a/src/services/bookmarkProcessing.ts b/src/services/bookmarkProcessing.ts index 54cb87f1..888699c0 100644 --- a/src/services/bookmarkProcessing.ts +++ b/src/services/bookmarkProcessing.ts @@ -33,6 +33,12 @@ export async function collectBookmarksFromEvents( if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags) + // Extract the 'd' tag and metadata for bookmark sets (kind 30003) + const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined + const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined + const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined + const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined + // Handle web bookmarks (kind:39701) as individual bookmarks if (evt.kind === 39701) { publicItemsAll.push({ @@ -45,13 +51,27 @@ export async function collectBookmarksFromEvents( parsedContent: undefined, type: 'web' as const, isPrivate: false, - added_at: evt.created_at || Math.floor(Date.now() / 1000) + added_at: evt.created_at || Math.floor(Date.now() / 1000), + sourceKind: 39701, + setName: dTag, + setTitle, + setDescription, + setImage }) continue } const pub = Helpers.getPublicBookmarks(evt) - publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false)) + publicItemsAll.push( + ...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({ + ...i, + sourceKind: evt.kind, + setName: dTag, + setTitle, + setDescription, + setImage + })) + ) try { if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) { @@ -94,7 +114,16 @@ export async function collectBookmarksFromEvents( try { const hiddenTags = JSON.parse(decryptedContent) as string[][] const manualPrivate = Helpers.parseBookmarkTags(hiddenTags) - privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true)) + privateItemsAll.push( + ...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({ + ...i, + sourceKind: evt.kind, + setName: dTag, + setTitle, + setDescription, + setImage + })) + ) Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate) Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent) // Don't set latestContent to decrypted JSON - it's not user-facing content @@ -106,7 +135,16 @@ export async function collectBookmarksFromEvents( const priv = Helpers.getHiddenBookmarks(evt) if (priv) { - privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true)) + privateItemsAll.push( + ...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({ + ...i, + sourceKind: evt.kind, + setName: dTag, + setTitle, + setDescription, + setImage + })) + ) } } catch { // ignore individual event failures diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index c9e42963..f53b630b 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -5,7 +5,6 @@ import { dedupeNip51Events, hydrateItems, isAccountWithExtension, - isHexId, hasNip04Decrypt, hasNip44Decrypt, dedupeBookmarksById, @@ -57,11 +56,28 @@ export const fetchBookmarks = async ( rawEvents.forEach((evt, i) => { const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty' - console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`) + const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0 + const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0 + console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`) }) const bookmarkListEvents = dedupeNip51Events(rawEvents) console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events') + + // Log which events made it through deduplication + bookmarkListEvents.forEach((evt, i) => { + const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none' + console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`) + }) + + // Check specifically for Primal's "reads" list + const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads')) + if (primalReads) { + console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8)) + } else { + console.log('❌ No Primal reads list found (kind:10003 with d="reads")') + } + if (bookmarkListEvents.length === 0) { // Keep existing bookmarks visible; do not clear list if nothing new found return @@ -97,20 +113,88 @@ export const fetchBookmarks = async ( ) const allItems = [...publicItemsAll, ...privateItemsAll] - const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId))) - let idToEvent: Map = new Map() + + // Separate hex IDs (regular events) from coordinates (addressable events) + const noteIds: string[] = [] + const coordinates: string[] = [] + + allItems.forEach(i => { + // Check if it's a hex ID (64 character hex string) + if (/^[0-9a-f]{64}$/i.test(i.id)) { + noteIds.push(i.id) + } else if (i.id.includes(':')) { + // Coordinate format: kind:pubkey:identifier + coordinates.push(i.id) + } + }) + + const idToEvent: Map = new Map() + + // Fetch regular events by ID if (noteIds.length > 0) { try { const events = await queryEvents( relayPool, - { ids: noteIds }, + { ids: Array.from(new Set(noteIds)) }, { localTimeoutMs: 800, remoteTimeoutMs: 2500 } ) - idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e])) + events.forEach((e: NostrEvent) => { + idToEvent.set(e.id, e) + // Also store by coordinate if it's an addressable event + if (e.kind && e.kind >= 30000 && e.kind < 40000) { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${e.kind}:${e.pubkey}:${dTag}` + idToEvent.set(coordinate, e) + } + }) } catch (error) { - console.warn('Failed to fetch events for hydration:', error) + console.warn('Failed to fetch events by ID:', error) } } + + // Fetch addressable events by coordinates + if (coordinates.length > 0) { + try { + // Group by kind for more efficient querying + const byKind = new Map>() + + coordinates.forEach(coord => { + const parts = coord.split(':') + const kind = parseInt(parts[0]) + const pubkey = parts[1] + const identifier = parts[2] || '' + + if (!byKind.has(kind)) { + byKind.set(kind, []) + } + byKind.get(kind)!.push({ pubkey, identifier }) + }) + + // Query each kind group + for (const [kind, items] of byKind.entries()) { + const authors = Array.from(new Set(items.map(i => i.pubkey))) + const identifiers = Array.from(new Set(items.map(i => i.identifier))) + + const events = await queryEvents( + relayPool, + { kinds: [kind], authors, '#d': identifiers }, + { localTimeoutMs: 800, remoteTimeoutMs: 2500 } + ) + + events.forEach((e: NostrEvent) => { + const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' + const coordinate = `${e.kind}:${e.pubkey}:${dTag}` + idToEvent.set(coordinate, e) + // Also store by event ID + idToEvent.set(e.id, e) + }) + } + } catch (error) { + console.warn('Failed to fetch addressable events:', error) + } + } + + console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`) const allBookmarks = dedupeBookmarksById([ ...hydrateItems(publicItemsAll, idToEvent), ...hydrateItems(privateItemsAll, idToEvent) diff --git a/src/styles/components/cards.css b/src/styles/components/cards.css index 2e59cc12..57bc563f 100644 --- a/src/styles/components/cards.css +++ b/src/styles/components/cards.css @@ -7,6 +7,27 @@ .bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; } .bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; } +.bookmarks-section-title { + font-size: 0.75rem !important; + font-weight: 700 !important; + text-transform: uppercase !important; + letter-spacing: 0.05em !important; + color: var(--color-text-muted) !important; + padding: 1.5rem 0.5rem 0.375rem !important; + margin: 0 !important; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} +.bookmarks-section:first-of-type .bookmarks-section-title { + border-top: none; + padding-top: 0.5rem !important; +} +.bookmark-section-action { + padding: 1.5rem 0.5rem 0.375rem; +} +.bookmarks-section:first-of-type .bookmark-section-action { + padding-top: 0.5rem; +} + .individual-bookmarks { margin: 1rem 0; } .individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); } @@ -23,8 +44,8 @@ .individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); } /* Compact view */ -.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; } -.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); transform: none; box-shadow: none; } +.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; } +.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; } .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: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; } .compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; } diff --git a/src/styles/components/reader.css b/src/styles/components/reader.css index faa9cb27..d8e144b0 100644 --- a/src/styles/components/reader.css +++ b/src/styles/components/reader.css @@ -30,7 +30,7 @@ .reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); opacity: 0.85; } .publish-date svg { font-size: 0.75rem; opacity: 0.6; } -.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; } +.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; z-index: 10; } .reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); } .reading-time svg { font-size: 0.875rem; } .highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); } diff --git a/src/styles/layout/sidebar.css b/src/styles/layout/sidebar.css index 140415ee..2013ed7c 100644 --- a/src/styles/layout/sidebar.css +++ b/src/styles/layout/sidebar.css @@ -81,7 +81,14 @@ .view-mode-controls { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; + gap: 0.5rem; +} + +.view-mode-left, +.view-mode-right { + display: flex; + align-items: center; gap: 0.5rem; } diff --git a/src/types/bookmarks.ts b/src/types/bookmarks.ts index 24cb3d62..94d6ec05 100644 --- a/src/types/bookmarks.ts +++ b/src/types/bookmarks.ts @@ -42,6 +42,14 @@ export interface IndividualBookmark { encryptedContent?: string // When the item was added to the bookmark list (synthetic, for sorting) added_at?: number + // The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web) + sourceKind?: number + // The 'd' tag value from kind 30003 bookmark sets + setName?: string + // Metadata from the bookmark set event (kind 30003) + setTitle?: string + setDescription?: string + setImage?: string } export interface ActiveAccount { diff --git a/src/utils/bookmarkUtils.tsx b/src/utils/bookmarkUtils.tsx index 14ba5f39..14e0716d 100644 --- a/src/utils/bookmarkUtils.tsx +++ b/src/utils/bookmarkUtils.tsx @@ -1,6 +1,6 @@ import React from 'react' import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns' -import { ParsedContent, ParsedNode } from '../types/bookmarks' +import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks' import ResolvedMention from '../components/ResolvedMention' // Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh @@ -82,3 +82,71 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
) } + +// Sorting and grouping for bookmarks +export const sortIndividualBookmarks = (items: IndividualBookmark[]) => { + return items + .slice() + .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) +} + +export function groupIndividualBookmarks(items: IndividualBookmark[]) { + const sorted = sortIndividualBookmarks(items) + const amethyst = sorted.filter(i => i.sourceKind === 30001) + const web = sorted.filter(i => i.kind === 39701 || i.type === 'web') + const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id) + const privateItems = sorted.filter(i => i.isPrivate && !isIn(amethyst, i) && !isIn(web, i)) + const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i)) + return { privateItems, publicItems, web, amethyst } +} + +// Simple filter: only exclude bookmarks with empty/whitespace-only content +export function hasContent(bookmark: IndividualBookmark): boolean { + return !!(bookmark.content && bookmark.content.trim().length > 0) +} + +// Bookmark sets helpers (kind 30003) +export interface BookmarkSet { + name: string + title?: string + description?: string + image?: string + bookmarks: IndividualBookmark[] +} + +export function getBookmarkSets(items: IndividualBookmark[]): BookmarkSet[] { + // Group bookmarks by setName + const setMap = new Map() + + items.forEach(bookmark => { + if (bookmark.setName) { + const existing = setMap.get(bookmark.setName) || [] + existing.push(bookmark) + setMap.set(bookmark.setName, existing) + } + }) + + // Convert to array and extract metadata from the bookmarks + const sets: BookmarkSet[] = [] + setMap.forEach((bookmarks, name) => { + // Get metadata from the first bookmark (all bookmarks in a set share the same metadata) + const firstBookmark = bookmarks[0] + const title = firstBookmark?.setTitle + const description = firstBookmark?.setDescription + const image = firstBookmark?.setImage + + sets.push({ + name, + title, + description, + image, + bookmarks: sortIndividualBookmarks(bookmarks) + }) + }) + + return sets.sort((a, b) => a.name.localeCompare(b.name)) +} + +export function getBookmarksWithoutSet(items: IndividualBookmark[]): IndividualBookmark[] { + return sortIndividualBookmarks(items.filter(b => !b.setName)) +}