Ver Fonte

Merge pull request #114 from pawelmalak/v1.7.0

Version 1.7.0
pawelmalak há 3 anos atrás
pai
commit
6f44200a3c
56 ficheiros alterados com 1179 adições e 386 exclusões
  1. 1 1
      .env
  2. 8 0
      CHANGELOG.md
  3. 1 0
      api.js
  4. 1 1
      client/.env
  5. 6 7
      client/public/index.html
  6. 8 5
      client/src/App.tsx
  7. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff
  8. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2
  9. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff
  10. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2
  11. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff
  12. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2
  13. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff
  14. BIN
      client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2
  15. 38 36
      client/src/components/Apps/AppForm/AppForm.tsx
  16. 76 65
      client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx
  17. 8 7
      client/src/components/NotificationCenter/NotificationCenter.tsx
  18. 14 14
      client/src/components/SearchBar/SearchBar.tsx
  19. 6 57
      client/src/components/Settings/OtherSettings/OtherSettings.tsx
  20. 30 0
      client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.module.css
  21. 112 0
      client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx
  22. 109 0
      client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx
  23. 154 0
      client/src/components/Settings/SearchSettings/SearchSettings.tsx
  24. 38 50
      client/src/components/Settings/Settings.tsx
  25. 28 0
      client/src/components/Settings/settings.json
  26. 2 2
      client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.module.css
  27. 11 0
      client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx
  28. 17 7
      client/src/components/UI/Notification/Notification.tsx
  29. 41 5
      client/src/index.css
  30. 9 3
      client/src/interfaces/Forms.ts
  31. 2 1
      client/src/interfaces/Notification.ts
  32. 4 0
      client/src/interfaces/Route.ts
  33. 1 0
      client/src/interfaces/SearchResult.ts
  34. 1 0
      client/src/interfaces/index.ts
  35. 39 25
      client/src/store/actions/actionTypes.ts
  36. 95 12
      client/src/store/actions/config.ts
  37. 57 15
      client/src/store/reducers/config.ts
  38. 18 11
      client/src/utility/checkVersion.ts
  39. 2 1
      client/src/utility/index.ts
  40. 7 0
      client/src/utility/redirectUrl.ts
  41. 15 2
      client/src/utility/searchParser.ts
  42. 4 1
      controllers/apps.js
  43. 1 1
      controllers/config.js
  44. 81 0
      controllers/queries/index.js
  45. 4 3
      package.json
  46. 4 15
      routes/config.js
  47. 14 0
      routes/queries.js
  48. 17 10
      server.js
  49. 7 4
      utils/File.js
  50. 0 22
      utils/findCss.js
  51. 32 0
      utils/init/createFile.js
  52. 9 0
      utils/init/index.js
  53. 7 3
      utils/init/initConfig.js
  54. 8 0
      utils/init/initFiles.js
  55. 0 0
      utils/init/initialConfig.json
  56. 32 0
      utils/init/initialFiles.json

+ 1 - 1
.env

@@ -1,3 +1,3 @@
 PORT=5005
 NODE_ENV=development
-VERSION=1.6.9
+VERSION=1.7.0

+ 8 - 0
CHANGELOG.md

@@ -1,3 +1,11 @@
+### v1.7.0 (2021-10-11)
+- Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
+- Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71))
+- Fixed bug related to creating new apps/bookmarks with custom icon ([#83](https://github.com/pawelmalak/flame/issues/83))
+- URL can now be assigned to notifications. Clicking on "New version is available" popup will now redirect to changelog ([#86](https://github.com/pawelmalak/flame/issues/86))
+- Added static fonts ([#94](https://github.com/pawelmalak/flame/issues/94))
+- Fixed bug with overriding app icon created with docker labels
+
 ### v1.6.9 (2021-10-09)
 - Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97))
 

+ 1 - 0
api.js

@@ -20,6 +20,7 @@ api.use('/api/config', require('./routes/config'));
 api.use('/api/weather', require('./routes/weather'));
 api.use('/api/categories', require('./routes/category'));
 api.use('/api/bookmarks', require('./routes/bookmark'));
+api.use('/api/queries', require('./routes/queries'));
 
 // Custom error handler
 api.use(errorHandler);

+ 1 - 1
client/.env

@@ -1 +1 @@
-REACT_APP_VERSION=1.6.9
+REACT_APP_VERSION=1.7.0

+ 6 - 7
client/public/index.html

@@ -4,16 +4,15 @@
     <meta charset="utf-8" />
     <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="description" content="Flame - self-hosted startpage for your server" />
-    <link rel="preconnect" href="https://fonts.gstatic.com">
-    <link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
-    <link rel="stylesheet" href="%PUBLIC_URL%/flame.css">
+    <meta
+      name="description"
+      content="Flame - self-hosted startpage for your server"
+    />
+    <link rel="stylesheet" href="%PUBLIC_URL%/flame.css" />
     <title>Flame</title>
   </head>
   <body>
-
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <div id="root"></div>
-    
   </body>
-</html>
+</html>

+ 8 - 5
client/src/App.tsx

@@ -1,5 +1,5 @@
 import { BrowserRouter, Route, Switch } from 'react-router-dom';
-import { getConfig, setTheme } from './store/actions';
+import { fetchQueries, getConfig, setTheme } from './store/actions';
 import 'external-svg-loader';
 
 // Redux
@@ -27,15 +27,18 @@ if (localStorage.theme) {
 // Check for updates
 checkVersion();
 
+// fetch queries
+store.dispatch<any>(fetchQueries());
+
 const App = (): JSX.Element => {
   return (
     <Provider store={store}>
       <BrowserRouter>
         <Switch>
-          <Route exact path='/' component={Home} />
-          <Route path='/settings' component={Settings} />
-          <Route path='/applications' component={Apps} />
-          <Route path='/bookmarks' component={Bookmarks} />
+          <Route exact path="/" component={Home} />
+          <Route path="/settings" component={Settings} />
+          <Route path="/applications" component={Apps} />
+          <Route path="/bookmarks" component={Bookmarks} />
         </Switch>
       </BrowserRouter>
       <NotificationCenter />

BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff


BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2


+ 38 - 36
client/src/components/Apps/AppForm/AppForm.tsx

@@ -22,7 +22,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
   const [formData, setFormData] = useState<NewApp>({
     name: '',
     url: '',
-    icon: ''
+    icon: '',
   });
 
   useEffect(() => {
@@ -30,13 +30,13 @@ const AppForm = (props: ComponentProps): JSX.Element => {
       setFormData({
         name: props.app.name,
         url: props.app.url,
-        icon: props.app.icon
+        icon: props.app.icon,
       });
     } else {
       setFormData({
         name: '',
         url: '',
-        icon: ''
+        icon: '',
       });
     }
   }, [props.app]);
@@ -44,7 +44,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
   const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
     setFormData({
       ...formData,
-      [e.target.name]: e.target.value
+      [e.target.name]: e.target.value,
     });
   };
 
@@ -59,6 +59,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
 
     const createFormData = (): FormData => {
       const data = new FormData();
+
       if (customIcon) {
         data.append('icon', customIcon);
       }
@@ -88,10 +89,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
     setFormData({
       name: '',
       url: '',
-      icon: ''
+      icon: '',
     });
-
-    setCustomIcon(null);
   };
 
   return (
@@ -100,33 +99,33 @@ const AppForm = (props: ComponentProps): JSX.Element => {
       formHandler={formSubmitHandler}
     >
       <InputGroup>
-        <label htmlFor='name'>App Name</label>
+        <label htmlFor="name">App Name</label>
         <input
-          type='text'
-          name='name'
-          id='name'
-          placeholder='Bookstack'
+          type="text"
+          name="name"
+          id="name"
+          placeholder="Bookstack"
           required
           value={formData.name}
-          onChange={e => inputChangeHandler(e)}
+          onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
       <InputGroup>
-        <label htmlFor='url'>App URL</label>
+        <label htmlFor="url">App URL</label>
         <input
-          type='text'
-          name='url'
-          id='url'
-          placeholder='bookstack.example.com'
+          type="text"
+          name="url"
+          id="url"
+          placeholder="bookstack.example.com"
           required
           value={formData.url}
-          onChange={e => inputChangeHandler(e)}
+          onChange={(e) => inputChangeHandler(e)}
         />
         <span>
           <a
-            href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
-            target='_blank'
-            rel='noreferrer'
+            href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
+            target="_blank"
+            rel="noreferrer"
           >
             {' '}
             Check supported URL formats
@@ -136,19 +135,19 @@ const AppForm = (props: ComponentProps): JSX.Element => {
       {!useCustomIcon ? (
         // use mdi icon
         <InputGroup>
-          <label htmlFor='icon'>App Icon</label>
+          <label htmlFor="icon">App Icon</label>
           <input
-            type='text'
-            name='icon'
-            id='icon'
-            placeholder='book-open-outline'
+            type="text"
+            name="icon"
+            id="icon"
+            placeholder="book-open-outline"
             required
             value={formData.icon}
-            onChange={e => inputChangeHandler(e)}
+            onChange={(e) => inputChangeHandler(e)}
           />
           <span>
             Use icon name from MDI.
-            <a href='https://materialdesignicons.com/' target='blank'>
+            <a href="https://materialdesignicons.com/" target="blank">
               {' '}
               Click here for reference
             </a>
@@ -163,17 +162,20 @@ const AppForm = (props: ComponentProps): JSX.Element => {
       ) : (
         // upload custom icon
         <InputGroup>
-          <label htmlFor='icon'>App Icon</label>
+          <label htmlFor="icon">App Icon</label>
           <input
-            type='file'
-            name='icon'
-            id='icon'
+            type="file"
+            name="icon"
+            id="icon"
             required
-            onChange={e => fileChangeHandler(e)}
-            accept='.jpg,.jpeg,.png,.svg'
+            onChange={(e) => fileChangeHandler(e)}
+            accept=".jpg,.jpeg,.png,.svg"
           />
           <span
-            onClick={() => toggleUseCustomIcon(!useCustomIcon)}
+            onClick={() => {
+              setCustomIcon(null);
+              toggleUseCustomIcon(!useCustomIcon);
+            }}
             className={classes.Switch}
           >
             Switch to MDI

+ 76 - 65
client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx

@@ -1,32 +1,40 @@
+// React
 import {
   useState,
   SyntheticEvent,
   Fragment,
   ChangeEvent,
-  useEffect
+  useEffect,
 } from 'react';
+
+// Redux
 import { connect } from 'react-redux';
+import {
+  getCategories,
+  addCategory,
+  addBookmark,
+  updateCategory,
+  updateBookmark,
+  createNotification,
+} from '../../../store/actions';
 
-import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
+// Typescript
 import {
   Bookmark,
   Category,
   GlobalState,
   NewBookmark,
   NewCategory,
-  NewNotification
+  NewNotification,
 } from '../../../interfaces';
 import { ContentType } from '../Bookmarks';
-import {
-  getCategories,
-  addCategory,
-  addBookmark,
-  updateCategory,
-  updateBookmark,
-  createNotification
-} from '../../../store/actions';
+
+// UI
+import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
+import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
 import Button from '../../UI/Buttons/Button/Button';
+
+// CSS
 import classes from './BookmarkForm.module.css';
 
 interface ComponentProps {
@@ -53,14 +61,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
   const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
   const [customIcon, setCustomIcon] = useState<File | null>(null);
   const [categoryName, setCategoryName] = useState<NewCategory>({
-    name: ''
+    name: '',
   });
 
   const [formData, setFormData] = useState<NewBookmark>({
     name: '',
     url: '',
     categoryId: -1,
-    icon: ''
+    icon: '',
   });
 
   // Load category data if provided for editing
@@ -79,14 +87,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
         name: props.bookmark.name,
         url: props.bookmark.url,
         categoryId: props.bookmark.categoryId,
-        icon: props.bookmark.icon
+        icon: props.bookmark.icon,
       });
     } else {
       setFormData({
         name: '',
         url: '',
         categoryId: -1,
-        icon: ''
+        icon: '',
       });
     }
   }, [props.bookmark]);
@@ -117,7 +125,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
         if (formData.categoryId === -1) {
           props.createNotification({
             title: 'Error',
-            message: 'Please select category'
+            message: 'Please select category',
           });
           return;
         }
@@ -133,10 +141,10 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
           name: '',
           url: '',
           categoryId: formData.categoryId,
-          icon: ''
+          icon: '',
         });
 
-        setCustomIcon(null);
+        // setCustomIcon(null);
       }
     } else {
       // Update
@@ -150,12 +158,12 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
           const data = createFormData();
           props.updateBookmark(props.bookmark.id, data, {
             prev: props.bookmark.categoryId,
-            curr: formData.categoryId
+            curr: formData.categoryId,
           });
         } else {
           props.updateBookmark(props.bookmark.id, formData, {
             prev: props.bookmark.categoryId,
-            curr: formData.categoryId
+            curr: formData.categoryId,
           });
         }
 
@@ -163,7 +171,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
           name: '',
           url: '',
           categoryId: -1,
-          icon: ''
+          icon: '',
         });
 
         setCustomIcon(null);
@@ -176,14 +184,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
   const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
     setFormData({
       ...formData,
-      [e.target.name]: e.target.value
+      [e.target.name]: e.target.value,
     });
   };
 
   const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
     setFormData({
       ...formData,
-      categoryId: parseInt(e.target.value)
+      categoryId: parseInt(e.target.value),
     });
   };
 
