diff --git a/components/BookmarkButton.tsx b/components/BookmarkButton.tsx new file mode 100644 index 000000000..6bc699324 --- /dev/null +++ b/components/BookmarkButton.tsx @@ -0,0 +1,42 @@ +import { type StyleProp, type ViewStyle } from 'react-native'; + +import { HoverEffect } from '~/common/styleguide'; +import { Bookmark, BookmarkFilled } from '~/components/Icons'; +import Tooltip from '~/components/Tooltip'; +import { useBookmarks } from '~/context/BookmarksContext'; + +type BookmarkButtonProps = { + bookmarkId: string; + style?: StyleProp; + iconStyle?: StyleProp; + filledIconStyle?: StyleProp; +}; + +export default function BookmarkButton({ + bookmarkId, + style, + iconStyle, + filledIconStyle, +}: BookmarkButtonProps) { + const { isBookmarked: checkIsBookmarked, toggleBookmark: toggleBookmarkGlobal } = useBookmarks(); + const isBookmarked = checkIsBookmarked(bookmarkId); + + function handleToggleBookmark() { + toggleBookmarkGlobal(bookmarkId); + } + + return ( + + {isBookmarked ? ( + + ) : ( + + )} + + }> + {isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'} + + ); +} diff --git a/components/Filters/FilterButton.tsx b/components/Filters/FilterButton.tsx index d205c89c0..414ad8f86 100644 --- a/components/Filters/FilterButton.tsx +++ b/components/Filters/FilterButton.tsx @@ -8,6 +8,7 @@ import tw from '~/util/tailwind'; import { ClearButton } from './ClearButton'; import { + FILTER_BOOKMARKS, FILTER_COMPATIBILITY, FILTER_MODULE_TYPE, FILTER_PLATFORMS, @@ -32,6 +33,7 @@ export function FilterButton({ isFilterVisible, query, onPress, onClearAllPress, ...FILTER_COMPATIBILITY.map(compatibility => compatibility.param), ...FILTER_TYPE.map(entryType => entryType.param), ...FILTER_MODULE_TYPE.map(moduleType => moduleType.param), + FILTER_BOOKMARKS.param, ]; const filterCount = Object.keys(query).reduce( diff --git a/components/Filters/helpers.ts b/components/Filters/helpers.ts index 72d3157aa..6811a5976 100644 --- a/components/Filters/helpers.ts +++ b/components/Filters/helpers.ts @@ -125,3 +125,8 @@ export const FILTER_MODULE_TYPE: FilterParamsType[] = [ title: 'Turbo Module', }, ]; + +export const FILTER_BOOKMARKS: FilterParamsType = { + param: 'bookmarks', + title: 'Bookmarked', +}; diff --git a/components/Filters/index.tsx b/components/Filters/index.tsx index 7b7f8d99e..c6780075a 100644 --- a/components/Filters/index.tsx +++ b/components/Filters/index.tsx @@ -2,11 +2,13 @@ import { type StyleProp, View, type ViewStyle } from 'react-native'; import FiltersSection from '~/components/Filters/FiltersSection'; import { Tag } from '~/components/Tag'; +import { useBookmarks } from '~/context/BookmarksContext'; import { type Query } from '~/types'; import { getPageQuery } from '~/util/search'; import tw from '~/util/tailwind'; import { + FILTER_BOOKMARKS, FILTER_COMPATIBILITY, FILTER_MODULE_TYPE, FILTER_PLATFORMS, @@ -25,6 +27,8 @@ type FiltersProps = { export function Filters({ query, style, basePath = '/packages' }: FiltersProps) { const pageQuery = getPageQuery(basePath, query); const isMainSearch = basePath === '/packages'; + const { bookmarkedIds } = useBookmarks(); + const hasBookmarks = bookmarkedIds.size > 0; return ( @@ -68,6 +72,9 @@ export function Filters({ query, style, basePath = '/packages' }: FiltersProps) basePath={basePath} /> ))} + {hasBookmarks && ( + + )} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index b02475a3d..4b7dd2af5 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -35,6 +35,33 @@ export function Star({ width, height, style }: IconProps) { ); } +export function Bookmark({ width, height, style }: IconProps) { + return ( + + + + ); +} + +export function BookmarkFilled({ width, height, style }: IconProps) { + return ( + + + + + ); +} + export function Web({ width, height, style }: IconProps) { return ( diff --git a/components/Library/index.tsx b/components/Library/index.tsx index 1f7bd089c..a3b58fc13 100644 --- a/components/Library/index.tsx +++ b/components/Library/index.tsx @@ -1,6 +1,7 @@ import { Platform, View } from 'react-native'; import { A, HoverEffect, useLayout } from '~/common/styleguide'; +import BookmarkButton from '~/components/BookmarkButton'; import CompatibilityTags from '~/components/CompatibilityTags'; import { GitHub } from '~/components/Icons'; import LibraryDescription from '~/components/Library/LibraryDescription'; @@ -33,6 +34,8 @@ export default function Library({ const { isSmallScreen, isBelowMaxWidth } = useLayout(); const libName = library.npmPkg ?? github.name; + const bookmarkId = library.npmPkg ?? library.github.fullName; + const hasSecondaryMetadata = github.license || github.urls.homepage || @@ -43,13 +46,19 @@ export default function Library({ return ( + {library.unmaintained && ( @@ -60,6 +62,12 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop /> + {rightSlot} diff --git a/context/BookmarksContext.tsx b/context/BookmarksContext.tsx new file mode 100644 index 000000000..c4fbdeda6 --- /dev/null +++ b/context/BookmarksContext.tsx @@ -0,0 +1,95 @@ +import { createContext, type PropsWithChildren, useContext, useEffect, useState } from 'react'; + +import { TimeRange } from '~/util/datetime'; + +const BOOKMARK_COOKIE_NAME = 'rnd_bookmarks'; +const COOKIE_MAX_AGE = TimeRange.YEAR; + +type BookmarksContextType = { + bookmarkedIds: Set; + isBookmarked: (id: string) => boolean; + toggleBookmark: (id: string) => void; + isLoading: boolean; +}; + +const BookmarksContext = createContext(null); + +function getCookie(name: string): string | null { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); + return match ? decodeURIComponent(match[2]) : null; +} + +function setCookie(name: string, value: string, maxAge: number) { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`; +} + +export function getBookmarksFromCookie(cookieString?: string): string[] { + if (typeof cookieString === 'string') { + const match = cookieString.match(new RegExp(`(^| )${BOOKMARK_COOKIE_NAME}=([^;]+)`)); + if (match) { + try { + return JSON.parse(decodeURIComponent(match[2])); + } catch { + return []; + } + } + return []; + } + + const value = getCookie(BOOKMARK_COOKIE_NAME); + if (!value) { + return []; + } + try { + return JSON.parse(value); + } catch { + return []; + } +} + +export function BookmarksProvider({ children }: PropsWithChildren) { + const [bookmarkedIds, setBookmarkedIds] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const bookmarks = getBookmarksFromCookie(); + setBookmarkedIds(new Set(bookmarks)); + setIsLoading(false); + }, []); + + function isBookmarked(id: string) { + return bookmarkedIds.has(id); + } + + function toggleBookmark(id: string) { + const newSet = new Set(bookmarkedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + + setCookie(BOOKMARK_COOKIE_NAME, JSON.stringify([...newSet]), COOKIE_MAX_AGE); + setBookmarkedIds(newSet); + } + + return ( + + {children} + + ); +} + +export function useBookmarks() { + const context = useContext(BookmarksContext); + if (!context) { + throw new Error('useBookmarks must be used within a BookmarksProvider'); + } + return context; +} diff --git a/pages/_app.tsx b/pages/_app.tsx index ab17b6643..0f3ad5c0e 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -6,6 +6,7 @@ import Head from 'next/head'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import Footer from '~/components/Footer'; +import { BookmarksProvider } from '~/context/BookmarksContext'; import CustomAppearanceProvider from '~/context/CustomAppearanceProvider'; import tw from '~/util/tailwind'; @@ -23,18 +24,20 @@ Sentry.init({ function App({ pageProps, Component }: AppProps) { return ( - - - - -
- -
-