├── 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 | 
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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------