├── .gitignore
├── public
└── favicon.ico
├── .eslintrc
├── app
├── routes
│ ├── __gallery
│ │ ├── index.tsx
│ │ ├── p.$imageId
│ │ │ └── index.tsx
│ │ └── p.$imageId.tsx
│ └── __gallery.tsx
├── entry.client.tsx
├── root.tsx
├── components
│ ├── photo-summary.tsx
│ ├── dialog.tsx
│ └── gallery.tsx
├── entry.server.tsx
└── api
│ └── image.server.ts
├── remix.env.d.ts
├── netlify.toml
├── remix.config.js
├── tsconfig.json
├── package.json
├── server.js
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /public/build
5 | /.netlify
6 | .env
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jjenzz/insta-remix/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"]
3 | }
4 |
--------------------------------------------------------------------------------
/app/routes/__gallery/index.tsx:
--------------------------------------------------------------------------------
1 | export default function GalleryIndexPage() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from '@remix-run/react';
2 | import { hydrateRoot } from 'react-dom/client';
3 |
4 | hydrateRoot(document, );
5 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "remix build"
3 | publish = "public"
4 |
5 | [dev]
6 | command = "remix watch"
7 | port = 3000
8 |
9 | [[redirects]]
10 | from = "/*"
11 | to = "/.netlify/functions/server"
12 | status = 200
13 |
14 | [[headers]]
15 | for = "/build/*"
16 | [headers.values]
17 | "Cache-Control" = "public, max-age=31536000, s-maxage=31536000"
18 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | module.exports = {
3 | serverBuildTarget: "netlify",
4 | server:
5 | process.env.NETLIFY || process.env.NETLIFY_LOCAL
6 | ? "./server.js"
7 | : undefined,
8 | ignoredRouteFiles: ["**/.*"],
9 | // appDirectory: "app",
10 | // assetsBuildDirectory: "public/build",
11 | // serverBuildPath: ".netlify/functions-internal/server.js",
12 | // publicPath: "/build/",
13 | };
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "strict": true,
12 | "allowJs": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "~/*": ["./app/*"]
17 | },
18 |
19 | // Remix takes care of building everything in `remix build`.
20 | "noEmit": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from '@remix-run/node';
2 | import * as Remix from '@remix-run/react';
3 |
4 | type Location = Remix.Location & { state: { restoreScroll?: boolean } };
5 |
6 | export const meta: MetaFunction = () => ({
7 | charset: 'utf-8',
8 | title: 'New Remix App',
9 | viewport: 'width=device-width,initial-scale=1',
10 | });
11 |
12 | export default function App() {
13 | const location = Remix.useLocation() as Location;
14 | const restoreScroll = location.state?.restoreScroll ?? true;
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {restoreScroll && }
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/components/photo-summary.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | /* -------------------------------------------------------------------------------------------------
4 | * PhotoSummary
5 | * -----------------------------------------------------------------------------------------------*/
6 |
7 | type PhotoSummaryProps = { src: string };
8 |
9 | const PhotoSummary = (props: PhotoSummaryProps) => (
10 |
11 |

12 |
13 |
Comments
14 |
15 |
16 | );
17 |
18 | /* ---------------------------------------------------------------------------------------------- */
19 |
20 | const styles: Record = {
21 | summary: {
22 | background: '#eee',
23 | display: 'grid',
24 | gridTemplateColumns: '500px minmax(400px, 1fr)',
25 | },
26 | comments: {
27 | padding: 20,
28 | },
29 | };
30 |
31 | export { PhotoSummary };
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "sideEffects": false,
4 | "scripts": {
5 | "build": "remix build",
6 | "dev": "remix dev",
7 | "start": "cross-env NODE_ENV=production netlify dev"
8 | },
9 | "dependencies": {
10 | "@netlify/functions": "^1.0.0",
11 | "@radix-ui/react-use-layout-effect": "^1.0.0",
12 | "@remix-run/netlify": "^1.7.0",
13 | "@remix-run/node": "^1.7.0",
14 | "@remix-run/react": "^1.7.0",
15 | "cross-env": "^7.0.3",
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0"
18 | },
19 | "devDependencies": {
20 | "@remix-run/dev": "^1.7.0",
21 | "@remix-run/eslint-config": "^1.7.0",
22 | "@remix-run/serve": "^1.7.0",
23 | "@types/react": "^18.0.15",
24 | "@types/react-dom": "^18.0.6",
25 | "eslint": "^8.20.0",
26 | "prettier": "^2.7.1",
27 | "typescript": "^4.7.4"
28 | },
29 | "engines": {
30 | "node": ">=14"
31 | },
32 | "prettier": {
33 | "printWidth": 100,
34 | "singleQuote": true
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/routes/__gallery/p.$imageId/index.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunction } from "@remix-run/node";
2 | import { json } from "@remix-run/node";
3 | import * as Remix from "@remix-run/react";
4 | import * as imageApi from "~/api/image.server";
5 | import * as Gallery from "~/components/gallery";
6 |
7 | type LoaderData = { images: Awaited> };
8 |
9 | export const loader: LoaderFunction = async () => {
10 | const images = await imageApi.getImages();
11 | const data: LoaderData = { images };
12 | return json(data);
13 | };
14 |
15 | export default function GalleryImageIndexPage() {
16 | const data = Remix.useLoaderData();
17 | return (
18 |
19 |
More images
20 |
21 | {data?.images.map((image) => (
22 |
23 |
24 |
25 |
26 |
27 | ))}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/app/routes/__gallery/p.$imageId.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunction } from '@remix-run/node';
2 | import { json } from '@remix-run/node';
3 | import * as Remix from '@remix-run/react';
4 | import * as imageApi from '~/api/image.server';
5 | import { Dialog } from '~/components/dialog';
6 | import { PhotoSummary } from '~/components/photo-summary';
7 |
8 | type LoaderData = Awaited>;
9 |
10 | export const loader: LoaderFunction = async ({ params }) => {
11 | const image: LoaderData = await imageApi.getImage({ id: params.imageId! });
12 | return json(image);
13 | };
14 |
15 | export default function GalleryImagePage() {
16 | const image = Remix.useLoaderData();
17 | const navigate = Remix.useNavigate();
18 | const context = Remix.useOutletContext<{ dialog: boolean }>();
19 |
20 | return context.dialog ? (
21 |
24 | ) : (
25 | <>
26 |
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from 'stream';
2 | import type { EntryContext } from '@remix-run/node';
3 | import { Response } from '@remix-run/node';
4 | import { RemixServer } from '@remix-run/react';
5 | import { renderToPipeableStream } from 'react-dom/server';
6 |
7 | const ABORT_DELAY = 5000;
8 |
9 | export default function handleRequest(
10 | request: Request,
11 | responseStatusCode: number,
12 | responseHeaders: Headers,
13 | remixContext: EntryContext
14 | ) {
15 | return new Promise((resolve, reject) => {
16 | let didError = false;
17 |
18 | const { pipe, abort } = renderToPipeableStream(
19 | ,
20 | {
21 | onShellReady: () => {
22 | const body = new PassThrough();
23 |
24 | responseHeaders.set('Content-Type', 'text/html');
25 |
26 | resolve(
27 | new Response(body, {
28 | headers: responseHeaders,
29 | status: didError ? 500 : responseStatusCode,
30 | })
31 | );
32 |
33 | pipe(body);
34 | },
35 | onShellError: (err) => {
36 | reject(err);
37 | },
38 | onError: (error) => {
39 | didError = true;
40 |
41 | console.error(error);
42 | },
43 | }
44 | );
45 |
46 | setTimeout(abort, ABORT_DELAY);
47 | });
48 | }
49 |
--------------------------------------------------------------------------------
/app/components/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import { useLayoutEffect } from '@radix-ui/react-use-layout-effect';
4 |
5 | /* -------------------------------------------------------------------------------------------------
6 | * Dialog
7 | * -----------------------------------------------------------------------------------------------*/
8 |
9 | type DialogProps = React.PropsWithChildren<{ onClose?(): void }>;
10 |
11 | const Dialog = (props: DialogProps) => {
12 | const [container, setContainer] = React.useState();
13 |
14 | useLayoutEffect(() => {
15 | setContainer(window.document.body);
16 | }, []);
17 |
18 | return container
19 | ? ReactDOM.createPortal(
20 |
21 |
{props.children}
22 |
,
23 | container
24 | )
25 | : null;
26 | };
27 |
28 | /* ---------------------------------------------------------------------------------------------- */
29 |
30 | const styles: Record = {
31 | overlay: {
32 | backgroundColor: 'rgba(0,0,0,0.5)',
33 | position: 'fixed',
34 | top: 0,
35 | right: 0,
36 | bottom: 0,
37 | left: 0,
38 | display: 'grid',
39 | placeItems: 'center',
40 | },
41 | root: {
42 | backgroundColor: 'white',
43 | padding: 20,
44 | borderRadius: 10,
45 | },
46 | };
47 |
48 | export { Dialog };
49 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from "@remix-run/netlify";
2 | import * as build from "@remix-run/dev/server-build";
3 |
4 | /*
5 | * Returns a context object with at most 3 keys:
6 | * - `netlifyGraphToken`: raw authentication token to use with Netlify Graph
7 | * - `clientNetlifyGraphAccessToken`: For use with JWTs generated by
8 | * `netlify-graph-auth`.
9 | * - `netlifyGraphSignature`: a signature for subscription events. Will be
10 | * present if a secret is set.
11 | */
12 | function getLoadContext(event, context) {
13 | let rawAuthorizationString;
14 | let netlifyGraphToken;
15 |
16 | if (event.authlifyToken != null) {
17 | netlifyGraphToken = event.authlifyToken;
18 | }
19 |
20 | const authHeader = event.headers["authorization"];
21 | const graphSignatureHeader = event.headers["x-netlify-graph-signature"];
22 |
23 | if (authHeader != null && /Bearer /gi.test(authHeader)) {
24 | rawAuthorizationString = authHeader.split(" ")[1];
25 | }
26 |
27 | const loadContext = {
28 | clientNetlifyGraphAccessToken: rawAuthorizationString,
29 | netlifyGraphToken: netlifyGraphToken,
30 | netlifyGraphSignature: graphSignatureHeader,
31 | };
32 |
33 | // Remove keys with undefined values
34 | Object.keys(loadContext).forEach((key) => {
35 | if (loadContext[key] == null) {
36 | delete loadContext[key];
37 | }
38 | });
39 |
40 | return loadContext;
41 | }
42 |
43 | export const handler = createRequestHandler({
44 | build,
45 | getLoadContext,
46 | mode: process.env.NODE_ENV,
47 | });
48 |
--------------------------------------------------------------------------------
/app/api/image.server.ts:
--------------------------------------------------------------------------------
1 | const IMAGES = [
2 | {
3 | id: "1",
4 | src: "https://i.pravatar.cc/600?img=1",
5 | },
6 | {
7 | id: "2",
8 | src: "https://i.pravatar.cc/600?img=2",
9 | },
10 | {
11 | id: "3",
12 | src: "https://i.pravatar.cc/600?img=3",
13 | },
14 | {
15 | id: "4",
16 | src: "https://i.pravatar.cc/600?img=4",
17 | },
18 | {
19 | id: "5",
20 | src: "https://i.pravatar.cc/600?img=5",
21 | },
22 | {
23 | id: "6",
24 | src: "https://i.pravatar.cc/600?img=6",
25 | },
26 | {
27 | id: "7",
28 | src: "https://i.pravatar.cc/600?img=7",
29 | },
30 | {
31 | id: "8",
32 | src: "https://i.pravatar.cc/600?img=8",
33 | },
34 | {
35 | id: "9",
36 | src: "https://i.pravatar.cc/600?img=9",
37 | },
38 | {
39 | id: "10",
40 | src: "https://i.pravatar.cc/600?img=10",
41 | },
42 | {
43 | id: "11",
44 | src: "https://i.pravatar.cc/600?img=11",
45 | },
46 | {
47 | id: "12",
48 | src: "https://i.pravatar.cc/600?img=12",
49 | },
50 | ];
51 |
52 | /* -------------------------------------------------------------------------------------------------
53 | * getImages
54 | * -----------------------------------------------------------------------------------------------*/
55 |
56 | async function getImages() {
57 | return IMAGES;
58 | }
59 |
60 | /* -------------------------------------------------------------------------------------------------
61 | * getImage
62 | * -----------------------------------------------------------------------------------------------*/
63 |
64 | async function getImage(params: { id: string }) {
65 | return IMAGES.find((image) => image.id === params.id);
66 | }
67 |
68 | /* ---------------------------------------------------------------------------------------------- */
69 |
70 | export { getImages, getImage };
71 |
--------------------------------------------------------------------------------
/app/routes/__gallery.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunction } from '@remix-run/node';
2 | import * as React from 'react';
3 | import { json } from '@remix-run/node';
4 | import * as Remix from '@remix-run/react';
5 | import { useLayoutEffect } from '@radix-ui/react-use-layout-effect';
6 | import * as imageApi from '~/api/image.server';
7 | import * as Gallery from '~/components/gallery';
8 |
9 | type LoaderData = { images: Awaited> };
10 | type Location = Remix.Location & { state: { dialog?: boolean } };
11 |
12 | export const loader: LoaderFunction = async () => {
13 | const images = await imageApi.getImages();
14 | const data: LoaderData = { images };
15 | return json(data);
16 | };
17 |
18 | export default function GalleryPage() {
19 | const data = Remix.useLoaderData();
20 | const [mode, setMode] = React.useState<'inline' | 'dialog'>('inline');
21 | const navigate = Remix.useNavigate();
22 | const location = Remix.useLocation() as Location;
23 | const isPhoto = location.pathname.startsWith('/p/');
24 |
25 | useLayoutEffect(() => {
26 | if (location.state?.dialog) {
27 | // remove dialog state from location
28 | navigate(location.pathname, { replace: true, state: { restoreScroll: false } });
29 | setMode('dialog');
30 | }
31 | }, [location.pathname, location.state, navigate]);
32 |
33 | return isPhoto && mode === 'inline' ? (
34 |
35 | ) : (
36 |
37 |
Gallery
38 |
39 | {data?.images.map((image) => (
40 |
41 |
46 |
47 |
48 |
49 | ))}
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Insta Remix
2 |
3 |
4 |
5 | - [Remix Docs](https://remix.run/docs)
6 | - [Netlify Functions](https://www.netlify.com/products/functions/)
7 |
8 | ## Netlify Setup
9 |
10 | 1. Install the [Netlify CLI](https://www.netlify.com/products/dev/):
11 |
12 | ```sh
13 | npm i -g netlify-cli
14 | ```
15 |
16 | If you have previously installed the Netlify CLI, you should update it to the latest version:
17 |
18 | ```sh
19 | npm i -g netlify-cli@latest
20 | ```
21 |
22 | 2. Sign up and log in to Netlify:
23 |
24 | ```sh
25 | netlify login
26 | ```
27 |
28 | 3. Create a new site:
29 |
30 | ```sh
31 | netlify init
32 | ```
33 |
34 | ## Development
35 |
36 | The Remix dev server starts your app in development mode, rebuilding assets on file changes. To start the Remix dev server:
37 |
38 | ```sh
39 | npm run dev
40 | ```
41 |
42 | Open up [http://localhost:3000](http://localhost:3000), and you should be ready to go!
43 |
44 | The Netlify CLI builds a production version of your Remix App Server and splits it into Netlify Functions that run locally. This includes any custom Netlify functions you've developed. The Netlify CLI runs all of this in its development mode.
45 |
46 | ```sh
47 | netlify dev
48 | ```
49 |
50 | Open up [http://localhost:3000](http://localhost:3000), and you should be ready to go!
51 |
52 | Note: When running the Netlify CLI, file changes will rebuild assets, but you will not see the changes to the page you are on unless you do a browser refresh of the page. Due to how the Netlify CLI builds the Remix App Server, it does not support hot module reloading.
53 |
54 | ## Deployment
55 |
56 | There are two ways to deploy your app to Netlify, you can either link your app to your git repo and have it auto deploy changes to Netlify, or you can deploy your app manually. If you've followed the setup instructions already, all you need to do is run this:
57 |
58 | ```sh
59 | # preview deployment
60 | netlify deploy --build
61 |
62 | # production deployment
63 | netlify deploy --build --prod
64 | ```
65 |
--------------------------------------------------------------------------------
/app/components/gallery.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | /* -------------------------------------------------------------------------------------------------
4 | * Gallery
5 | * -----------------------------------------------------------------------------------------------*/
6 |
7 | type GalleryElement = React.ElementRef<'ul'>;
8 | interface GalleryProps extends React.ComponentPropsWithoutRef<'ul'> {}
9 |
10 | const Gallery = React.forwardRef((props, forwardedRef) => (
11 |
12 | ));
13 |
14 | Gallery.displayName = 'Gallery';
15 |
16 | /* -------------------------------------------------------------------------------------------------
17 | * GalleryItem
18 | * -----------------------------------------------------------------------------------------------*/
19 |
20 | type GalleryItemElement = React.ElementRef<'li'>;
21 | interface GalleryItemProps extends React.ComponentPropsWithoutRef<'li'> {}
22 |
23 | const GalleryItem = React.forwardRef(
24 | (props, forwardedRef) =>
25 | );
26 |
27 | GalleryItem.displayName = 'GalleryItem';
28 |
29 | /* -------------------------------------------------------------------------------------------------
30 | * GalleryImage
31 | * -----------------------------------------------------------------------------------------------*/
32 |
33 | type GalleryImageElement = React.ElementRef<'img'>;
34 | interface GalleryImageProps extends React.ComponentPropsWithoutRef<'img'> {}
35 |
36 | const GalleryImage = React.forwardRef(
37 | (props, forwardedRef) => (
38 |
39 | )
40 | );
41 |
42 | GalleryImage.displayName = 'GalleryImage';
43 |
44 | /* ---------------------------------------------------------------------------------------------- */
45 |
46 | const styles: Record = {
47 | gallery: {
48 | display: 'grid',
49 | justifyContent: 'center',
50 | padding: 0,
51 | gridGap: 0,
52 | listStyle: 'none',
53 | gridTemplateColumns: 'repeat(auto-fill, 300px)',
54 | },
55 | image: {
56 | verticalAlign: 'middle',
57 | },
58 | };
59 |
60 | export { Gallery as Root, GalleryItem as Item, GalleryImage as Image };
61 |
--------------------------------------------------------------------------------