Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions components/BookmarkButton.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewStyle>;
iconStyle?: StyleProp<ViewStyle>;
filledIconStyle?: StyleProp<ViewStyle>;
};

export default function BookmarkButton({
bookmarkId,
style,
iconStyle,
filledIconStyle,
}: BookmarkButtonProps) {
const { isBookmarked: checkIsBookmarked, toggleBookmark: toggleBookmarkGlobal } = useBookmarks();
const isBookmarked = checkIsBookmarked(bookmarkId);

function handleToggleBookmark() {
toggleBookmarkGlobal(bookmarkId);
}

return (
<Tooltip
trigger={
<HoverEffect onPress={handleToggleBookmark} style={style}>
{isBookmarked ? (
<BookmarkFilled style={filledIconStyle ?? iconStyle} />
) : (
<Bookmark style={iconStyle} />
)}
</HoverEffect>
}>
{isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}
</Tooltip>
);
}
2 changes: 2 additions & 0 deletions components/Filters/FilterButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import tw from '~/util/tailwind';

import { ClearButton } from './ClearButton';
import {
FILTER_BOOKMARKS,
FILTER_COMPATIBILITY,
FILTER_MODULE_TYPE,
FILTER_PLATFORMS,
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions components/Filters/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,8 @@ export const FILTER_MODULE_TYPE: FilterParamsType[] = [
title: 'Turbo Module',
},
];

export const FILTER_BOOKMARKS: FilterParamsType = {
param: 'bookmarks',
title: 'Bookmarked',
};
7 changes: 7 additions & 0 deletions components/Filters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<View style={[tw`flex-1 items-center bg-palette-gray1 py-2 dark:bg-very-dark`, style]}>
Expand Down Expand Up @@ -68,6 +72,9 @@ export function Filters({ query, style, basePath = '/packages' }: FiltersProps)
basePath={basePath}
/>
))}
{hasBookmarks && (
<ToggleLink query={pageQuery} filterParam={FILTER_BOOKMARKS} basePath={basePath} />
)}
</FiltersSection>
<View style={tw`w-full max-w-layout flex-row flex-wrap content-start`}>
<FiltersSection title="Compatibility">
Expand Down
27 changes: 27 additions & 0 deletions components/Icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ export function Star({ width, height, style }: IconProps) {
);
}

export function Bookmark({ width, height, style }: IconProps) {
return (
<Svg width={width ?? 16} height={height ?? 16} viewBox="0 0 256 256" style={style}>
<Path
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"
fill="currentColor"
/>
</Svg>
);
}

export function BookmarkFilled({ width, height, style }: IconProps) {
return (
<Svg width={width ?? 16} height={height ?? 16} viewBox="0 0 256 256" style={style}>
<Path
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"
fill="currentColor"
opacity="0.2"
/>
<Path
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"
fill="currentColor"
/>
</Svg>
);
}

