Skip to content

Commit 38b5840

Browse files
feat: add cookie-based bookmarking for libraries (#2143)
1 parent 36b7164 commit 38b5840

File tree

13 files changed

+244
-16
lines changed

13 files changed

+244
-16
lines changed

components/BookmarkButton.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { type StyleProp, type ViewStyle } from 'react-native';
2+
3+
import { HoverEffect } from '~/common/styleguide';
4+
import { Bookmark, BookmarkFilled } from '~/components/Icons';
5+
import Tooltip from '~/components/Tooltip';
6+
import { useBookmarks } from '~/context/BookmarksContext';
7+
8+
type BookmarkButtonProps = {
9+
bookmarkId: string;
10+
style?: StyleProp<ViewStyle>;
11+
iconStyle?: StyleProp<ViewStyle>;
12+
filledIconStyle?: StyleProp<ViewStyle>;
13+
};
14+
15+
export default function BookmarkButton({
16+
bookmarkId,
17+
style,
18+
iconStyle,
19+
filledIconStyle,
20+
}: BookmarkButtonProps) {
21+
const { isBookmarked: checkIsBookmarked, toggleBookmark: toggleBookmarkGlobal } = useBookmarks();
22+
const isBookmarked = checkIsBookmarked(bookmarkId);
23+
24+
function handleToggleBookmark() {
25+
toggleBookmarkGlobal(bookmarkId);
26+
}
27+
28+
return (
29+
<Tooltip
30+
trigger={
31+
<HoverEffect onPress={handleToggleBookmark} style={style}>
32+
{isBookmarked ? (
33+
<BookmarkFilled style={filledIconStyle ?? iconStyle} />
34+
) : (
35+
<Bookmark style={iconStyle} />
36+
)}
37+
</HoverEffect>
38+
}>
39+
{isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}
40+
</Tooltip>
41+
);
42+
}

components/Filters/FilterButton.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import tw from '~/util/tailwind';
88

99
import { ClearButton } from './ClearButton';
1010
import {
11+
FILTER_BOOKMARKS,
1112
FILTER_COMPATIBILITY,
1213
FILTER_MODULE_TYPE,
1314
FILTER_PLATFORMS,
@@ -32,6 +33,7 @@ export function FilterButton({ isFilterVisible, query, onPress, onClearAllPress,
3233
...FILTER_COMPATIBILITY.map(compatibility => compatibility.param),
3334
...FILTER_TYPE.map(entryType => entryType.param),
3435
...FILTER_MODULE_TYPE.map(moduleType => moduleType.param),
36+
FILTER_BOOKMARKS.param,
3537
];
3638

3739
const filterCount = Object.keys(query).reduce(

components/Filters/helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,8 @@ export const FILTER_MODULE_TYPE: FilterParamsType[] = [
125125
title: 'Turbo Module',
126126
},
127127
];
128+
129+
export const FILTER_BOOKMARKS: FilterParamsType = {
130+
param: 'bookmarks',
131+
title: 'Bookmarked',
132+
};

components/Filters/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { type StyleProp, View, type ViewStyle } from 'react-native';
22

33
import FiltersSection from '~/components/Filters/FiltersSection';
44
import { Tag } from '~/components/Tag';
5+
import { useBookmarks } from '~/context/BookmarksContext';
56
import { type Query } from '~/types';
67
import { getPageQuery } from '~/util/search';
78
import tw from '~/util/tailwind';
89

910
import {
11+
FILTER_BOOKMARKS,
1012
FILTER_COMPATIBILITY,
1113
FILTER_MODULE_TYPE,
1214
FILTER_PLATFORMS,
@@ -25,6 +27,8 @@ type FiltersProps = {
2527
export function Filters({ query, style, basePath = '/packages' }: FiltersProps) {
2628
const pageQuery = getPageQuery(basePath, query);
2729
const isMainSearch = basePath === '/packages';
30+
const { bookmarkedIds } = useBookmarks();
31+
const hasBookmarks = bookmarkedIds.size > 0;
2832

2933
return (
3034
<View style={[tw`flex-1 items-center bg-palette-gray1 py-2 dark:bg-very-dark`, style]}>
@@ -68,6 +72,9 @@ export function Filters({ query, style, basePath = '/packages' }: FiltersProps)
6872
basePath={basePath}
6973
/>
7074
))}
75+
{hasBookmarks && (
76+
<ToggleLink query={pageQuery} filterParam={FILTER_BOOKMARKS} basePath={basePath} />
77+
)}
7178
</FiltersSection>
7279
<View style={tw`w-full max-w-layout flex-row flex-wrap content-start`}>
7380
<FiltersSection title="Compatibility">

components/Icons/index.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ export function Star({ width, height, style }: IconProps) {
3535
);
3636
}
3737

38+
export function Bookmark({ width, height, style }: IconProps) {
39+
return (
40+
<Svg width={width ?? 16} height={height ?? 16} viewBox="0 0 256 256" style={style}>
41+
<Path
42+
d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"
43+
fill="currentColor"
44+
/>
45+
</Svg>
46+
);
47+
}
48+
49+
export function BookmarkFilled({ width, height, style }: IconProps) {
50+
return (
51+
<Svg width={width ?? 16} height={height ?? 16} viewBox="0 0 256 256" style={style}>
52+
<Path
53+
d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Z"
54+
fill="currentColor"
55+
opacity="0.2"
56+
/>
57+
<Path
58+
d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,177.57-51.77-32.35a8,8,0,0,0-8.48,0L72,209.57V48H184Z"
59+
fill="currentColor"
60+
/>
61+
</Svg>
62+
);
63+
}
64+
3865
export function Web({ width, height, style }: IconProps) {
3966
return (
4067
<Svg width={width ?? 18} height={height ?? 19} viewBox="0 0 18 19" style={style}>

components/Library/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Platform, View } from 'react-native';
22

33
import { A, HoverEffect, useLayout } from '~/common/styleguide';
4+
import BookmarkButton from '~/components/BookmarkButton';
45
import CompatibilityTags from '~/components/CompatibilityTags';
56
import { GitHub } from '~/components/Icons';
67
import LibraryDescription from '~/components/Library/LibraryDescription';
@@ -33,6 +34,8 @@ export default function Library({
3334
const { isSmallScreen, isBelowMaxWidth } = useLayout();
3435

3536
const libName = library.npmPkg ?? github.name;
37+
const bookmarkId = library.npmPkg ?? library.github.fullName;
38+
3639
const hasSecondaryMetadata =
3740
github.license ||
3841
github.urls.homepage ||
@@ -43,13 +46,19 @@ export default function Library({
4346
return (
4447
<View
4548
style={[
46-
tw`mb-4 flex-row overflow-hidden rounded-md border border-palette-gray2 dark:border-default`,
49+
tw`relative mb-4 flex-row overflow-hidden rounded-md border border-palette-gray2 dark:border-default`,
4750
isSmallScreen && tw`flex-col`,
4851
skipMetadata && tw`mx-[0.75%] min-h-[206px] w-[48.5%]`,
4952
skipMetadata && (isSmallScreen || isBelowMaxWidth) && tw`w-[98.5%] max-w-[98.5%]`,
5053
skipSecondaryMetadata && tw`min-h-0`,
5154
library.unmaintained && tw`opacity-85`,
5255
]}>
56+
<BookmarkButton
57+
bookmarkId={bookmarkId}
58+
style={tw`absolute right-2 top-2 z-10 rounded border border-palette-gray2 p-1.5 dark:border-palette-gray6`}
59+
iconStyle={tw`size-4 text-palette-gray4 dark:text-palette-gray5`}
60+
filledIconStyle={tw`size-4 text-primary-dark dark:text-primary`}
61+
/>
5362
<View style={[tw`flex-1 p-4 pb-3.5 pl-5`, isSmallScreen && tw`px-3.5 pb-3 pt-2.5`]}>
5463
{library.unmaintained && (
5564
<View

components/Package/PackageHeader.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
22
import { View } from 'react-native';
33

44
import { A, HoverEffect, P, useLayout } from '~/common/styleguide';
5+
import BookmarkButton from '~/components/BookmarkButton';
56
import CompatibilityTags from '~/components/CompatibilityTags';
67
import { GitHub } from '~/components/Icons';
78
import LibraryDescription from '~/components/Library/LibraryDescription';
@@ -21,6 +22,7 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop
2122
const { isSmallScreen } = useLayout();
2223

2324
const ghUsername = library.github.fullName.split('/')[0];
25+
const bookmarkId = library.npmPkg ?? library.github.fullName;
2426

2527
return (
2628
<>
@@ -58,6 +60,12 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop
5860
/>
5961
</A>
6062
</HoverEffect>
63+
<BookmarkButton
64+
bookmarkId={bookmarkId}
65+
style={tw`size-5`}
66+
iconStyle={tw`size-5 text-palette-gray5 dark:text-palette-gray4`}
67+
filledIconStyle={tw`size-5 text-primary-dark dark:text-primary`}
68+
/>
6169
</View>
6270
{rightSlot}
6371
</View>

context/BookmarksContext.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createContext, type PropsWithChildren, useContext, useEffect, useState } from 'react';
2+
3+
import { TimeRange } from '~/util/datetime';
4+
5+
const BOOKMARK_COOKIE_NAME = 'rnd_bookmarks';
6+
const COOKIE_MAX_AGE = TimeRange.YEAR;
7+
8+
type BookmarksContextType = {
9+
bookmarkedIds: Set<string>;
10+
isBookmarked: (id: string) => boolean;
11+
toggleBookmark: (id: string) => void;
12+
isLoading: boolean;
13+
};
14+
15+
const BookmarksContext = createContext<BookmarksContextType | null>(null);
16+
17+
function getCookie(name: string): string | null {
18+
if (typeof document === 'undefined') {
19+
return null;
20+
}
21+
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
22+
return match ? decodeURIComponent(match[2]) : null;
23+
}
24+
25+
function setCookie(name: string, value: string, maxAge: number) {
26+
if (typeof document === 'undefined') {
27+
return;
28+
}
29+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
30+
}
31+
32+
export function getBookmarksFromCookie(cookieString?: string): string[] {
33+
if (typeof cookieString === 'string') {
34+
const match = cookieString.match(new RegExp(`(^| )${BOOKMARK_COOKIE_NAME}=([^;]+)`));
35+
if (match) {
36+
try {
37+
return JSON.parse(decodeURIComponent(match[2]));
38+
} catch {
39+
return [];
40+
}
41+
}
42+
return [];
43+
}
44+
45+
const value = getCookie(BOOKMARK_COOKIE_NAME);
46+
if (!value) {
47+
return [];
48+
}
49+
try {
50+
return JSON.parse(value);
51+
} catch {
52+
return [];
53+
}
54+
}
55+
56+
export function BookmarksProvider({ children }: PropsWithChildren) {
57+
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set());
58+
const [isLoading, setIsLoading] = useState(true);
59+
60+
useEffect(() => {
61+
const bookmarks = getBookmarksFromCookie();
62+
setBookmarkedIds(new Set(bookmarks));
63+
setIsLoading(false);
64+
}, []);
65+
66+
function isBookmarked(id: string) {
67+
return bookmarkedIds.has(id);
68+
}
69+
70+
function toggleBookmark(id: string) {
71+
const newSet = new Set(bookmarkedIds);
72+
if (newSet.has(id)) {
73+
newSet.delete(id);
74+
} else {
75+
newSet.add(id);
76+
}
77+
78+
setCookie(BOOKMARK_COOKIE_NAME, JSON.stringify([...newSet]), COOKIE_MAX_AGE);
79+
setBookmarkedIds(newSet);
80+
}
81+
82+
return (
83+
<BookmarksContext.Provider value={{ bookmarkedIds, isBookmarked, toggleBookmark, isLoading }}>
84+
{children}
85+
</BookmarksContext.Provider>
86+
);
87+
}
88+
89+
export function useBookmarks() {
90+
const context = useContext(BookmarksContext);
91+
if (!context) {
92+
throw new Error('useBookmarks must be used within a BookmarksProvider');
93+
}
94+
return context;
95+
}

pages/_app.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Head from 'next/head';
66
import { SafeAreaProvider } from 'react-native-safe-area-context';
77

88
import Footer from '~/components/Footer';
9+
import { BookmarksProvider } from '~/context/BookmarksContext';
910
import CustomAppearanceProvider from '~/context/CustomAppearanceProvider';
1011
import tw from '~/util/tailwind';
1112

@@ -23,18 +24,20 @@ Sentry.init({
2324
function App({ pageProps, Component }: AppProps) {
2425
return (
2526
<CustomAppearanceProvider>
26-
<SafeAreaProvider>
27-
<Head>
28-
<meta
29-
name="viewport"
30-
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=2,viewport-fit=cover"
31-
/>
32-
</Head>
33-
<main style={tw`flex flex-1 flex-col`}>
34-
<Component {...pageProps} />
35-
</main>
36-
<Footer />
37-
</SafeAreaProvider>
27+
<BookmarksProvider>
28+
<SafeAreaProvider>
29+
<Head>
30+
<meta
31+
name="viewport"
32+
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,viewport-fit=cover"
33+
/>
34+
</Head>
35+
<main style={tw`flex flex-1 flex-col`}>
36+
<Component {...pageProps} />
37+
</main>
38+
<Footer />
39+
</SafeAreaProvider>
40+
</BookmarksProvider>
3841
</CustomAppearanceProvider>
3942
);
4043
}

pages/api/libraries/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { drop, take } from 'es-toolkit';
22
import { type NextApiRequest, type NextApiResponse } from 'next';
33

44
import data from '~/assets/data.json';
5+
import { getBookmarksFromCookie } from '~/context/BookmarksContext';
56
import { type DataAssetType, type QueryOrder, type SortedDataType } from '~/types';
67
import { NUM_PER_PAGE } from '~/util/Constants';
78
import { parseQueryParams } from '~/util/parseQueryParams';
@@ -60,6 +61,11 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
6061
const sortDirection = parsedQuery.direction ?? 'descending';
6162
const libraries = sortDirection === 'ascending' ? ReversedSortedData[sortBy] : SortedData[sortBy];
6263

64+
// Get bookmarks from cookie if bookmarks filter is enabled
65+
const bookmarkedIds = parsedQuery.bookmarks
66+
? new Set(getBookmarksFromCookie(req.headers.cookie))
67+
: null;
68+
6369
const filteredLibraries = handleFilterLibraries({
6470
libraries,
6571
sortBy,
@@ -98,6 +104,8 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
98104
turboModule: parsedQuery.turboModule,
99105
nightlyProgram: parsedQuery.nightlyProgram,
100106
owner: parsedQuery.owner,
107+
bookmarks: parsedQuery.bookmarks,
108+
bookmarkedIds,
101109
});
102110

103111
const offset = parsedQuery.offset ? Number.parseInt(parsedQuery.offset.toString(), 10) : 0;
@@ -113,7 +121,12 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
113121
: filteredLibraries;
114122
const filteredAndPaginatedLibraries = take(drop(relevanceSortedLibraries, offset), limit);
115123

116-
res.setHeader('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=300');
124+
// Don't cache responses with bookmarks filter since it depends on user-specific cookies
125+
if (parsedQuery.bookmarks) {
126+
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
127+
} else {
128+
res.setHeader('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=300');
129+
}
117130

118131
return res.json({
119132
libraries: filteredAndPaginatedLibraries,

0 commit comments

Comments
 (0)