@@ -215,48 +223,48 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
       {props.contentType === ContentType.category ? (
         <Fragment>
           <InputGroup>
-            <label htmlFor='categoryName'>Category Name</label>
+            <label htmlFor="categoryName">Category Name</label>
             <input
-              type='text'
-              name='categoryName'
-              id='categoryName'
-              placeholder='Social Media'
+              type="text"
+              name="categoryName"
+              id="categoryName"
+              placeholder="Social Media"
               required
               value={categoryName.name}
-              onChange={e => setCategoryName({ name: e.target.value })}
+              onChange={(e) => setCategoryName({ name: e.target.value })}
             />
           </InputGroup>
         </Fragment>
       ) : (
         <Fragment>
           <InputGroup>
-            <label htmlFor='name'>Bookmark Name</label>
+            <label htmlFor="name">Bookmark Name</label>
             <input
-              type='text'
-              name='name'
-              id='name'
-              placeholder='Reddit'
+              type="text"
+              name="name"
+              id="name"
+              placeholder="Reddit"
               required
               value={formData.name}
-              onChange={e => inputChangeHandler(e)}
+              onChange={(e) => inputChangeHandler(e)}
             />
           </InputGroup>
           <InputGroup>
-            <label htmlFor='url'>Bookmark URL</label>
+            <label htmlFor="url">Bookmark URL</label>
             <input
-              type='text'
-              name='url'
-              id='url'
-              placeholder='reddit.com'
+              type="text"
+              name="url"
+              id="url"
+              placeholder="reddit.com"
               required
               value={formData.url}
-              onChange={e => inputChangeHandler(e)}
+              onChange={(e) => inputChangeHandler(e)}
             />
             <span>
               <a
-                href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
-                target='_blank'
-                rel='noreferrer'
+                href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
+                target="_blank"
+                rel="noreferrer"
               >
                 {' '}
                 Check supported URL formats
@@ -264,12 +272,12 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
             </span>
           </InputGroup>
           <InputGroup>
-            <label htmlFor='categoryId'>Bookmark Category</label>
+            <label htmlFor="categoryId">Bookmark Category</label>
             <select
-              name='categoryId'
-              id='categoryId'
+              name="categoryId"
+              id="categoryId"
               required
-              onChange={e => selectChangeHandler(e)}
+              onChange={(e) => selectChangeHandler(e)}
               value={formData.categoryId}
             >
               <option value={-1}>Select category</option>
@@ -285,18 +293,18 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
           {!useCustomIcon ? (
             // mdi
             <InputGroup>
-              <label htmlFor='icon'>Bookmark Icon (optional)</label>
+              <label htmlFor="icon">Bookmark Icon (optional)</label>
               <input
-                type='text'
-                name='icon'
-                id='icon'
-                placeholder='book-open-outline'
+                type="text"
+                name="icon"
+                id="icon"
+                placeholder="book-open-outline"
                 value={formData.icon}
-                onChange={e => inputChangeHandler(e)}
+                onChange={(e) => inputChangeHandler(e)}
               />
               <span>
                 Use icon name from MDI.
-                <a href='https://materialdesignicons.com/' target='blank'>
+                <a href="https://materialdesignicons.com/" target="blank">
                   {' '}
                   Click here for reference
                 </a>
@@ -311,16 +319,19 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
           ) : (
             // custom
             <InputGroup>
-              <label htmlFor='icon'>Bookmark Icon (optional)</label>
+              <label htmlFor="icon">Bookmark Icon (optional)</label>
               <input
-                type='file'
-                name='icon'
-                id='icon'
-                onChange={e => fileChangeHandler(e)}
-                accept='.jpg,.jpeg,.png,.svg'
+                type="file"
+                name="icon"
+                id="icon"
+                onChange={(e) => fileChangeHandler(e)}
+                accept=".jpg,.jpeg,.png,.svg"
               />
               <span
-                onClick={() => toggleUseCustomIcon(!useCustomIcon)}
+                onClick={() => {
+                  setCustomIcon(null);
+                  toggleUseCustomIcon(!useCustomIcon);
+                }}
                 className={classes.Switch}
               >
                 Switch to MDI
@@ -336,7 +347,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
 
 const mapStateToProps = (state: GlobalState) => {
   return {
-    categories: state.bookmark.categories
+    categories: state.bookmark.categories,
   };
 };
 
@@ -346,7 +357,7 @@ const dispatchMap = {
   addBookmark,
   updateCategory,
   updateBookmark,
-  createNotification
+  createNotification,
 };
 
 export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

+ 8 - 7
client/src/components/NotificationCenter/NotificationCenter.tsx

@@ -20,19 +20,20 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
           <Notification
             title={notification.title}
             message={notification.message}
+            url={notification.url || null}
             id={notification.id}
             key={notification.id}
           />
-        )
+        );
       })}
     </div>