export function Web({ width, height, style }: IconProps) {
return (
<Svg width={width ?? 18} height={height ?? 19} viewBox="0 0 18 19" style={style}>
Expand Down
11 changes: 10 additions & 1 deletion components/Library/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 ||
Expand All @@ -43,13 +46,19 @@ export default function Library({
return (
<View
style={[
tw`mb-4 flex-row overflow-hidden rounded-md border border-palette-gray2 dark:border-default`,
tw`relative mb-4 flex-row overflow-hidden rounded-md border border-palette-gray2 dark:border-default`,
isSmallScreen && tw`flex-col`,
skipMetadata && tw`mx-[0.75%] min-h-[206px] w-[48.5%]`,
skipMetadata && (isSmallScreen || isBelowMaxWidth) && tw`w-[98.5%] max-w-[98.5%]`,
skipSecondaryMetadata && tw`min-h-0`,
library.unmaintained && tw`opacity-85`,
]}>
<BookmarkButton
bookmarkId={bookmarkId}
style={tw`absolute right-2 top-2 z-10 rounded border border-palette-gray2 bg-white p-1.5 dark:border-palette-gray6 dark:bg-palette-gray7`}
iconStyle={tw`size-4 text-palette-gray4 dark:text-palette-gray5`}
filledIconStyle={tw`size-4 text-primary-dark dark:text-primary`}
/>
<View style={[tw`flex-1 p-4 pb-3.5 pl-5`, isSmallScreen && tw`px-3.5 pb-3 pt-2.5`]}>
{library.unmaintained && (
<View
Expand Down
8 changes: 8 additions & 0 deletions components/Package/PackageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
import { View } from 'react-native';

import { A, HoverEffect, P, 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';
Expand All @@ -22,6 +23,7 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop
const { isSmallScreen } = useLayout();

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

return (
<>
Expand Down Expand Up @@ -60,6 +62,12 @@ export default function PackageHeader({ library, registryData, rightSlot }: Prop
/>
</A>
</HoverEffect>
<BookmarkButton
bookmarkId={bookmarkId}
style={tw`size-5`}
iconStyle={tw`size-5 text-palette-gray5 dark:text-palette-gray4`}
filledIconStyle={tw`size-5 text-primary-dark dark:text-primary`}
/>
</View>
{rightSlot}
</View>
Expand Down
95 changes: 95 additions & 0 deletions context/BookmarksContext.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
isBookmarked: (id: string) => boolean;
toggleBookmark: (id: string) => void;
isLoading: boolean;
};

const BookmarksContext = createContext<BookmarksContextType | null>(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<Set<string>>(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 (
<BookmarksContext.Provider value={{ bookmarkedIds, isBookmarked, toggleBookmark, isLoading }}>
{children}
</BookmarksContext.Provider>
);
}

export function useBookmarks() {
const context = useContext(BookmarksContext);
if (!context) {
throw new Error('useBookmarks must be used within a BookmarksProvider');
}
return context;
}
27 changes: 15 additions & 12 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,18 +24,20 @@ Sentry.init({
function App({ pageProps, Component }: AppProps) {
return (
<CustomAppearanceProvider>
<SafeAreaProvider>
<Head>
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=2,viewport-fit=cover"
/>
</Head>
<main style={tw`flex flex-1 flex-col`}>
<Component {...pageProps} />
</main>
<Footer />
</SafeAreaProvider>
<BookmarksProvider>
<SafeAreaProvider>
<Head>
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,viewport-fit=cover"
/>
</Head>
<main style={tw`flex flex-1 flex-col`}>
<Component {...pageProps} />
</main>
<Footer />
</SafeAreaProvider>
</BookmarksProvider>
</CustomAppearanceProvider>
);
}
Expand Down
15 changes: 14 additions & 1 deletion pages/api/libraries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { drop, take } from 'es-toolkit';
import { type NextApiRequest, type NextApiResponse } from 'next';

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

// Get bookmarks from cookie if bookmarks filter is enabled
const bookmarkedIds = parsedQuery.bookmarks
? new Set(getBookmarksFromCookie(req.headers.cookie))
: null;

const filteredLibraries = handleFilterLibraries({
libraries,
sortBy,
Expand Down Expand Up @@ -98,6 +104,8 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
turboModule: parsedQuery.turboModule,
nightlyProgram: parsedQuery.nightlyProgram,
owner: parsedQuery.owner,
bookmarks: parsedQuery.bookmarks,
bookmarkedIds,
});

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

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

return res.json({
libraries: filteredAndPaginatedLibraries,
Expand Down
9 changes: 8 additions & 1 deletion pages/packages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ function Index({ data, query }: Props) {

Index.getInitialProps = async (ctx: NextPageContext) => {
const url = getApiUrl(urlWithQuery('/libraries', ctx.query), ctx);
const response = await fetch(url);

// Forward cookies when making server-side requests (needed for bookmarks filter)
const headers: HeadersInit = {};
if (ctx.req?.headers.cookie) {
headers.cookie = ctx.req.headers.cookie;
}

const response = await fetch(url, { headers });
const result: APIResponseType = await response.json();

return {
Expand Down
Loading