├── .gitignore ├── README.md ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── manifest.json ├── switchblade-full-dev.png ├── switchblade-full.png └── switchblade.png ├── src ├── App.jsx ├── AppChrome.jsx ├── components │ ├── Alert.jsx │ ├── AutoComplete.jsx │ ├── Button.jsx │ ├── Card.jsx │ ├── Checkbox.jsx │ ├── Collapse.jsx │ ├── Divider.jsx │ ├── Drawer.jsx │ ├── FeatureFlag.jsx │ ├── FilterShortcuts.jsx │ ├── FilterUsers.jsx │ ├── FilterVersions.jsx │ ├── Grid.jsx │ ├── Header.jsx │ ├── Icon.jsx │ ├── Input.jsx │ ├── LabeledInput.jsx │ ├── Loader.jsx │ ├── MfaModal.jsx │ ├── MobileSwap.jsx │ ├── Modal.jsx │ ├── NavDrawer.jsx │ ├── NavLink.jsx │ ├── NewShortcutButton.jsx │ ├── NewUserButton.jsx │ ├── NewVersionButton.jsx │ ├── NoContent.jsx │ ├── NotAuthorized.jsx │ ├── Panel.jsx │ ├── PermissionsWrapper.jsx │ ├── Select.jsx │ ├── SetupDrawer.jsx │ ├── ShortcutEditorDrawer.jsx │ ├── Stack.jsx │ ├── SystemInfoDrawer.jsx │ ├── Table.jsx │ ├── Tag.jsx │ ├── UserEditorDrawer.jsx │ └── VersionEditorDrawer.jsx ├── constants │ ├── deletedStates.js │ ├── prereleaseStates.js │ ├── requiredStates.js │ ├── shortcutStates.js │ ├── statusColors.js │ └── versionStates.js ├── icons │ ├── alert.svg │ ├── close.svg │ ├── collapse.svg │ ├── disabled.svg │ ├── enabled.svg │ ├── loader.svg │ ├── manage-users.svg │ ├── menu.svg │ ├── new.svg │ ├── not-found.svg │ ├── switchblade.svg │ └── user.svg ├── index.css ├── index.jsx ├── lib │ ├── config.js │ ├── switchblade.js │ └── util.js ├── pages │ ├── Account.jsx │ ├── Login.jsx │ ├── ManageUsers.jsx │ ├── ShortcutList.jsx │ └── ShortcutVersionList.jsx ├── router │ ├── paths.js │ └── routes.jsx ├── state │ ├── app.js │ ├── auth.js │ ├── index.js │ ├── me.js │ ├── server.js │ ├── shortcuts.js │ ├── users.js │ └── versions.js └── styles │ ├── Alert.module.css │ ├── AutoComplete.module.css │ ├── Button.module.css │ ├── Card.module.css │ ├── Checkbox.module.css │ ├── Collapse.module.css │ ├── Drawer.module.css │ ├── Grid.module.css │ ├── Header.module.css │ ├── Input.module.css │ ├── Loader.module.css │ ├── MfaModal.module.css │ ├── Modal.module.css │ ├── NavLink.module.css │ ├── Panel.module.css │ ├── Select.module.css │ ├── Stack.module.css │ ├── Table.module.css │ ├── Tag.module.css │ └── animations.module.css └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .DS_Store 4 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | Any environment that can run an application built on React 18 should suffice. This app was created with Node 19, but should work with Node 18. The app is built using Vite. 4 | 5 | # Environment Variables 6 | 7 | You can setup the following environment variables to configure your Switchblade UI installation. When running the app locally you can do this with a `.env` file at the top level of the repo. This file has been git-ignored and will not be committed. 8 | 9 | - `SWITCHBLADE_API_HOST`: The full domain name for your Switchblade API with no trailing slash. If you don't enter this value, you'll be prompted to enter it before you reach the login screen. You will have the option of letting your browser remember your hostname in the future. Leaving this blank will allow anyone to use your installation of this interface to manage their own Switchblade installation, making it possible to host a pubicly-available version of this UI that does not give access to any of your data. 10 | 11 | # Change Log 12 | 13 | ## v1.1.2 (2024-12-24) 14 | - Updated dependencies 15 | 16 | ## v1.1.1 (2024-05-10) 17 | - Fixed an issue where pressing the escape key could take you to a different page if no modals or drawers were open 18 | - Updated dependencies 19 | 20 | ## v1.1.0 (2023-10-04) 21 | - Added support for Switchblade 1.2.0 features: 22 | - Create and manage users 23 | - Give each user specific access permissions to control what they can do in the app 24 | - Support filtering shortcuts and versions by creator 25 | - List shortcut and version creators on editor drawers 26 | 27 | ## v1.0.1 (2023-06-07) 28 | - Switched to `npm` as the package repo for `switchblade-sdk`. 29 | 30 | ## v1.0.0 (2023-06-07) 31 | - Initial release -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Switchblade 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "components/*": [ 5 | "./src/components/*" 6 | ], 7 | "constants/*": [ 8 | "./src/constants/*" 9 | ], 10 | "icons/*": [ 11 | "./src/icons/*" 12 | ], 13 | "lib/*": [ 14 | "./src/lib/*" 15 | ], 16 | "pages/*": [ 17 | "./src/pages/*" 18 | ], 19 | "router/*": [ 20 | "./src/router/*" 21 | ], 22 | "state/*": [ 23 | "./src/state/*" 24 | ], 25 | "styles/*": [ 26 | "./src/styles/*" 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "switchblade-ui", 3 | "version": "1.1.2", 4 | "author": "Mike Beasley", 5 | "scripts": { 6 | "start": "vite", 7 | "dev": "vite --host", 8 | "build": "npm run clean && SWITCHBLADE_API_HOST=$SWITCHBLADE_API_HOST vite build", 9 | "clean": "rm -rf build", 10 | "serve": "vite serve build" 11 | }, 12 | "type": "module", 13 | "dependencies": { 14 | "@reduxjs/toolkit": "^2.2.4", 15 | "dayjs": "^1.11.11", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-redux": "^9.1.2", 19 | "react-router": "^6.23.1", 20 | "react-router-dom": "^6.23.1", 21 | "switchblade-sdk": "^1.2.1" 22 | }, 23 | "devDependencies": { 24 | "@vitejs/plugin-react": "^4.2.1", 25 | "vite": "^5.2.11", 26 | "vite-plugin-svgr": "^4.2.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBeas/switchblade-ui/d6f325ae6d3a4b28d3912d819767ff7c4b34635d/public/favicon.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Switchblade", 3 | "name": "Switchblade", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/switchblade-full-dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBeas/switchblade-ui/d6f325ae6d3a4b28d3912d819767ff7c4b34635d/public/switchblade-full-dev.png -------------------------------------------------------------------------------- /public/switchblade-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBeas/switchblade-ui/d6f325ae6d3a4b28d3912d819767ff7c4b34635d/public/switchblade-full.png -------------------------------------------------------------------------------- /public/switchblade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBeas/switchblade-ui/d6f325ae6d3a4b28d3912d819767ff7c4b34635d/public/switchblade.png -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { RouterProvider } from 'react-router'; 4 | import { loadServerConfig, selectServerConfig } from 'state/server'; 5 | import { selectTokenExpired, setTokenExpired } from 'state/auth'; 6 | 7 | import { router } from 'router/routes'; 8 | import { isDev } from 'lib/util'; 9 | 10 | import LoginPage from 'pages/Login'; 11 | import SystemInfoDrawer from 'components/SystemInfoDrawer'; 12 | import SetupDrawer from 'components/SetupDrawer'; 13 | import Modal from 'components/Modal'; 14 | 15 | const App = () => { 16 | const dispatch = useDispatch(); 17 | const serverConfig = useSelector(selectServerConfig); 18 | const tokenExpired = useSelector(selectTokenExpired); 19 | 20 | useEffect(() => { dispatch(loadServerConfig()) }, []); 21 | 22 | useEffect(() => { 23 | if (isDev) { 24 | document.getElementById("apple-touch-icon").setAttribute("href", "/switchblade-full-dev.png") 25 | document.getElementById("apple-touch-icon-precomposed").setAttribute("href", "/switchblade-full-dev.png") 26 | } 27 | }, [isDev]) 28 | 29 | return ( 30 | <> 31 | dispatch(setTokenExpired(false))} 34 | header="Session Expired" 35 | > 36 |
37 |
38 | Your session has expired. To continue, please login again. 39 |
40 | 41 |
You can adjust your session timeout length on the Switchblade server using the environment variables described in the Switchblade documentation. 42 |
43 |
44 |
45 | 46 | 47 | 48 | {serverConfig.api.authenticated ? 49 | 50 | : } 51 | 52 | ) 53 | } 54 | 55 | export default App; -------------------------------------------------------------------------------- /src/AppChrome.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Outlet } from 'react-router'; 3 | 4 | import Header from 'components/Header'; 5 | import NavDrawer from 'components/NavDrawer'; 6 | 7 | const AppChrome = () => { 8 | useEffect(() => { 9 | document.getElementById("theme-color").setAttribute("content", "white"); 10 | }, []) 11 | 12 | return ( 13 | <> 14 |
15 |
16 | 17 | 18 |
19 | 20 | ) 21 | } 22 | 23 | export default AppChrome; -------------------------------------------------------------------------------- /src/components/Alert.jsx: -------------------------------------------------------------------------------- 1 | import { classNames } from 'lib/util'; 2 | import animations from 'styles/animations.module.css'; 3 | import styles from 'styles/Alert.module.css'; 4 | 5 | const Colors = { 6 | White: "white", 7 | Blue: "blue", 8 | Red: "red", 9 | Yellow: "yellow", 10 | Green: "green" 11 | } 12 | 13 | const Alert = ({ 14 | title, 15 | content, 16 | color = Colors.Blue, 17 | animated = true, 18 | centered, 19 | animation = animations.pullIn, 20 | ...props 21 | }) => ( 22 |
31 |
{title}
32 |
{content}
33 |
34 | ) 35 | 36 | Alert.Colors = Colors; 37 | 38 | export default Alert; -------------------------------------------------------------------------------- /src/components/AutoComplete.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Input from 'components/Input'; 3 | import animation from 'styles/animations.module.css'; 4 | import styles from 'styles/AutoComplete.module.css'; 5 | 6 | const AutoComplete = ({ onChange, onSearch, options = [], loading, emptyMessage, value, ...restProps }) => { 7 | const [inputValue, setInputValue] = useState(''); 8 | const [openDropdown, setOpenDropdown] = useState(false); 9 | 10 | useEffect(() => { onSearch(inputValue) }, [inputValue]); 11 | 12 | useEffect(() => { setInputValue(value?.label) }, [value?.label]); 13 | 14 | return ( 15 | <> 16 |
17 | setOpenDropdown(true)} 20 | onBlur={() => { 21 | setOpenDropdown(false) 22 | if (value?.label && inputValue !== value?.label) { 23 | setInputValue(value?.label); 24 | } 25 | }} 26 | onChange={setInputValue} 27 | button={{ 28 | onClick: () => { 29 | onChange(null); 30 | setInputValue(null) 31 | }, 32 | children: 'Clear' 33 | }} 34 | {...restProps} 35 | /> 36 | {( 37 |
41 | {loading && options.length === 0 ? 42 |
{emptyMessage ?? 'Searching...'}
: ( 43 | options.length === 0 ? 44 | (
{emptyMessage ?? 'No matches'}
) : 45 | options.map((o) => ( 46 |
onChange(o)} 49 | className={`${styles.optionSpace} ${styles.option}`} 50 | > 51 | {o.label} 52 |
53 | )) 54 | )} 55 |
56 | )} 57 |
58 | 59 | ) 60 | } 61 | 62 | export default AutoComplete; -------------------------------------------------------------------------------- /src/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import { classNames } from 'lib/util'; 2 | import styles from 'styles/Button.module.css'; 3 | 4 | const Colors = { 5 | White: styles.white, 6 | Blue: styles.blue, 7 | Red: styles.red, 8 | Green: styles.green 9 | } 10 | 11 | const Sizes = { 12 | Default: styles.default, 13 | Large: styles.large 14 | } 15 | 16 | const Button = ({ 17 | children, 18 | size = Sizes.Default, 19 | block, 20 | color = Colors.Blue, 21 | ghost, 22 | inputButton, 23 | ...props 24 | }) => ( 25 | 38 | ) 39 | 40 | Button.Colors = Colors; 41 | Button.Sizes = Sizes; 42 | 43 | export default Button; -------------------------------------------------------------------------------- /src/components/Card.jsx: -------------------------------------------------------------------------------- 1 | import { isMobile } from 'lib/config'; 2 | import { classNames } from 'lib/util'; 3 | import styles from 'styles/Card.module.css'; 4 | 5 | const Card = ({ title, footer, children, onClick, ...props }) => { 6 | return ( 7 |
15 | {title &&
{title}
} 16 |
{children}
17 | {footer &&
{footer}
} 18 |
19 | ) 20 | } 21 | 22 | export default Card; -------------------------------------------------------------------------------- /src/components/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import styles from 'styles/Checkbox.module.css'; 2 | 3 | const Checkbox = ({ label, checked, id, onChange, ...props }) => ( 4 | <> 5 | onChange(e.target.checked)} 9 | className={styles.checkbox} 10 | id={id ?? label} 11 | {...props} 12 | /> 13 | 16 | 17 | ) 18 | 19 | export default Checkbox; -------------------------------------------------------------------------------- /src/components/Collapse.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import CollapseIcon from 'icons/collapse.svg?react'; 4 | import Icon from 'components/Icon'; 5 | 6 | import styles from 'styles/Collapse.module.css'; 7 | 8 | const Collapse = ({ title, defaultOpen = false, children }) => { 9 | const [open, setOpen] = useState(defaultOpen); 10 | 11 | return ( 12 |
setOpen(!open)}> 13 |
14 | {title} 15 | setOpen(!open)} /> 16 |
17 | {open && ( 18 |
e.stopPropagation()}> 19 | {children} 20 |
21 | )} 22 |
23 | ) 24 | } 25 | 26 | export default Collapse; -------------------------------------------------------------------------------- /src/components/Divider.jsx: -------------------------------------------------------------------------------- 1 | const Divider = ({ style, size = 20 }) => ( 2 |
10 | ) 11 | 12 | export default Divider; -------------------------------------------------------------------------------- /src/components/Drawer.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react'; 2 | import Button from 'components/Button'; 3 | import { isMobile, isInstalled } from 'lib/config'; 4 | import { classNames } from 'lib/util'; 5 | import Icon from 'components/Icon'; 6 | import CloseIcon from 'icons/close.svg?react'; 7 | import styles from 'styles/Drawer.module.css'; 8 | 9 | const Positions = { 10 | Top: "top", 11 | Bottom: "bottom", 12 | Left: "left", 13 | Right: "right" 14 | } 15 | 16 | const Drawer = ({ 17 | open, 18 | header, 19 | children, 20 | footer, 21 | hide, 22 | canClose = true, 23 | showCloseButton = true, 24 | rounded = !isMobile, 25 | width = 600, 26 | height = '100%', 27 | position = Positions.Right 28 | }) => { 29 | const ESC = "Escape"; 30 | 31 | const drawerHandleKey = useMemo(() => (e) => { if (e.code === ESC && canClose) hide() }, []); 32 | 33 | useEffect(() => { 34 | if (open) { 35 | document.getElementsByTagName("body")[0].style.overflow = "hidden"; 36 | document.addEventListener("keydown", drawerHandleKey); 37 | } else { 38 | document.getElementsByTagName("body")[0].style.overflow = "scroll"; 39 | document.removeEventListener("keydown", drawerHandleKey); 40 | } 41 | }, [open, drawerHandleKey]) 42 | 43 | return ( 44 |
{ if (canClose) hide() }} 47 | > 48 |
e.stopPropagation()} 56 | style={[Positions.Left, Positions.Right].includes(position) ? { width } : { height }} 57 | > 58 | 59 |
60 |
61 | {header} 62 | {canClose && showCloseButton && ( 63 | 71 | )} 72 |
73 |
74 | 75 |
78 | {children} 79 |
80 | 81 | {footer && 82 |
83 | {footer} 84 |
85 | } 86 | 87 | {!footer && !showCloseButton && canClose && 88 |
89 |
90 | 91 |
92 |
93 | } 94 | 95 |
96 |
97 | ) 98 | } 99 | 100 | Drawer.Positions = Positions; 101 | 102 | export default Drawer; -------------------------------------------------------------------------------- /src/components/FeatureFlag.jsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { selectServerConfig } from 'state/server'; 3 | 4 | const FeatureFlag = ({ flag, children, alternate }) => { 5 | const serverConfig = useSelector(selectServerConfig); 6 | 7 | return serverConfig?.features?.[flag] ? children : alternate; 8 | } 9 | 10 | export default FeatureFlag; -------------------------------------------------------------------------------- /src/components/FilterShortcuts.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { resetShortcutFilters, selectShortcutsCreator, selectShortcutFilters, selectShortcutsLoading, selectShortcutsSearchTerm, setShortcutsSearchTerm, updateShortcutFilters, updateShortcutsCreator } from 'state/shortcuts'; 5 | 6 | import { SHORTCUT_STATES } from 'constants/shortcutStates'; 7 | import { DELETED_STATES } from 'constants/deletedStates'; 8 | 9 | import Stack from 'components/Stack'; 10 | import Select from 'components/Select'; 11 | import LabeledInput from 'components/LabeledInput'; 12 | import Collapse from 'components/Collapse'; 13 | import Button from 'components/Button'; 14 | import MobileSwap from 'components/MobileSwap'; 15 | import Input from 'components/Input'; 16 | import FeatureFlag from 'components/FeatureFlag'; 17 | 18 | import { switchblade } from 'lib/switchblade'; 19 | import { isMobile } from 'lib/config'; 20 | import { useDebounce } from 'lib/util'; 21 | import AutoComplete from 'components/AutoComplete'; 22 | 23 | const FilterShortcuts = () => { 24 | const dispatch = useDispatch(); 25 | 26 | const loading = useSelector(selectShortcutsLoading); 27 | const filters = useSelector(selectShortcutFilters); 28 | const creator = useSelector(selectShortcutsCreator); 29 | 30 | const search = useSelector(selectShortcutsSearchTerm); 31 | const debouncedSearch = useDebounce(search, 500); 32 | 33 | useEffect(() => { 34 | dispatch(updateShortcutFilters({ search: debouncedSearch })) 35 | }, [debouncedSearch]); 36 | 37 | const onSearchCreator = async (searchTerm) => { 38 | if (!searchTerm) { 39 | dispatch(updateShortcutsCreator({ options: [] })); 40 | return; 41 | } 42 | 43 | dispatch(updateShortcutsCreator({ loading: true })); 44 | 45 | const { users } = await switchblade.autocomplete.users(searchTerm); 46 | const newOpts = users.map((u) => ({ label: u.username, value: u.id })); 47 | dispatch(updateShortcutsCreator({ loading: false, options: newOpts })); 48 | } 49 | 50 | const onSelectCreator = (selectedCreator) => { 51 | dispatch(updateShortcutFilters({ creatorId: selectedCreator?.value ?? null })) 52 | dispatch(updateShortcutsCreator({ selected: selectedCreator })); 53 | } 54 | 55 | const render = () => ( 56 | 57 | 58 | 59 | onSelectCreator(newCreatorId)} 67 | options={creator.options} 68 | /> 69 | 70 | 71 | 72 | 73 | 74 | dispatch(setShortcutsSearchTerm(search))} 80 | /> 81 | 82 | 83 | 84 | 85 | dispatch(updateShortcutFilters({ deleted }))} 101 | options={DELETED_STATES} 102 | /> 103 | 104 | 105 | 106 | 114 | 115 | 116 | ) 117 | 118 | return ( 119 | {render()}} 122 | /> 123 | ) 124 | } 125 | 126 | export default FilterShortcuts; -------------------------------------------------------------------------------- /src/components/FilterUsers.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { DELETED_STATES } from 'constants/deletedStates'; 5 | 6 | import Stack from 'components/Stack'; 7 | import Select from 'components/Select'; 8 | import LabeledInput from 'components/LabeledInput'; 9 | import Collapse from 'components/Collapse'; 10 | import Button from 'components/Button'; 11 | import MobileSwap from 'components/MobileSwap'; 12 | import Input from 'components/Input'; 13 | 14 | import { isMobile } from 'lib/config'; 15 | import { useDebounce } from 'lib/util'; 16 | import { resetUserFilters, selectUserFilters, selectUsersLoading, selectUsersSearchTerm, setUsersSearchTerm, updateUserFilters } from 'state/users'; 17 | 18 | const FilterUsers = () => { 19 | const dispatch = useDispatch(); 20 | 21 | const loading = useSelector(selectUsersLoading); 22 | const filters = useSelector(selectUserFilters); 23 | 24 | const search = useSelector(selectUsersSearchTerm); 25 | const debouncedSearch = useDebounce(search, 500); 26 | 27 | useEffect(() => { 28 | dispatch(updateUserFilters({ search: debouncedSearch })) 29 | }, [debouncedSearch]); 30 | 31 | const render = () => ( 32 | 33 | 34 | dispatch(setUsersSearchTerm(newSearch))} 40 | /> 41 | 42 | 43 | 44 | dispatch(setVersionsSearchTerm(search))} 86 | /> 87 | 88 | 89 | 90 | 91 | dispatch(updateVersionFilters({ deleted }))} 108 | options={DELETED_STATES} 109 | /> 110 | 111 | 112 | 113 | dispatch(updateVersionFilters({ required }))} 130 | options={REQUIRED_STATES} 131 | /> 132 | 133 | 134 | 135 | 142 | 143 | 144 | ) 145 | 146 | return ( 147 | {render()}} 150 | /> 151 | ) 152 | } 153 | 154 | export default FilterVersions; -------------------------------------------------------------------------------- /src/components/Grid.jsx: -------------------------------------------------------------------------------- 1 | import styles from 'styles/Grid.module.css'; 2 | 3 | const Grid = ({ children, itemWidth = '350px' }) => ( 4 |
10 | {children} 11 |
12 | ) 13 | 14 | export default Grid; -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import { useMatches } from 'react-router'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { setShowNavDrawer, selectHeader } from 'state/app'; 5 | 6 | import Stack from 'components/Stack'; 7 | import Icon from 'components/Icon'; 8 | import Divider from 'components/Divider'; 9 | 10 | import MenuIcon from 'icons/menu.svg?react'; 11 | import styles from 'styles/Header.module.css'; 12 | 13 | const Header = () => { 14 | const dispatch = useDispatch(); 15 | const matches = useMatches(); 16 | 17 | const handle = matches?.[matches.length - 1]?.handle; 18 | const HeaderComponent = handle?.header; 19 | const ButtonComponent = handle?.button; 20 | 21 | const appHeader = useSelector(selectHeader); 22 | 23 | return ( 24 |
25 | 26 | 27 | dispatch(setShowNavDrawer(true))}> 28 | 33 | {appHeader ?? 'Switchblade'} 34 | 35 | {ButtonComponent && } 36 | 37 | 38 | {HeaderComponent && } 39 | {HeaderComponent && } 40 | 41 |
42 | ) 43 | } 44 | 45 | export default Header; -------------------------------------------------------------------------------- /src/components/Icon.jsx: -------------------------------------------------------------------------------- 1 | const Icon = ({ icon, size = 48, maxSize, color, style, onClick, ...props }) => icon({ 2 | style: { 3 | display: 'flex', 4 | alignItems: 'center', 5 | justifyContent: 'center', 6 | height: size, 7 | width: size, 8 | maxHeight: maxSize, 9 | maxWidth: maxSize, 10 | cursor: onClick ? 'pointer' : undefined, 11 | color, 12 | ...style, 13 | }, 14 | onClick, 15 | ...props 16 | }) 17 | 18 | export default Icon; -------------------------------------------------------------------------------- /src/components/Input.jsx: -------------------------------------------------------------------------------- 1 | import Button from 'components/Button'; 2 | import { classNames } from 'lib/util'; 3 | import styles from 'styles/Input.module.css'; 4 | 5 | const Sizes = { 6 | Default: styles.default, 7 | Large: styles.large 8 | } 9 | 10 | const Text = ({ size = Sizes.Default, block, onChange, value, button, disabled, viewOnly, ...props }) => ( 11 | <> 12 | onChange(e.target.value)} 22 | value={value ?? (viewOnly ? '-' : '')} 23 | {...props} 24 | /> 25 | {button ? ( 26 |