-  )
-}
+  );
+};
 
 const mapStateToProps = (state: GlobalState) => {
   return {
-    notifications: state.notification.notifications
-  }
-}
+    notifications: state.notification.notifications,
+  };
+};
 
-export default connect(mapStateToProps)(NotificationCenter);
+export default connect(mapStateToProps)(NotificationCenter);

+ 14 - 14
client/src/components/SearchBar/SearchBar.tsx

@@ -11,7 +11,7 @@ import { NewNotification } from '../../interfaces';
 import classes from './SearchBar.module.css';
 
 // Utils
-import { searchParser } from '../../utility';
+import { searchParser, urlParser, redirectUrl } from '../../utility';
 
 interface ComponentProps {
   createNotification: (notification: NewNotification) => void;
@@ -28,28 +28,28 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
   }, []);
 
   const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
-    const searchResult = searchParser(inputRef.current.value);
+    const { isLocal, search, query, isURL, sameTab } = searchParser(
+      inputRef.current.value
+    );
 
-    if (searchResult.isLocal) {
-      setLocalSearch(searchResult.search);
+    if (isLocal) {
+      setLocalSearch(search);
     }
 
     if (e.code === 'Enter') {
-      if (!searchResult.query.prefix) {
+      if (!query.prefix) {
         createNotification({
           title: 'Error',
           message: 'Prefix not found',
         });
-      } else if (searchResult.isLocal) {
-        setLocalSearch(searchResult.search);
+      } else if (isURL) {
+        const url = urlParser(inputRef.current.value)[1];
+        redirectUrl(url, sameTab);
+      } else if (isLocal) {
+        setLocalSearch(search);
       } else {
-        if (searchResult.sameTab) {
-          document.location.replace(
-            `${searchResult.query.template}${searchResult.search}`
-          );
-        } else {
-          window.open(`${searchResult.query.template}${searchResult.search}`);
-        }
+        const url = `${query.template}${search}`;
+        redirectUrl(url, sameTab);
       }
     }
   };

+ 6 - 57
client/src/components/Settings/OtherSettings/OtherSettings.tsx

@@ -13,20 +13,16 @@ import {
 import {
   GlobalState,
   NewNotification,
-  Query,
   SettingsForm,
 } from '../../../interfaces';
 
 // UI
 import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
 import Button from '../../UI/Buttons/Button/Button';
-
-// CSS
-import classes from './OtherSettings.module.css';
+import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
 
 // Utils
 import { searchConfig } from '../../../utility';
-import { queries } from '../../../utility/searchQueries.json';
 
 interface ComponentProps {
   createNotification: (notification: NewNotification) => void;
@@ -45,12 +41,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
     hideHeader: 0,
     hideApps: 0,
     hideCategories: 0,
-    hideSearch: 0,
-    defaultSearchProvider: 'd',
     useOrdering: 'createdAt',
     appsSameTab: 0,
     bookmarksSameTab: 0,
-    searchSameTab: 0,
     dockerApps: 1,
     dockerHost: 'localhost',
     kubernetesApps: 1,
@@ -66,12 +59,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
       hideHeader: searchConfig('hideHeader', 0),
       hideApps: searchConfig('hideApps', 0),
       hideCategories: searchConfig('hideCategories', 0),
-      hideSearch: searchConfig('hideSearch', 0),
-      defaultSearchProvider: searchConfig('defaultSearchProvider', 'd'),
       useOrdering: searchConfig('useOrdering', 'createdAt'),
       appsSameTab: searchConfig('appsSameTab', 0),
       bookmarksSameTab: searchConfig('bookmarksSameTab', 0),
-      searchSameTab: searchConfig('searchSameTab', 0),
       dockerApps: searchConfig('dockerApps', 0),
       dockerHost: searchConfig('dockerHost', 'localhost'),
       kubernetesApps: searchConfig('kubernetesApps', 0),
@@ -114,7 +104,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
   return (
     <form onSubmit={(e) => formSubmitHandler(e)}>
       {/* OTHER OPTIONS */}
-      <h2 className={classes.SettingsSection}>Miscellaneous</h2>
+      <SettingsHeadline text="Miscellaneous" />
       <InputGroup>
         <label htmlFor="customTitle">Custom page title</label>
         <input
@@ -128,7 +118,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
       </InputGroup>
 
       {/* BEAHVIOR OPTIONS */}
-      <h2 className={classes.SettingsSection}>App Behavior</h2>
+      <SettingsHeadline text="App Behavior" />
       <InputGroup>
         <label htmlFor="pinAppsByDefault">
           Pin new applications by default
@@ -170,35 +160,6 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
           <option value="orderId">Custom order</option>
         </select>
       </InputGroup>
-      <InputGroup>
-        <label htmlFor="defaultSearchProvider">Default Search Provider</label>
-        <select
-          id="defaultSearchProvider"
-          name="defaultSearchProvider"
-          value={formData.defaultSearchProvider}
-          onChange={(e) => inputChangeHandler(e)}
-        >
-          {queries.map((query: Query, idx) => (
-            <option key={idx} value={query.prefix}>
-              {query.name}
-            </option>
-          ))}
-        </select>
-      </InputGroup>
-      <InputGroup>
-        <label htmlFor="searchSameTab">
-          Open search results in the same tab
-        </label>
-        <select
-          id="searchSameTab"
-          name="searchSameTab"
-          value={formData.searchSameTab}
-          onChange={(e) => inputChangeHandler(e, true)}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
-      </InputGroup>
       <InputGroup>
         <label htmlFor="appsSameTab">Open applications in the same tab</label>
         <select
@@ -225,19 +186,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
       </InputGroup>
 
       {/* MODULES OPTIONS */}
-      <h2 className={classes.SettingsSection}>Modules</h2>
-      <InputGroup>
-        <label htmlFor="hideSearch">Hide search bar</label>
-        <select
-          id="hideSearch"
-          name="hideSearch"
-          value={formData.hideSearch}
-          onChange={(e) => inputChangeHandler(e, true)}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
-      </InputGroup>
+      <SettingsHeadline text="Modules" />
       <InputGroup>
         <label htmlFor="hideHeader">Hide greeting and date</label>
         <select
@@ -276,7 +225,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
       </InputGroup>
 
       {/* DOCKER SETTINGS */}
-      <h2 className={classes.SettingsSection}>Docker</h2>
+      <SettingsHeadline text="Docker" />
       <InputGroup>
         <label htmlFor="dockerHost">Docker Host</label>
         <input
@@ -316,7 +265,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
       </InputGroup>
 
       {/* KUBERNETES SETTINGS */}
-      <h2 className={classes.SettingsSection}>Kubernetes</h2>
+      <SettingsHeadline text="Kubernetes" />
       <InputGroup>
         <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
         <select

+ 30 - 0
client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.module.css

@@ -0,0 +1,30 @@
+.QueriesGrid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+}
+
+.QueriesGrid span {
+  color: var(--color-primary);
+}
+
+.QueriesGrid span:last-child {
+  margin-bottom: 10px;
+}
+
+.ActionIcons {
+  display: flex;
+}
+
+.ActionIcons svg {
+  width: 20px;
+}
+
+.ActionIcons svg:hover {
+  cursor: pointer;
+}
+
+.Separator {
+  grid-column: 1 / 4;
+  border-bottom: 1px solid var(--color-primary);
+  margin: 10px 0;
+}

+ 112 - 0
client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx

@@ -0,0 +1,112 @@
+import { Fragment, useState } from 'react';
+import { connect } from 'react-redux';
+
+import classes from './CustomQueries.module.css';
+
+import Modal from '../../../UI/Modal/Modal';
+import Icon from '../../../UI/Icons/Icon/Icon';
+import { GlobalState, NewNotification, Query } from '../../../../interfaces';
+import QueriesForm from './QueriesForm';
+import { deleteQuery, createNotification } from '../../../../store/actions';
+import Button from '../../../UI/Buttons/Button/Button';
+import { searchConfig } from '../../../../utility';
+
+interface Props {
+  customQueries: Query[];
+  deleteQuery: (prefix: string) => {};
+  createNotification: (notification: NewNotification) => void;
+}
+
+const CustomQueries = (props: Props): JSX.Element => {
+  const { customQueries, deleteQuery, createNotification } = props;
+
+  const [modalIsOpen, setModalIsOpen] = useState(false);
+  const [editableQuery, setEditableQuery] = useState<Query | null>(null);
+
+  const updateHandler = (query: Query) => {
+    setEditableQuery(query);
+    setModalIsOpen(true);
+  };
+
+  const deleteHandler = (query: Query) => {
+    const currentProvider = searchConfig('defaultSearchProvider', 'l');
+    const isCurrent = currentProvider === query.prefix;
+
+    if (isCurrent) {
+      createNotification({
+        title: 'Error',
+        message: 'Cannot delete active provider',
+      });
+    } else if (
+      window.confirm(`Are you sure you want to delete this provider?`)
+    ) {
+      deleteQuery(query.prefix);
+    }
+  };
+
+  return (
+    <Fragment>
+      <Modal
+        isOpen={modalIsOpen}
+        setIsOpen={() => setModalIsOpen(!modalIsOpen)}
+      >
+        {editableQuery ? (
+          <QueriesForm
+            modalHandler={() => setModalIsOpen(!modalIsOpen)}
+            query={editableQuery}
+          />
+        ) : (
+          <QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
+        )}
+      </Modal>
+
+      <div>
+        <div className={classes.QueriesGrid}>
+          {customQueries.length > 0 && (
+            <Fragment>
+              <span>Name</span>
+              <span>Prefix</span>
+              <span>Actions</span>
+
+              <div className={classes.Separator}></div>
+            </Fragment>
+          )}
+
+          {customQueries.map((q: Query, idx) => (
+            <Fragment key={idx}>
+              <span>{q.name}</span>
+              <span>{q.prefix}</span>
+              <span className={classes.ActionIcons}>
+                <span onClick={() => updateHandler(q)}>
+                  <Icon icon="mdiPencil" />
+                </span>
+                <span onClick={() => deleteHandler(q)}>
+                  <Icon icon="mdiDelete" />
+                </span>
+              </span>
+            </Fragment>
+          ))}
+        </div>
+
+        <Button
+          click={() => {
+            setEditableQuery(null);
+            setModalIsOpen(true);
+          }}
+        >
+          Add new search provider
+        </Button>
+      </div>
+    </Fragment>
+  );
+};
+
+const mapStateToProps = (state: GlobalState) => {
+  return {
+    customQueries: state.config.customQueries,
+  };
+};
+
+export default connect(mapStateToProps, { deleteQuery, createNotification })(
+  CustomQueries
+);

+ 109 - 0
client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx

@@ -0,0 +1,109 @@
+import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
+import { Query } from '../../../../interfaces';
+import Button from '../../../UI/Buttons/Button/Button';
+import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
+import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
+import { connect } from 'react-redux';
+import { addQuery, updateQuery } from '../../../../store/actions';
+
+interface Props {
+  modalHandler: () => void;
+  addQuery: (query: Query) => {};
+  updateQuery: (query: Query, Oldprefix: string) => {};
+  query?: Query;
+}
+
+const QueriesForm = (props: Props): JSX.Element => {
+  const { modalHandler, addQuery, updateQuery, query } = props;
+
+  const [formData, setFormData] = useState<Query>({
+    name: '',
+    prefix: '',
+    template: '',
+  });
+
+  const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
+    const { name, value } = e.target;
+
+    setFormData({
+      ...formData,
+      [name]: value,
+    });
+  };
+
+  const formHandler = (e: FormEvent) => {
+    e.preventDefault();
+
+    if (query) {
+      updateQuery(formData, query.prefix);
+    } else {
+      addQuery(formData);
+    }
+
+    // close modal
+    modalHandler();
+
+    // clear form
+    setFormData({
+      name: '',
+      prefix: '',
+      template: '',
+    });
+  };
+
+  useEffect(() => {
+    if (query) {
+      setFormData(query);
+    } else {
+      setFormData({
+        name: '',
+        prefix: '',
+        template: '',
+      });
+    }
+  }, [query]);
+
+  return (
+    <ModalForm modalHandler={modalHandler} formHandler={formHandler}>
+      <InputGroup>
+        <label htmlFor="name">Name</label>
+        <input
+          type="text"
+          name="name"
+          id="name"
+          placeholder="Google"
+          required
+          value={formData.name}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+      <InputGroup>
+        <label htmlFor="name">Prefix</label>
+        <input
+          type="text"
+          name="prefix"
+          id="prefix"
+          placeholder="g"
+          required
+          value={formData.prefix}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+      <InputGroup>
+        <label htmlFor="name">Query Template</label>
+        <input
+          type="text"
+          name="template"
+          id="template"
+          placeholder="https://www.google.com/search?q="
+          required
+          value={formData.template}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+      {query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
+    </ModalForm>
+  );
+};
+
+export default connect(null, { addQuery, updateQuery })(QueriesForm);

+ 154 - 0
client/src/components/Settings/SearchSettings/SearchSettings.tsx

@@ -0,0 +1,154 @@
+// React
+import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
+import { connect } from 'react-redux';
+
+// State
+import { createNotification, updateConfig } from '../../../store/actions';
+
+// Typescript
+import {
+  GlobalState,
+  NewNotification,
+  Query,
+  SearchForm,
+} from '../../../interfaces';
+
+// Components
+import CustomQueries from './CustomQueries/CustomQueries';
+
+// UI
+import Button from '../../UI/Buttons/Button/Button';
+import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
+import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
+
+// Utils
+import { searchConfig } from '../../../utility';
+
+// Data
+import { queries } from '../../../utility/searchQueries.json';
+
+interface Props {
+  createNotification: (notification: NewNotification) => void;
+  updateConfig: (formData: SearchForm) => void;
+  loading: boolean;
+  customQueries: Query[];
+}
+
+const SearchSettings = (props: Props): JSX.Element => {
+  // Initial state
+  const [formData, setFormData] = useState<SearchForm>({
+    hideSearch: 0,
+    defaultSearchProvider: 'l',
+    searchSameTab: 0,
+  });
+
+  // Get config
+  useEffect(() => {
+    setFormData({
+      hideSearch: searchConfig('hideSearch', 0),
+      defaultSearchProvider: searchConfig('defaultSearchProvider', 'l'),
+      searchSameTab: searchConfig('searchSameTab', 0),
+    });
+  }, [props.loading]);
+
+  // Form handler
+  const formSubmitHandler = async (e: FormEvent) => {
+    e.preventDefault();
+
+    // Save settings
+    await props.updateConfig(formData);
+  };
+
+  // Input handler
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    isNumber?: boolean
+  ) => {
+    let value: string | number = e.target.value;
+
+    if (isNumber) {
+      value = parseFloat(value);
+    }
+
+    setFormData({
+      ...formData,
+      [e.target.name]: value,
+    });
+  };
+
+  return (
+    <Fragment>
+      {/* GENERAL SETTINGS */}
+      <form
+        onSubmit={(e) => formSubmitHandler(e)}
+        style={{ marginBottom: '30px' }}
+      >
+        <SettingsHeadline text="General" />
+        <InputGroup>
+          <label htmlFor="defaultSearchProvider">Default Search Provider</label>
+          <select
+            id="defaultSearchProvider"
+            name="defaultSearchProvider"
+            value={formData.defaultSearchProvider}
+            onChange={(e) => inputChangeHandler(e)}
+          >
+            {[...queries, ...props.customQueries].map((query: Query, idx) => {
+              const isCustom = idx >= queries.length;
+
+              return (
+                <option key={idx} value={query.prefix}>
+                  {isCustom && '+'} {query.name}
+                </option>
+              );
+            })}
+          </select>
+        </InputGroup>
+        <InputGroup>
+          <label htmlFor="searchSameTab">
+            Open search results in the same tab
+          </label>
+          <select
+            id="searchSameTab"
+            name="searchSameTab"
+            value={formData.searchSameTab}
+            onChange={(e) => inputChangeHandler(e, true)}
+          >
+            <option value={1}>True</option>
+            <option value={0}>False</option>
+          </select>
+        </InputGroup>
+        <InputGroup>
+          <label htmlFor="hideSearch">Hide search bar</label>
+          <select
+            id="hideSearch"
+            name="hideSearch"
+            value={formData.hideSearch}
+            onChange={(e) => inputChangeHandler(e, true)}
+          >
+            <option value={1}>True</option>
+            <option value={0}>False</option>
+          </select>
+        </InputGroup>
+        <Button>Save changes</Button>
+      </form>
+
+      {/* CUSTOM QUERIES */}
+      <SettingsHeadline text="Custom search providers" />
+      <CustomQueries />
+    </Fragment>
+  );
+};
+
+const mapStateToProps = (state: GlobalState) => {
+  return {
+    loading: state.config.loading,
+    customQueries: state.config.customQueries,
+  };
+};
+
+const actions = {
+  createNotification,
+  updateConfig,
+};
+
+export default connect(mapStateToProps, actions)(SearchSettings);

+ 38 - 50
client/src/components/Settings/Settings.tsx

@@ -1,73 +1,61 @@
+//
 import { NavLink, Link, Switch, Route } from 'react-router-dom';
 
-import classes from './Settings.module.css';
+// Typescript
+import { Route as SettingsRoute } from '../../interfaces';
 
-import { Container } from '../UI/Layout/Layout';
-import Headline from '../UI/Headlines/Headline/Headline';
+// CSS
+import classes from './Settings.module.css';
 
+// Components
 import Themer from '../Themer/Themer';
 import WeatherSettings from './WeatherSettings/WeatherSettings';
 import OtherSettings from './OtherSettings/OtherSettings';
 import AppDetails from './AppDetails/AppDetails';
 import StyleSettings from './StyleSettings/StyleSettings';
+import SearchSettings from './SearchSettings/SearchSettings';
+
+// UI
+import { Container } from '../UI/Layout/Layout';
+import Headline from '../UI/Headlines/Headline/Headline';
+
+// Data
+import { routes } from './settings.json';
 
 const Settings = (): JSX.Element => {
   return (
     <Container>
-      <Headline
-        title='Settings'
-        subtitle={<Link to='/'>Go back</Link>}
-      />
+      <Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
       <div className={classes.Settings}>
+        {/* NAVIGATION MENU */}
         <nav className={classes.SettingsNav}>
-          <NavLink
-            className={classes.SettingsNavLink}
-            activeClassName={classes.SettingsNavLinkActive}
-            exact
-            to='/settings'>
-            Theme
-          </NavLink>
-          <NavLink
-            className={classes.SettingsNavLink}
-            activeClassName={classes.SettingsNavLinkActive}
-            exact
-            to='/settings/weather'>
-            Weather
-          </NavLink>
-          <NavLink
-            className={classes.SettingsNavLink}
-            activeClassName={classes.SettingsNavLinkActive}
-            exact
-            to='/settings/other'>
-            Other
-          </NavLink>
-          <NavLink
-            className={classes.SettingsNavLink}
-            activeClassName={classes.SettingsNavLinkActive}
-            exact
-            to='/settings/css'>
-            CSS
-          </NavLink>
-          <NavLink
-            className={classes.SettingsNavLink}
-            activeClassName={classes.SettingsNavLinkActive}
-            exact
-            to='/settings/app'>
-            App
-          </NavLink>
+          {routes.map(({ name, dest }: SettingsRoute, idx) => (
+            <NavLink
+              className={classes.SettingsNavLink}
+              activeClassName={classes.SettingsNavLinkActive}
+              exact
+              to={dest}
+              key={idx}
+            >
+              {name}
+            </NavLink>
+          ))}
         </nav>
+
+        {/* ROUTES */}
         <section className={classes.SettingsContent}>
           <Switch>
-            <Route exact path='/settings' component={Themer} />
-            <Route path='/settings/weather' component={WeatherSettings} />
-            <Route path='/settings/other' component={OtherSettings} />
-            <Route path='/settings/css' component={StyleSettings} />
-            <Route path='/settings/app' component={AppDetails} />
+            <Route exact path="/settings" component={Themer} />
+            <Route path="/settings/weather" component={WeatherSettings} />
+            <Route path="/settings/search" component={SearchSettings} />
+            <Route path="/settings/other" component={OtherSettings} />
+            <Route path="/settings/css" component={StyleSettings} />
+            <Route path="/settings/app" component={AppDetails} />
           </Switch>
         </section>
       </div>
     </Container>
-  )
-}
+  );
+};
 
