├── demo.gif ├── public ├── favicon.ico └── vercel.svg ├── README.md ├── pages ├── _app.js ├── api │ └── generate.js └── index.js ├── styles ├── globals.css └── Home.module.css ├── .gitignore ├── package.json ├── components ├── Layout.jsx └── AddLayout.jsx └── templates └── index.js /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intergalacticspacehighway/react-navigation-route-builder/HEAD/demo.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intergalacticspacehighway/react-navigation-route-builder/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

React navigation route builder

3 | 4 |

UI tool to quickly setup your navigators and screens

5 |
6 | 7 | [Try it out](https://rnrb.vercel.app/) 8 | 9 |
10 | 11 | ![](demo.gif) 12 | 13 |
14 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, theme, CSSReset } from "@chakra-ui/react"; 2 | import "../styles/globals.css"; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ( 6 | 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-navigation-builder", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "NODE_ENV=production node server.js" 9 | }, 10 | "dependencies": { 11 | "@chakra-ui/react": "^1.0.1", 12 | "@emotion/react": "^11.1.1", 13 | "@emotion/styled": "^11.0.0", 14 | "archiver": "^5.1.0", 15 | "body-parser": "^1.19.0", 16 | "express": "^4.17.1", 17 | "framer-motion": "^2.9.4", 18 | "next": "10.0.2", 19 | "react": "^16.8", 20 | "react-dom": "^16.8", 21 | "uuid": "^8.3.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | HStack, 5 | List, 6 | ListItem, 7 | CloseButton, 8 | } from "@chakra-ui/react"; 9 | 10 | export function Layout({ data, onSelectNode, selectedNodeId, onRemove }) { 11 | const renderTree = (tree) => { 12 | return ( 13 | 14 | {tree.children.map((child, index) => { 15 | return ( 16 | 17 | 18 | 25 | 26 | onRemove(child.id)}> 27 | 28 | {child.children.length > 0 ? renderTree(child) : null} 29 | 30 | ); 31 | })} 32 | 33 | ); 34 | }; 35 | 36 | return ( 37 | 43 | 44 | 45 | 52 | 53 | 54 | {renderTree(data)} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .main { 11 | padding: 5rem 0; 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | .footer { 20 | width: 100%; 21 | height: 100px; 22 | border-top: 1px solid #eaeaea; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | 28 | .footer img { 29 | margin-left: 0.5rem; 30 | } 31 | 32 | .footer a { 33 | display: flex; 34 | justify-content: center; 35 | align-items: center; 36 | } 37 | 38 | .title a { 39 | color: #0070f3; 40 | text-decoration: none; 41 | } 42 | 43 | .title a:hover, 44 | .title a:focus, 45 | .title a:active { 46 | text-decoration: underline; 47 | } 48 | 49 | .title { 50 | margin: 0; 51 | line-height: 1.15; 52 | font-size: 4rem; 53 | } 54 | 55 | .title, 56 | .description { 57 | text-align: center; 58 | } 59 | 60 | .description { 61 | line-height: 1.5; 62 | font-size: 1.5rem; 63 | } 64 | 65 | .code { 66 | background: #fafafa; 67 | border-radius: 5px; 68 | padding: 0.75rem; 69 | font-size: 1.1rem; 70 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 71 | Bitstream Vera Sans Mono, Courier New, monospace; 72 | } 73 | 74 | .grid { 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | flex-wrap: wrap; 79 | max-width: 800px; 80 | margin-top: 3rem; 81 | } 82 | 83 | .card { 84 | margin: 1rem; 85 | flex-basis: 45%; 86 | padding: 1.5rem; 87 | text-align: left; 88 | color: inherit; 89 | text-decoration: none; 90 | border: 1px solid #eaeaea; 91 | border-radius: 10px; 92 | transition: color 0.15s ease, border-color 0.15s ease; 93 | } 94 | 95 | .card:hover, 96 | .card:focus, 97 | .card:active { 98 | color: #0070f3; 99 | border-color: #0070f3; 100 | } 101 | 102 | .card h3 { 103 | margin: 0 0 1rem 0; 104 | font-size: 1.5rem; 105 | } 106 | 107 | .card p { 108 | margin: 0; 109 | font-size: 1.25rem; 110 | line-height: 1.5; 111 | } 112 | 113 | .logo { 114 | height: 1em; 115 | } 116 | 117 | @media (max-width: 600px) { 118 | .grid { 119 | width: 100%; 120 | flex-direction: column; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /components/AddLayout.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Select, 5 | VStack, 6 | Input, 7 | FormLabel, 8 | AlertDescription, 9 | Alert, 10 | AlertTitle, 11 | Link, 12 | } from "@chakra-ui/react"; 13 | import { useRef } from "react"; 14 | 15 | const nodes = [ 16 | { 17 | label: "Screen", 18 | type: "screen", 19 | }, 20 | { 21 | label: "Stack", 22 | type: "stack", 23 | }, 24 | { 25 | label: "Drawer", 26 | type: "drawer", 27 | }, 28 | { 29 | label: "Material Top tab", 30 | type: "materialTopTab", 31 | }, 32 | { 33 | label: "Material Bottom tab", 34 | type: "materialBottomTab", 35 | }, 36 | { 37 | label: "Bottom tab", 38 | type: "bottomTab", 39 | }, 40 | ]; 41 | 42 | export function AddLayout({ handleAddNode, selectedNode }) { 43 | const ref = useRef(); 44 | const nameRef = useRef(); 45 | const onSubmit = (e) => { 46 | e.preventDefault(); 47 | handleAddNode({ 48 | ...nodes[ref.current.value], 49 | name: nameRef.current.value, 50 | }); 51 | }; 52 | 53 | return ( 54 |
55 | 62 | 63 | Select layout/screen 64 | 73 | 74 | 75 | 76 | Enter name 77 | 83 | 84 | 85 | 88 | 89 | 90 | 91 | Important: 92 | 93 | 97 | Read nested navigators best practices 98 | 99 | 100 | 101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /templates/index.js: -------------------------------------------------------------------------------- 1 | const commonImports = `import React from "react"`; 2 | 3 | const dynamicImports = (children) => { 4 | const screenChildren = children.filter((child) => child.type === "screen"); 5 | const navigatorChildren = children.filter((child) => child.type !== "screen"); 6 | 7 | return ` 8 | import { ${screenChildren 9 | .map((child) => child.name) 10 | .join(", ")} } from "../screens"; 11 | ${navigatorChildren 12 | .map((child) => { 13 | return `import {${child.name}} from "./${child.name}"`; 14 | }) 15 | .join("\n")} 16 | `; 17 | }; 18 | 19 | const stack = ({ children, name }) => { 20 | return ` 21 | ${commonImports}; 22 | import { createStackNavigator } from '@react-navigation/stack'; 23 | ${dynamicImports(children)} 24 | 25 | const Stack = createStackNavigator(); 26 | 27 | export function ${name}() { 28 | return ( 29 | 30 | ${children 31 | .map((child) => { 32 | return ``; 33 | }) 34 | .join("\n")} 35 | 36 | 37 | ); 38 | } 39 | `; 40 | }; 41 | 42 | const drawer = ({ children, name }) => { 43 | return ` 44 | ${commonImports}; 45 | import { createDrawerNavigator } from '@react-navigation/drawer'; 46 | ${dynamicImports(children)} 47 | 48 | const Drawer = createDrawerNavigator(); 49 | 50 | export function ${name}() { 51 | return ( 52 | 53 | ${children 54 | .map((child) => { 55 | return ``; 56 | }) 57 | .join("\n")} 58 | 59 | 60 | ); 61 | } 62 | `; 63 | }; 64 | 65 | const materialTopTab = ({ children, name }) => { 66 | return ` 67 | ${commonImports}; 68 | import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; 69 | 70 | ${dynamicImports(children)} 71 | 72 | const Tab = createMaterialTopTabNavigator(); 73 | 74 | export function ${name}() { 75 | return ( 76 | 77 | ${children 78 | .map((child) => { 79 | return ``; 80 | }) 81 | .join("\n")} 82 | 83 | 84 | ); 85 | } 86 | `; 87 | }; 88 | 89 | const materialBottomTab = ({ children, name }) => { 90 | return ` 91 | ${commonImports}; 92 | import { createMaterialBottomTabNavigator } from '@react-navigation/material-bottom-tabs'; 93 | 94 | ${dynamicImports(children)} 95 | 96 | const Tab = createMaterialBottomTabNavigator(); 97 | 98 | export function ${name}() { 99 | return ( 100 | 101 | ${children 102 | .map((child) => { 103 | return ``; 104 | }) 105 | .join("\n")} 106 | 107 | 108 | ); 109 | } 110 | `; 111 | }; 112 | 113 | const bottomTab = ({ children, name }) => { 114 | return ` 115 | ${commonImports}; 116 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 117 | 118 | ${dynamicImports(children)} 119 | 120 | const Tab = createBottomTabNavigator(); 121 | 122 | export function ${name}() { 123 | return ( 124 | 125 | ${children 126 | .map((child) => { 127 | return ``; 128 | }) 129 | .join("\n")} 130 | 131 | 132 | ); 133 | } 134 | `; 135 | }; 136 | 137 | const screen = ({ name }) => { 138 | return ` 139 | ${commonImports}; 140 | 141 | export function ${name}() { 142 | return ( 143 | <> 144 | ); 145 | } 146 | `; 147 | }; 148 | 149 | module.exports = { 150 | stack, 151 | screen, 152 | drawer, 153 | materialTopTab, 154 | materialBottomTab, 155 | bottomTab, 156 | }; 157 | -------------------------------------------------------------------------------- /pages/api/generate.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const templateRenderers = require("../../templates"); 4 | const { v4: uuidv4 } = require("uuid"); 5 | const archiver = require("archiver"); 6 | const tempPath = path.resolve(__dirname, "tmp"); 7 | 8 | export default async function handler(req, res) { 9 | const sessionId = uuidv4(); 10 | const folderPath = path.resolve(tempPath, sessionId); 11 | try { 12 | createDirectories(sessionId); 13 | generateTemplates({ data: req.body, id: sessionId }); 14 | res.statusCode = 200; 15 | res.setHeader("Content-Type", "application/zip"); 16 | await zipDirectory(folderPath, res); 17 | } catch (e) { 18 | console.error(e); 19 | res.statusCode = 500; 20 | res.end(JSON.stringify({ message: "Something went wrong" })); 21 | } finally { 22 | console.log("cleaning up folders"); 23 | deleteFolderRecursive(folderPath); 24 | } 25 | } 26 | 27 | function zipDirectory(source, res) { 28 | const archive = archiver("zip", { zlib: { level: 9 } }); 29 | 30 | return new Promise((resolve, reject) => { 31 | archive.directory(source, false).on("error", reject).pipe(res); 32 | 33 | res.on("close", resolve); 34 | archive.finalize(); 35 | }); 36 | } 37 | 38 | const generateFileForNode = ({ id, node }) => { 39 | // console.log({ node }); 40 | const templateString = getTemplateString({ 41 | type: node.type, 42 | children: node.children, 43 | name: node.name, 44 | }); 45 | createFile({ 46 | id, 47 | templateString, 48 | type: node.type, 49 | name: node.name, 50 | }); 51 | }; 52 | 53 | const generateTemplates = ({ data, id }) => { 54 | let visitedSet = new Set(); 55 | let queue = []; 56 | queue.push(data); 57 | visitedSet.add(data.id); 58 | 59 | while (queue.length !== 0) { 60 | let node = queue.shift(); 61 | 62 | generateFileForNode({ id, node }); 63 | 64 | for (let i = 0; i < node.children.length; i++) { 65 | const childNode = node.children[i]; 66 | if (!visitedSet.has(childNode.id)) { 67 | queue.push(childNode); 68 | visitedSet.add(childNode.id); 69 | } 70 | } 71 | } 72 | }; 73 | 74 | const createDirectories = (id) => { 75 | if (!fs.existsSync(tempPath)) { 76 | fs.mkdirSync(tempPath); 77 | } 78 | 79 | const folderPath = path.resolve(tempPath, id); 80 | fs.mkdirSync(folderPath); 81 | 82 | let directories = ["navigators", "screens"]; 83 | directories.forEach((directory) => { 84 | // console.log(__dirname, "temp", id, directory); 85 | const directoryPath = path.resolve(folderPath, directory); 86 | fs.mkdirSync(directoryPath); 87 | }); 88 | }; 89 | 90 | const deleteDirectories = (id) => { 91 | const folderPath = path.resolve(tempPath, id); 92 | deleteFolderRecursive(folderPath); 93 | }; 94 | 95 | // Stolen from https://geedew.com/remove-a-directory-that-is-not-empty-in-nodejs/ 96 | const deleteFolderRecursive = function (path) { 97 | if (fs.existsSync(path)) { 98 | fs.readdirSync(path).forEach(function (file, index) { 99 | const curPath = path + "/" + file; 100 | if (fs.lstatSync(curPath).isDirectory()) { 101 | // recurse 102 | deleteFolderRecursive(curPath); 103 | } else { 104 | // delete file 105 | fs.unlinkSync(curPath); 106 | } 107 | }); 108 | fs.rmdirSync(path); 109 | } 110 | }; 111 | 112 | const getTemplateString = ({ children, type, name }) => { 113 | const templateRenderer = templateRenderers[type]; 114 | const string = templateRenderer({ 115 | children: children, 116 | name, 117 | }); 118 | return string; 119 | }; 120 | 121 | const createFile = ({ id, templateString, type, name }) => { 122 | let folderPath = path.resolve(tempPath, id, "navigators"); 123 | if (type === "screen") { 124 | folderPath = path.resolve(tempPath, id, "screens"); 125 | const exportsString = `export * from "./${name}"\n`; 126 | const indexFilePath = path.resolve(folderPath, `index.tsx`); 127 | fs.appendFileSync(indexFilePath, exportsString); 128 | } 129 | 130 | const filePath = path.resolve(folderPath, `${name}.tsx`); 131 | // console.log({ templateString }); 132 | fs.writeFileSync(filePath, templateString); 133 | }; 134 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { useEffect, useMemo, useState } from "react"; 3 | import { AddLayout } from "../components/AddLayout"; 4 | import { Layout } from "../components/Layout"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { Box, Button, Flex, VStack, Link } from "@chakra-ui/react"; 7 | 8 | const persistKey = "persisterState"; 9 | 10 | const rootTree = { 11 | type: "stack", 12 | id: "root", 13 | name: "RootStack", 14 | children: [], 15 | }; 16 | 17 | const getPersisterStateOrDefaultState = () => { 18 | let state = rootTree; 19 | try { 20 | if (localStorage.getItem(persistKey)) { 21 | state = JSON.parse(localStorage.getItem(persistKey)); 22 | } 23 | } catch (e) { 24 | state = rootTree; 25 | } 26 | 27 | return state; 28 | }; 29 | 30 | const findTreeNode = (tree, id) => { 31 | if (tree.id === id) { 32 | return tree; 33 | } 34 | 35 | for (let i = 0; i < tree.children.length; i++) { 36 | const childNode = findTreeNode(tree.children[i], id); 37 | if (childNode) { 38 | return childNode; 39 | } 40 | } 41 | }; 42 | 43 | export default function Home() { 44 | const [treeData, setTreeData] = useState(getPersisterStateOrDefaultState); 45 | const [selectedNodeId, setSelectedNodeId] = useState(rootTree.id); 46 | const selectedNode = useMemo(() => { 47 | const node = findTreeNode(treeData, selectedNodeId); 48 | return node; 49 | }, [selectedNodeId, treeData]); 50 | 51 | useEffect(() => { 52 | localStorage.setItem(persistKey, JSON.stringify(treeData)); 53 | }, [treeData]); 54 | 55 | const onAddNode = (node) => { 56 | const parent = findTreeNode(treeData, selectedNodeId); 57 | // mutation, but okay since we're changing the reference of treeState anyways 58 | parent.children = parent.children.concat({ 59 | ...node, 60 | children: [], 61 | id: uuidv4(), 62 | }); 63 | 64 | setTreeData({ 65 | ...treeData, 66 | }); 67 | }; 68 | 69 | const onRemove = (nodeId) => { 70 | let parent; 71 | 72 | const findParentOfNode = (tree, id) => { 73 | if (tree.id === id) { 74 | return tree; 75 | } 76 | 77 | for (let i = 0; i < tree.children.length; i++) { 78 | const childNode = findParentOfNode(tree.children[i], id); 79 | if (childNode && !parent) { 80 | parent = tree; 81 | } 82 | } 83 | }; 84 | 85 | findParentOfNode(treeData, nodeId); 86 | 87 | console.log({ nodeId, parent }); 88 | 89 | // mutation, but okay since we're changing the reference of treeState anyways 90 | parent.children = parent.children.filter((child) => child.id !== nodeId); 91 | 92 | setSelectedNodeId(parent.id); 93 | setTreeData({ 94 | ...treeData, 95 | }); 96 | }; 97 | 98 | const handleSubmit = async () => { 99 | const res = await fetch("/api/generate", { 100 | method: "POST", 101 | body: JSON.stringify(treeData), 102 | headers: { 103 | "Content-Type": "application/json", 104 | }, 105 | }) 106 | .then((response) => response.blob()) 107 | .then(downloadFile); 108 | 109 | console.log({ res }); 110 | }; 111 | 112 | return ( 113 |
114 | 115 | RN route builder 116 | 117 | 118 | 124 | 125 | 126 | 127 | 128 | 132 | GitHub 133 | 134 | 135 | 136 | 137 |
138 | ); 139 | } 140 | 141 | const downloadFile = (blob) => { 142 | const url = window.URL.createObjectURL(blob); 143 | const a = document.createElement("a"); 144 | a.href = url; 145 | a.download = "boilerplate.zip"; 146 | document.body.appendChild(a); 147 | a.click(); 148 | a.remove(); 149 | }; 150 | --------------------------------------------------------------------------------