├── .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 | navigate('/', { state: { restoreScroll: false } })}> 22 | 23 | 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 | --------------------------------------------------------------------------------