-export default Settings;
+export default Settings;

+ 28 - 0
client/src/components/Settings/settings.json

@@ -0,0 +1,28 @@
+{
+  "routes": [
+    {
+      "name": "Theme",
+      "dest": "/settings"
+    },
+    {
+      "name": "Weather",
+      "dest": "/settings/weather"
+    },
+    {
+      "name": "Search",
+      "dest": "/settings/search"
+    },
+    {
+      "name": "Other",
+      "dest": "/settings/other"
+    },
+    {
+      "name": "CSS",
+      "dest": "/settings/css"
+    },
+    {
+      "name": "App",
+      "dest": "/settings/app"
+    }
+  ]
+}

+ 2 - 2
client/src/components/Settings/OtherSettings/OtherSettings.module.css → client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.module.css

@@ -1,4 +1,4 @@
-.SettingsSection {
+.SettingsHeadline {
   color: var(--color-primary);
   padding-bottom: 3px;
   margin-bottom: 10px;
@@ -6,4 +6,4 @@
   font-weight: 500;
   border-bottom: 2px solid var(--color-accent);
   display: inline-block;
-}
+}

+ 11 - 0
client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx

@@ -0,0 +1,11 @@
+const classes = require('./SettingsHeadline.module.css');
+
+interface Props {
+  text: string;
+}
+
+const SettingsHeadline = (props: Props): JSX.Element => {
+  return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
+};
+
+export default SettingsHeadline;

