Skip to content
Merged
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
7 changes: 7 additions & 0 deletions react-instantsearch-hooks/remix/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
"rules": {
"@typescript-eslint/naming-convention": "off",
"spaced-comment": ["error", "always", { "markers": ["/"] }]
}
}
8 changes: 8 additions & 0 deletions react-instantsearch-hooks/remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules

/.cache
/build
/public/build
.env

/app/tailwind.css
53 changes: 53 additions & 0 deletions react-instantsearch-hooks/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Welcome to Remix!

- [Remix Docs](https://remix.run/docs)

## Development

From your terminal:

```sh
npm run dev
```

This starts your app in development mode, rebuilding assets on file changes.

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying node applications, the built-in Remix app server is production-ready.

Make sure to deploy the output of `remix build`

- `build/`
- `public/build/`

### Using a Template

When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.

```sh
cd ..
# create a new project, and pick a pre-configured host
npx create-remix@latest
cd my-new-remix-app
# remove the new project's app (not the old one!)
rm -rf app
# copy your app over
cp -R ../my-old-remix-app/app app
```
4 changes: 4 additions & 0 deletions react-instantsearch-hooks/remix/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { RemixBrowser } from '@remix-run/react';
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document, <RemixBrowser />);
21 changes: 21 additions & 0 deletions react-instantsearch-hooks/remix/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);

responseHeaders.set('Content-Type', 'text/html');

return new Response(`<!DOCTYPE html>${markup}`, {
status: responseStatusCode,
headers: responseHeaders,
});
}
32 changes: 32 additions & 0 deletions react-instantsearch-hooks/remix/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MetaFunction } from '@remix-run/node';
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react';

export const meta: MetaFunction = () => ({
charset: 'utf-8',
title: 'React InstantSearch Hooks - Remix',
viewport: 'width=device-width,initial-scale=1',
});

export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
122 changes: 122 additions & 0 deletions react-instantsearch-hooks/remix/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import algoliasearch from 'algoliasearch/lite';
import type { InstantSearchServerState } from 'react-instantsearch-hooks-web';
import {
DynamicWidgets,
Hits,
InstantSearch,
InstantSearchSSRProvider,
Pagination,
RefinementList,
SearchBox,
useInstantSearch,
} from 'react-instantsearch-hooks-web';
import { getServerState } from 'react-instantsearch-hooks-server';
import { history } from 'instantsearch.js/cjs/lib/routers/index.js';
import instantSearchStyles from 'instantsearch.css/themes/satellite-min.css';

import type { LinksFunction, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

import { Hit } from '../../components/Hit';
import { Panel } from '../../components/Panel';
import { ScrollTo } from '../../components/ScrollTo';
import { NoResultsBoundary } from '../../components/NoResultsBoundary';
import { SearchErrorToast } from '../../components/SearchErrorToast';

import tailwindStyles from '../tailwind.css';

const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
);

export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: instantSearchStyles },
{ rel: 'stylesheet', href: tailwindStyles },
];

export const loader: LoaderFunction = async ({ request }) => {
const serverUrl = request.url;
const serverState = await getServerState(<Search serverUrl={serverUrl} />);

return json({
serverState,
serverUrl,
});
};

type SearchProps = {
serverState?: InstantSearchServerState;
serverUrl?: string;
};

function Search({ serverState, serverUrl }: SearchProps) {
return (
<InstantSearchSSRProvider {...serverState}>
<InstantSearch
searchClient={searchClient}
indexName="instant_search"
routing={{
router: history({
getLocation() {
if (typeof window === 'undefined') {
return new URL(serverUrl!) as unknown as Location;
}

return window.location;
},
}),
}}
>
<SearchErrorToast />

<ScrollTo className="max-w-6xl p-4 flex gap-4 m-auto">
<div>
<DynamicWidgets fallbackComponent={FallbackComponent} />
</div>

<div className="flex flex-col w-full gap-8">
<SearchBox />
<NoResultsBoundary fallback={<NoResults />}>
<Hits
hitComponent={Hit}
classNames={{
list: 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
item: 'p-2 w-full',
}}
/>
<Pagination className="flex self-center" />
</NoResultsBoundary>
</div>
</ScrollTo>
</InstantSearch>
</InstantSearchSSRProvider>
);
}

function FallbackComponent({ attribute }: { attribute: string }) {
return (
<Panel header={attribute}>
<RefinementList attribute={attribute} />
</Panel>
);
}

function NoResults() {
const { indexUiState } = useInstantSearch();

return (
<div>
<p>
No results for <q>{indexUiState.query}</q>.
</p>
</div>
);
}

export default function HomePage() {
const { serverState, serverUrl } = useLoaderData();

return <Search serverState={serverState} serverUrl={serverUrl} />;
}
31 changes: 31 additions & 0 deletions react-instantsearch-hooks/remix/components/Hit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Hit as AlgoliaHit } from 'instantsearch.js';
import { Highlight } from 'react-instantsearch-hooks-web';

type HitProps = {
hit: AlgoliaHit<{
name: string;
price: number;
image: string;
brand: string;
}>;
};

export function Hit({ hit }: HitProps) {
return (
<div className="group relative">
<div className="flex justify-center overflow-hidden">
<img
src={hit.image}
alt={hit.name}
className="object-center object-cover"
/>
</div>
<h3 className="mt-4 text-sm text-gray-700">
<span className="absolute inset-0" />
<Highlight hit={hit} attribute="name" />
</h3>
<p className="mt-1 text-sm text-gray-500">{hit.brand}</p>
<p className="mt-1 text-sm font-medium text-gray-900">${hit.price}</p>
</div>
);
}
27 changes: 27 additions & 0 deletions react-instantsearch-hooks/remix/components/NoResultsBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import { useInstantSearch } from 'react-instantsearch-hooks-web';

type NoResultsBoundaryProps = {
children: ReactNode;
fallback: ReactNode;
};

export function NoResultsBoundary({
children,
fallback,
}: NoResultsBoundaryProps) {
const { results } = useInstantSearch();

// The `__isArtificial` flag makes sure to not display the No Results message
// when no hits have been returned yet.
if (!results.__isArtificial && results.nbHits === 0) {
return (
<>
{fallback}
<div hidden>{children}</div>
</>
);
}

return <>{children}</>;
}
17 changes: 17 additions & 0 deletions react-instantsearch-hooks/remix/components/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function Panel({
children,
header,
footer,
}: {
children: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="ais-Panel">
{header && <div className="ais-Panel-header">{header}</div>}
<div className="ais-Panel-body">{children}</div>
{footer && <div className="ais-Panel-footer">{footer}</div>}
</div>
);
}
37 changes: 37 additions & 0 deletions react-instantsearch-hooks/remix/components/ScrollTo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ComponentProps, ReactNode } from 'react';
import React, { useEffect, useRef } from 'react';
import { useInstantSearch } from 'react-instantsearch-hooks-web';

type ScrollToProps = ComponentProps<'div'> & {
children: ReactNode;
};

export function ScrollTo({ children, ...props }: ScrollToProps) {
const { use } = useInstantSearch();
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
return use(() => {
return {
onStateChange() {
const isFiltering = document.body.classList.contains('filtering');
const isTyping =
document.activeElement?.tagName === 'INPUT' &&
document.activeElement?.getAttribute('type') === 'search';

if (isFiltering || isTyping) {
return;
}

containerRef.current!.scrollIntoView();
},
};
});
}, [use]);

return (
<div {...props} ref={containerRef}>
{children}
</div>
);
}
Loading