+ 17 - 7
client/src/components/UI/Notification/Notification.tsx

@@ -8,12 +8,16 @@ interface ComponentProps {
   title: string;
   message: string;
   id: number;
+  url: string | null;
   clearNotification: (id: number) => void;
 }
 
 const Notification = (props: ComponentProps): JSX.Element => {
   const [isOpen, setIsOpen] = useState(true);
-  const elementClasses = [classes.Notification, isOpen ? classes.NotificationOpen : classes.NotificationClose].join(' ');
+  const elementClasses = [
+    classes.Notification,
+    isOpen ? classes.NotificationOpen : classes.NotificationClose,
+  ].join(' ');
 
   useEffect(() => {
     const closeNotification = setTimeout(() => {
@@ -22,21 +26,27 @@ const Notification = (props: ComponentProps): JSX.Element => {
 
     const clearNotification = setTimeout(() => {
       props.clearNotification(props.id);
-    }, 3600)
+    }, 3600);
 
     return () => {
       window.clearTimeout(closeNotification);
       window.clearTimeout(clearNotification);
+    };
+  }, []);
+
+  const clickHandler = () => {
+    if (props.url) {
+      window.open(props.url, '_blank');
     }
-  }, [])
+  };
 
   return (
-    <div className={elementClasses}>
+    <div className={elementClasses} onClick={clickHandler}>
       <h4>{props.title}</h4>
       <p>{props.message}</p>
       <div className={classes.Pog}></div>
     </div>
-  )
-}
+  );
+};
 
-export default connect(null, { clearNotification })(Notification);
+export default connect(null, { clearNotification })(Notification);

+ 41 - 5
client/src/index.css

@@ -1,3 +1,39 @@
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local(''),
+    url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff2') format('woff2'),
+    url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff') format('woff');
+}
+
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 500;
+  src: local(''),
+    url('./assets/fonts/Roboto/roboto-v29-latin-500.woff2') format('woff2'),
+    url('./assets/fonts/Roboto/roboto-v29-latin-500.woff') format('woff');
+}
+
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 900;
+  src: local(''),
+    url('./assets/fonts/Roboto/roboto-v29-latin-900.woff2') format('woff2'),
+    url('./assets/fonts/Roboto/roboto-v29-latin-900.woff') format('woff');
+}
+
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 700;
+  src: local(''),
+    url('./assets/fonts/Roboto/roboto-v29-latin-700.woff2') format('woff2'),
+    url('./assets/fonts/Roboto/roboto-v29-latin-700.woff') format('woff');
+}
+
 * {
   margin: 0;
   padding: 0;
@@ -5,18 +41,18 @@
 }
 
 body {
-  --color-background: #2B2C56;
-  --color-primary: #EFF1FC;
-  --color-accent: #6677EB;
+  --color-background: #242b33;
+  --color-primary: #effbff;
+  --color-accent: #6ee2ff;
   --spacing-ui: 10px;
 
   background-color: var(--color-background);
   transition: background-color 0.3s;
-  font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Roboto, sans-serif;
+  font-family: Roboto, sans-serif;
   font-size: 14px;
 }
 
 a {
   color: var(--color-primary);
   text-decoration: none;
-}
+}

+ 9 - 3
client/src/interfaces/Forms.ts

@@ -5,6 +5,12 @@ export interface WeatherForm {
   isCelsius: number;
 }
 
+export interface SearchForm {
+  hideSearch: number;
+  defaultSearchProvider: string;
+  searchSameTab: number;
+}
+
 export interface SettingsForm {
   customTitle: string;
   pinAppsByDefault: number;
@@ -12,12 +18,12 @@ export interface SettingsForm {
   hideHeader: number;
   hideApps: number;
   hideCategories: number;
-  hideSearch: number;
-  defaultSearchProvider: string;
+  // hideSearch: number;
+  // defaultSearchProvider: string;
   useOrdering: string;
   appsSameTab: number;
   bookmarksSameTab: number;
-  searchSameTab: number;
+  // searchSameTab: number;
   dockerApps: number;
   dockerHost: string;
   kubernetesApps: number;

+ 2 - 1
client/src/interfaces/Notification.ts

@@ -1,8 +1,9 @@
 export interface NewNotification {
   title: string;
   message: string;
+  url?: string;
 }
 
 export interface Notification extends NewNotification {
   id: number;
-}
+}

+ 4 - 0
client/src/interfaces/Route.ts

@@ -0,0 +1,4 @@
+export interface Route {
+  name: string;
+  dest: string;
+}

+ 1 - 0
client/src/interfaces/SearchResult.ts

@@ -2,6 +2,7 @@ import { Query } from './Query';
 
 export interface SearchResult {
   isLocal: boolean;
+  isURL: boolean;
   sameTab: boolean;
   search: string;
   query: Query;

+ 1 - 0
client/src/interfaces/index.ts

@@ -10,3 +10,4 @@ export * from './Config';
 export * from './Forms';
 export * from './Query';
 export * from './SearchResult';
+export * from './Route';

+ 39 - 25
client/src/store/actions/actionTypes.ts

@@ -26,8 +26,14 @@ import {
   ClearNotificationAction,
   // Config
   GetConfigAction,
-  UpdateConfigAction
+  UpdateConfigAction,
 } from './';
+import {
+  AddQueryAction,
+  DeleteQueryAction,
+  FetchQueriesAction,
+  UpdateQueryAction,
+} from './config';
 
 export enum ActionTypes {
   // Theme
@@ -62,35 +68,43 @@ export enum ActionTypes {
   clearNotification = 'CLEAR_NOTIFICATION',
   // Config
   getConfig = 'GET_CONFIG',
-  updateConfig = 'UPDATE_CONFIG'
+  updateConfig = 'UPDATE_CONFIG',
+  fetchQueries = 'FETCH_QUERIES',
+  addQuery = 'ADD_QUERY',
+  deleteQuery = 'DELETE_QUERY',
+  updateQuery = 'UPDATE_QUERY',
 }
 
-export type Action = 
+export type Action =
   // Theme
-  SetThemeAction |
+  | SetThemeAction
   // Apps
-  GetAppsAction<any> |
-  PinAppAction |
-  AddAppAction |
-  DeleteAppAction |
-  UpdateAppAction |
-  ReorderAppsAction |
-  SortAppsAction |
+  | GetAppsAction<any>
+  | PinAppAction
+  | AddAppAction
+  | DeleteAppAction
+  | UpdateAppAction
+  | ReorderAppsAction
+  | SortAppsAction
   // Categories
-  GetCategoriesAction<any> |
-  AddCategoryAction |
-  PinCategoryAction |
-  DeleteCategoryAction |
-  UpdateCategoryAction |
-  SortCategoriesAction |
-  ReorderCategoriesAction |
+  | GetCategoriesAction<any>
+  | AddCategoryAction
+  | PinCategoryAction
+  | DeleteCategoryAction
+  | UpdateCategoryAction
+  | SortCategoriesAction
+  | ReorderCategoriesAction
   // Bookmarks
-  AddBookmarkAction |
-  DeleteBookmarkAction |
-  UpdateBookmarkAction |
+  | AddBookmarkAction
+  | DeleteBookmarkAction
+  | UpdateBookmarkAction
   // Notifications
-  CreateNotificationAction |
-  ClearNotificationAction |
+  | CreateNotificationAction
+  | ClearNotificationAction
   // Config
-  GetConfigAction |
-  UpdateConfigAction;
+  | GetConfigAction
+  | UpdateConfigAction
+  | FetchQueriesAction
+  | AddQueryAction
+  | DeleteQueryAction
+  | UpdateQueryAction;

+ 95 - 12
client/src/store/actions/config.ts

@@ -1,7 +1,7 @@
 import axios from 'axios';
 import { Dispatch } from 'redux';
 import { ActionTypes } from './actionTypes';
-import { Config, ApiResponse } from '../../interfaces';
+import { Config, ApiResponse, Query } from '../../interfaces';
 import { CreateNotificationAction } from './notification';
 import { searchConfig } from '../../utility';
 
@@ -13,18 +13,18 @@ export interface GetConfigAction {
 export const getConfig = () => async (dispatch: Dispatch) => {
   try {
     const res = await axios.get<ApiResponse<Config[]>>('/api/config');
-   
+
     dispatch<GetConfigAction>({
       type: ActionTypes.getConfig,
-      payload: res.data.data
-    })
+      payload: res.data.data,
+    });
 
     // Set custom page title if set
     document.title = searchConfig('customTitle', 'Flame');
   } catch (err) {
-    console.log(err)
+    console.log(err);
   }
-}
+};
 
 export interface UpdateConfigAction {
   type: ActionTypes.updateConfig;
@@ -34,19 +34,102 @@ export interface UpdateConfigAction {
 export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
   try {
     const res = await axios.put<ApiResponse<Config[]>>('/api/config', formData);
+
     dispatch<CreateNotificationAction>({
       type: ActionTypes.createNotification,
       payload: {
         title: 'Success',
-        message: 'Settings updated'
-      }
-    })
+        message: 'Settings updated',
+      },
+    });
 
     dispatch<UpdateConfigAction>({
       type: ActionTypes.updateConfig,
-      payload: res.data.data
-    })
+      payload: res.data.data,
+    });
   } catch (err) {
     console.log(err);
   }
-}
+};
+
+export interface FetchQueriesAction {
+  type: ActionTypes.fetchQueries;
+  payload: Query[];
+}
+
+export const fetchQueries =
+  () => async (dispatch: Dispatch<FetchQueriesAction>) => {
+    try {
+      const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
+
+      dispatch<FetchQueriesAction>({
+        type: ActionTypes.fetchQueries,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export interface AddQueryAction {
+  type: ActionTypes.addQuery;
+  payload: Query;
+}
+
+export const addQuery =
+  (query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<Query>>('/api/queries', query);
+
+      dispatch<AddQueryAction>({
+        type: ActionTypes.addQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export interface DeleteQueryAction {
+  type: ActionTypes.deleteQuery;
+  payload: Query[];
+}
+
+export const deleteQuery =
+  (prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
+    try {
+      const res = await axios.delete<ApiResponse<Query[]>>(
+        `/api/queries/${prefix}`
+      );
+
+      dispatch<DeleteQueryAction>({
+        type: ActionTypes.deleteQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export interface UpdateQueryAction {
+  type: ActionTypes.updateQuery;
+  payload: Query[];
+}
+
+export const updateQuery =
+  (query: Query, oldPrefix: string) =>
+  async (dispatch: Dispatch<UpdateQueryAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<Query[]>>(
+        `/api/queries/${oldPrefix}`,
+        query
+      );
+
+      dispatch<UpdateQueryAction>({
+        type: ActionTypes.updateQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };

+ 57 - 15
client/src/store/reducers/config.ts

@@ -1,36 +1,78 @@
 import { ActionTypes, Action } from '../actions';
-import { Config } from '../../interfaces';
+import { Config, Query } from '../../interfaces';
 
 export interface State {
   loading: boolean;
   config: Config[];
+  customQueries: Query[];
 }
 
 const initialState: State = {
   loading: true,
-  config: []
-}
+  config: [],
+  customQueries: [],
+};
 
 const getConfig = (state: State, action: Action): State => {
   return {
+    ...state,
     loading: false,
-    config: action.payload
-  }
-}
+    config: action.payload,
+  };
+};
 
 const updateConfig = (state: State, action: Action): State => {
   return {
     ...state,
-    config: action.payload
-  }
-}
+    config: action.payload,
+  };
+};
+
+const fetchQueries = (state: State, action: Action): State => {
+  return {
+    ...state,
+    customQueries: action.payload,
+  };
+};
+
+const addQuery = (state: State, action: Action): State => {
+  return {
+    ...state,
+    customQueries: [...state.customQueries, action.payload],
+  };
+};
+
+const deleteQuery = (state: State, action: Action): State => {
+  return {
+    ...state,
+    customQueries: action.payload,
+  };
+};
+
+const updateQuery = (state: State, action: Action): State => {
+  return {
+    ...state,
+    customQueries: action.payload,
+  };
+};
 
 const configReducer = (state: State = initialState, action: Action) => {
-  switch(action.type) {
-    case ActionTypes.getConfig: return getConfig(state, action);
-    case ActionTypes.updateConfig: return updateConfig(state, action);
-    default: return state;
+  switch (action.type) {
+    case ActionTypes.getConfig:
+      return getConfig(state, action);
+    case ActionTypes.updateConfig:
+      return updateConfig(state, action);
+    case ActionTypes.fetchQueries:
+      return fetchQueries(state, action);
+    case ActionTypes.addQuery:
+      return addQuery(state, action);
+    case ActionTypes.deleteQuery:
+      return deleteQuery(state, action);
+    case ActionTypes.updateQuery:
+      return updateQuery(state, action);
+    default:
+      return state;
   }
-}
+};
 
-export default configReducer;
+export default configReducer;

+ 18 - 11
client/src/utility/checkVersion.ts

@@ -4,24 +4,31 @@ import { createNotification } from '../store/actions';
 
 export const checkVersion = async (isForced: boolean = false) => {
   try {
-    const res = await axios.get<string>('https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env');
+    const res = await axios.get<string>(
+      'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env'
+    );
 
     const githubVersion = res.data
       .split('\n')
-      .map(pair => pair.split('='))[0][1];
+      .map((pair) => pair.split('='))[0][1];
 
     if (githubVersion !== process.env.REACT_APP_VERSION) {
-      store.dispatch<any>(createNotification({
-        title: 'Info',
-        message: 'New version is available!'
-      }))
+      store.dispatch<any>(
+        createNotification({
+          title: 'Info',
+          message: 'New version is available!',
+          url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md',
+        })
+      );
     } else if (isForced) {
-      store.dispatch<any>(createNotification({
-        title: 'Info',
-        message: 'You are using the latest version!'
-      }))
+      store.dispatch<any>(
+        createNotification({
+          title: 'Info',
+          message: 'You are using the latest version!',
+        })
+      );
     }
   } catch (err) {
     console.log(err);
   }
-}
+};

+ 2 - 1
client/src/utility/index.ts

@@ -3,4 +3,5 @@ export * from './urlParser';
 export * from './searchConfig';
 export * from './checkVersion';
 export * from './sortData';
-export * from './searchParser';
+export * from './searchParser';
+export * from './redirectUrl';

+ 7 - 0
client/src/utility/redirectUrl.ts

@@ -0,0 +1,7 @@
+export const redirectUrl = (url: string, sameTab: boolean) => {
+  if (sameTab) {
+    document.location.replace(url);
+  } else {
+    window.open(url);
+  }
+};

+ 15 - 2
client/src/utility/searchParser.ts

@@ -1,11 +1,12 @@
 import { queries } from './searchQueries.json';
 import { Query, SearchResult } from '../interfaces';
-
+import { store } from '../store/store';
 import { searchConfig } from '.';
 
 export const searchParser = (searchQuery: string): SearchResult => {
   const result: SearchResult = {
     isLocal: false,
+    isURL: false,
     sameTab: false,
     search: '',
     query: {
@@ -15,6 +16,15 @@ export const searchParser = (searchQuery: string): SearchResult => {
     },
   };
 
+  const customQueries = store.getState().config.customQueries;
+
+  // Check if url or ip was passed
+  const urlRegex =
+    /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;
+
+  result.isURL = urlRegex.test(searchQuery);
+
+  // Match prefix and query
   const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
 
   const prefix = splitQuery
@@ -25,8 +35,11 @@ export const searchParser = (searchQuery: string): SearchResult => {
     ? encodeURIComponent(splitQuery[2])
     : encodeURIComponent(searchQuery);
 
-  const query = queries.find((q: Query) => q.prefix === prefix);
+  const query = [...queries, ...customQueries].find(
+    (q: Query) => q.prefix === prefix
+  );
 
+  // If search provider was found
   if (query) {
     result.query = query;
     result.search = search;

+ 4 - 1
controllers/apps.js

@@ -131,7 +131,10 @@ exports.getApps = asyncWrapper(async (req, res, next) => {
         if (apps.some((app) => app.name === item.name)) {
           const app = apps.filter((e) => e.name === item.name)[0];
 
-          if (item.icon === 'custom') {
+          if (
+            item.icon === 'custom' ||
+            (item.icon === 'docker' && app.icon != 'docker')
+          ) {
             await app.update({
               name: item.name,
               url: item.url,

+ 1 - 1
controllers/config.js

@@ -162,7 +162,7 @@ exports.getCss = asyncWrapper(async (req, res, next) => {
 // @access    Public
 exports.updateCss = asyncWrapper(async (req, res, next) => {
   const file = new File(join(__dirname, '../public/flame.css'));
-  file.write(req.body.styles);
+  file.write(req.body.styles, false);
 
   // Copy file to docker volume
   fs.copyFileSync(

+ 81 - 0
controllers/queries/index.js

@@ -0,0 +1,81 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+const { join } = require('path');
+
+const QUERIES_PATH = join(__dirname, '../../data/customQueries.json');
+
+// @desc      Add custom search query
+// @route     POST /api/queries
+// @access    Public
+exports.addQuery = asyncWrapper(async (req, res, next) => {
+  const file = new File(QUERIES_PATH);
+  let content = JSON.parse(file.read());
+
+  // Add new query
+  content.queries.push(req.body);
+  file.write(content, true);
+
+  res.status(201).json({
+    success: true,
+    data: req.body,
+  });
+});
+
+// @desc      Get custom queries file
+// @route     GET /api/queries
+// @access    Public
+exports.getQueries = asyncWrapper(async (req, res, next) => {
+  const file = new File(QUERIES_PATH);
+  const content = JSON.parse(file.read());
+
+  res.status(200).json({
+    success: true,
+    data: content.queries,
+  });
+});
+
+// @desc      Update query
+// @route     PUT /api/queries/:prefix
+// @access    Public
+exports.updateQuery = asyncWrapper(async (req, res, next) => {
+  const file = new File(QUERIES_PATH);
+  let content = JSON.parse(file.read());
+
+  let queryIdx = content.queries.findIndex(
+    (q) => q.prefix == req.params.prefix
+  );
+
+  // query found
+  if (queryIdx > -1) {
+    content.queries = [
+      ...content.queries.slice(0, queryIdx),
+      req.body,
+      ...content.queries.slice(queryIdx + 1),
+    ];
+  }
+
+  file.write(content, true);
+
+  res.status(200).json({
+    success: true,
+    data: content.queries,
+  });
+});
+
+// @desc      Delete query
+// @route     DELETE /api/queries/:prefix
+// @access    Public
+exports.deleteQuery = asyncWrapper(async (req, res, next) => {
+  const file = new File(QUERIES_PATH);
+  let content = JSON.parse(file.read());
+
+  content.queries = content.queries.filter(
+    (q) => q.prefix != req.params.prefix
+  );
+  file.write(content, true);
+
+  res.status(200).json({
+    success: true,
+    data: content.queries,
+  });
+});

+ 4 - 3
package.json

@@ -5,10 +5,11 @@
   "main": "index.js",
   "scripts": {
     "start": "node server.js",
-    "init-server": "echo Instaling server dependencies && npm install && mkdir public && touch public/flame.css",
+    "init-server": "echo Instaling server dependencies && npm install",
     "init-client": "cd client && echo Instaling client dependencies && npm install",
-    "dev-init": "npm run init-server && npm run init-client",
-    "dev-server": "nodemon server.js",
+    "dir-init": "npx mkdirp data public && touch public/flame.css public/customQueries.json",
+    "dev-init": "npm run dir-init && npm run init-server && npm run init-client",
+    "dev-server": "nodemon server.js -e js",
     "dev-client": "npm start --prefix client",
     "dev": "concurrently \"npm run dev-server\" \"npm run dev-client\"",
     "skaffold": "concurrently \"npm run init-client\" \"npm run dev-server\""

+ 4 - 15
routes/config.js

@@ -12,21 +12,10 @@ const {
   getCss,
 } = require('../controllers/config');
 
-router
-  .route('/')
-  .post(createPair)
-  .get(getAllPairs)
-  .put(updateValues);
+router.route('/').post(createPair).get(getAllPairs).put(updateValues);
 
-router
-  .route('/:key')
-  .get(getSinglePair)
-  .put(updateValue)
-  .delete(deletePair);
+router.route('/:key').get(getSinglePair).put(updateValue).delete(deletePair);
 
-router
-  .route('/0/css')
-  .get(getCss)
-  .put(updateCss);
+router.route('/0/css').get(getCss).put(updateCss);
 
-module.exports = router;
+module.exports = router;

+ 14 - 0
routes/queries.js

@@ -0,0 +1,14 @@
+const express = require('express');
+const router = express.Router();
+
+const {
+  getQueries,
+  addQuery,
+  deleteQuery,
+  updateQuery,
+} = require('../controllers/queries/');
+
+router.route('/').post(addQuery).get(getQueries);
+router.route('/:prefix').delete(deleteQuery).put(updateQuery);
+
+module.exports = router;

+ 17 - 10
server.js

@@ -1,23 +1,28 @@
 require('dotenv').config();
 const http = require('http');
+
+// Database
 const { connectDB } = require('./db');
+const associateModels = require('./models/associateModels');
+
+// Server
 const api = require('./api');
 const jobs = require('./utils/jobs');
 const Socket = require('./Socket');
 const Sockets = require('./Sockets');
-const associateModels = require('./models/associateModels');
-const initConfig = require('./utils/initConfig');
-const findCss = require('./utils/findCss');
+
+// Utils
+const initApp = require('./utils/init');
 const Logger = require('./utils/Logger');
 const logger = new Logger();
 
-const PORT = process.env.PORT || 5005;
-
 (async () => {
+  const PORT = process.env.PORT || 5005;
+
+  // Init app
   await connectDB();
   await associateModels();
-  await initConfig();
-  findCss();
+  await initApp();
 
   // Create server for Express API and WebSockets
   const server = http.createServer();
@@ -28,6 +33,8 @@ const PORT = process.env.PORT || 5005;
   Sockets.registerSocket('weather', weatherSocket);
 
   server.listen(PORT, () => {
-    logger.log(`Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`);
-  })
-})();
+    logger.log(
+      `Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`
+    );
+  });
+})();

+ 7 - 4
utils/File.js

@@ -3,7 +3,7 @@ const fs = require('fs');
 class File {
   constructor(path) {
     this.path = path;
-    this.content = '';
+    this.content = null;
   }
 
   read() {
@@ -16,10 +16,13 @@ class File {
     }
   }
 
-  write(data) {
+  write(data, isJSON) {
     this.content = data;
-    fs.writeFileSync(this.path, this.content);
+    fs.writeFileSync(
+      this.path,
+      isJSON ? JSON.stringify(this.content) : this.content
+    );
   }
 }
 
-module.exports = File;
+module.exports = File;

+ 0 - 22
utils/findCss.js

@@ -1,22 +0,0 @@
-const fs = require('fs');
-const { join } = require('path');
-const Logger = require('./Logger');
-const logger = new Logger();
-
-// Check if flame.css exists in mounted docker volume. Create new file if not
-const findCss = () => {
-  const srcPath = join(__dirname, '../data/flame.css');
-  const destPath = join(__dirname, '../public/flame.css');
-
-  if (fs.existsSync(srcPath)) {
-    fs.copyFileSync(srcPath, destPath);
-    logger.log('Custom CSS file found');
-    return;
-  }
-
-  logger.log('Creating empty CSS file');
-  fs.writeFileSync(destPath, '');
-
-}
-
-module.exports = findCss;

+ 32 - 0
utils/init/createFile.js

@@ -0,0 +1,32 @@
+const fs = require('fs');
+const { join } = require('path');
+
+const Logger = require('../Logger');
+const logger = new Logger();
+
+const createFile = async (file) => {
+  const { name, msg, template, isJSON, paths } = file;
+
+  const srcPath = join(__dirname, paths.src, name);
+  const destPath = join(__dirname, paths.dest, name);
+
+  // Check if file exists
+  if (fs.existsSync(srcPath)) {
+    fs.copyFileSync(srcPath, destPath);
+
+    if (process.env.NODE_ENV == 'development') {
+      logger.log(msg.found);
+    }
+
+    return;
+  }
+
+  // Create file if not
+  fs.writeFileSync(destPath, isJSON ? JSON.stringify(template) : template);
+
+  if (process.env.NODE_ENV == 'development') {
+    logger.log(msg.created);
+  }
+};
+
+module.exports = createFile;

+ 9 - 0
utils/init/index.js

@@ -0,0 +1,9 @@
+const initConfig = require('./initConfig');
+const initFiles = require('./initFiles');
+
+const initApp = async () => {
+  await initConfig();
+  await initFiles();
+};
+
+module.exports = initApp;

+ 7 - 3
utils/initConfig.js → utils/init/initConfig.js

@@ -1,7 +1,8 @@
 const { Op } = require('sequelize');
-const Config = require('../models/Config');
+const Config = require('../../models/Config');
 const { config } = require('./initialConfig.json');
-const Logger = require('./Logger');
+
+const Logger = require('../Logger');
 const logger = new Logger();
 
 const initConfig = async () => {
@@ -28,7 +29,10 @@ const initConfig = async () => {
     }
   });
 
-  logger.log('Initial config created');
+  if (process.env.NODE_ENV == 'development') {
+    logger.log('Initial config created');
+  }
+
   return;
 };
 

+ 8 - 0
utils/init/initFiles.js

@@ -0,0 +1,8 @@
+const createFile = require('./createFile');
+const { files } = require('./initialFiles.json');
+
+const initFiles = async () => {
+  files.forEach(async (file) => await createFile(file));
+};
+
+module.exports = initFiles;

+ 0 - 0
utils/initialConfig.json → utils/init/initialConfig.json


+ 32 - 0
utils/init/initialFiles.json

@@ -0,0 +1,32 @@
+{
+  "files": [
+    {
+      "name": "flame.css",
+      "msg": {
+        "created": "Created empty CSS file",
+        "found": "Custom CSS file found"
+      },
+      "paths": {
+        "src": "../../data",
+        "dest": "../../public"
+      },
+      "template": "",
+      "isJSON": false
+    },
+    {
+      "name": "customQueries.json",
+      "msg": {
+        "created": "Created empty queries file",
+        "found": "Custom queries file found"
+      },
+      "paths": {
+        "src": "../../data",
+        "dest": "../../data"
+      },
+      "template": {
+        "queries": []
+      },
+      "isJSON": true
+    }
+  ]
+}